From 0aa20d78730239fd7e8bef639606c6c5be9d5e32 Mon Sep 17 00:00:00 2001 From: Guilherme Guilherme Date: Mon, 18 May 2026 23:20:03 +0100 Subject: [PATCH 01/18] Add artifact scripts --- .../artifacts/AIChatbotNovaCachedImages.py | 172 ++++++ .../artifacts/AIChatbotNovaConversations.py | 579 ++++++++++++++++++ scripts/artifacts/AIChatbotNovaHistory.py | 276 +++++++++ .../artifacts/AIChatbotNovaHistoryDetail.py | 329 ++++++++++ .../AIChatbotNovaHistoryDetailDocument.py | 411 +++++++++++++ .../AIChatbotNovaHistoryDetailImage.py | 395 ++++++++++++ .../AIChatbotNovaHistoryDetailLink.py | 296 +++++++++ 7 files changed, 2458 insertions(+) create mode 100644 scripts/artifacts/AIChatbotNovaCachedImages.py create mode 100644 scripts/artifacts/AIChatbotNovaConversations.py create mode 100644 scripts/artifacts/AIChatbotNovaHistory.py create mode 100644 scripts/artifacts/AIChatbotNovaHistoryDetail.py create mode 100644 scripts/artifacts/AIChatbotNovaHistoryDetailDocument.py create mode 100644 scripts/artifacts/AIChatbotNovaHistoryDetailImage.py create mode 100644 scripts/artifacts/AIChatbotNovaHistoryDetailLink.py diff --git a/scripts/artifacts/AIChatbotNovaCachedImages.py b/scripts/artifacts/AIChatbotNovaCachedImages.py new file mode 100644 index 00000000..f58839ce --- /dev/null +++ b/scripts/artifacts/AIChatbotNovaCachedImages.py @@ -0,0 +1,172 @@ +__artifacts_v2__ = { + "nova_cache_images": { + "name": "Nova AI Chatbot - Cached Images (Glide Disk Cache)", + "description": ( + "Extracts all cached image files from the Nova AI Chatbot app's Glide disk cache " + "(cache/image_manager_disk_cache/*.0). These .0 files are raw JPEG images that were " + "downloaded from Firebase Storage and cached locally. The module embeds the original " + "cache file paths as file:// URLs in the HTML report, allowing direct preview from " + "the extracted data folder. No copying is performed, preserving forensic integrity. " + "Each image is displayed as a clickable thumbnail with file metadata." + ), + "author": "Guilherme Guilherme", + "version": "1.3", + "date": "2026-05-03", + "requirements": "none", + "category": "AI Chatbot - Nova", + "notes": ( + "The Glide disk cache location: cache/image_manager_disk_cache/*.0. " + "Each .0 file is a raw JPEG. The filename is a SHA-256 hash of the signed Firebase URL. " + "The module directly links to the original file using absolute paths. " + "For the preview to work, the report must be opened on the same computer that extracted " + "the data, and the browser must allow file:// links (most do when the report is also " + "opened from a file:// location)." + ), + "paths": ("*/com.scaleup.chatai/cache/image_manager_disk_cache",), + "function": "get_nova_cache_images", + } +} + +import os +import csv +import datetime +import html as html_module +from scripts.artifact_report import ArtifactHtmlReport +import scripts.ilapfuncs + + +def _e(text): + return html_module.escape(str(text)) if text else "" + + +def _convert_timestamp(ts_sec): + if ts_sec is None: + return "" + try: + return datetime.datetime.utcfromtimestamp(ts_sec).strftime( + "%Y-%m-%d %H:%M:%S UTC" + ) + except (OSError, OverflowError, ValueError): + return str(ts_sec) + + +def _format_file_size(size_bytes): + if size_bytes is None: + return "" + try: + size_bytes = int(size_bytes) + if size_bytes < 1024: + return f"{size_bytes} B" + elif size_bytes < 1024**2: + return f"{size_bytes / 1024:.1f} KB" + elif size_bytes < 1024**3: + return f"{size_bytes / (1024**2):.1f} MB" + else: + return f"{size_bytes / (1024**3):.2f} GB" + except (ValueError, TypeError): + return str(size_bytes) + + +def get_nova_cache_images(files_found, report_folder, seeker, wrap_text): + """ + Entry point for the nova_cache_images artifact. + Scans for *.0 files, and generates an HTML gallery with direct file:// links + to the original cache files. No copying is performed. + """ + # Collect all unique cache directories from the glob matches + cache_dirs = set() + for path in files_found: + path = str(path) + if os.path.isdir(path): + cache_dirs.add(path) + else: + parent = os.path.dirname(path) + cache_dirs.add(parent) + + if not cache_dirs: + scripts.ilapfuncs.logfunc("[nova_cache_images] No cache directory found.") + return + + all_images = [] # list of dict with metadata and absolute path + + for cache_dir in cache_dirs: + if not os.path.isdir(cache_dir): + continue + for fname in os.listdir(cache_dir): + if not fname.endswith(".0"): + continue + src_path = os.path.join(cache_dir, fname) + try: + stat = os.stat(src_path) + all_images.append( + { + "original_name": fname, + "abs_path": src_path, + "size": stat.st_size, + "mtime": stat.st_mtime, + } + ) + except Exception as e: + scripts.ilapfuncs.logfunc( + f"[nova_cache_images] Error reading {src_path}: {e}" + ) + + if not all_images: + scripts.ilapfuncs.logfunc("[nova_cache_images] No .0 cache files found.") + return + + all_images.sort(key=lambda x: x["mtime"], reverse=True) + + # Prepare HTML rows + headers = [ + "Thumbnail & Filename", + "File Size", + "Last Modified (UTC)", + "Original Cache Filename", + ] + html_rows = [] + tsv_rows = [] + + for img in all_images: + # Convert absolute path to file:// URL + abs_url = "file://" + os.path.abspath(img["abs_path"]) + # For display, use the basename as label + display_name = f"{img['original_name']}" + thumbnail_html = ( + f'
' + f' ' + f' {_e(display_name)}' + f"
" + f" {_e(display_name)}" + f"
" + ) + size_str = _format_file_size(img["size"]) + mtime_str = _convert_timestamp(img["mtime"]) + html_rows.append((thumbnail_html, size_str, mtime_str, img["original_name"])) + tsv_rows.append((display_name, size_str, mtime_str, img["original_name"])) + + # Generate HTML report directly inside report_folder (top-level _HTML) + report_name = "Nova AI Chatbot - Cached Images" + report = ArtifactHtmlReport(report_name) + report.start_artifact_report(report_folder, report_name) + report.add_script() + report.write_artifact_data_table( + headers, html_rows, "cache/image_manager_disk_cache", html_escape=False + ) + report.end_artifact_report() + + # TSV export + tsv_path = os.path.join(report_folder, f"{report_name}.tsv") + with open(tsv_path, "w", newline="", encoding="utf-8") as tsvfile: + writer = csv.writer(tsvfile, delimiter="\t") + writer.writerow( + ["Filename", "File Size", "Last Modified (UTC)", "Original Cache Filename"] + ) + writer.writerows(tsv_rows) + + scripts.ilapfuncs.logfunc( + f"[nova_cache_images] Displayed {len(all_images)} cached images using file:// links." + ) diff --git a/scripts/artifacts/AIChatbotNovaConversations.py b/scripts/artifacts/AIChatbotNovaConversations.py new file mode 100644 index 00000000..f2679b8e --- /dev/null +++ b/scripts/artifacts/AIChatbotNovaConversations.py @@ -0,0 +1,579 @@ +__artifacts_v2__ = { + "nova_chatbot_conversations": { + "name": "Nova AI Chatbot - Conversations (Full Detail)", + "description": ( + "Reconstructs full conversations from the AI Chatbot - Nova app by joining " + "History, HistoryDetail, HistoryDetailImage, HistoryDetailDocument, and " + "HistoryDetailLink tables. Produces one row per message with all attachment " + "metadata surfaced inline. Image origin (user‑submitted vs AI‑generated) is " + "determined by the parent message role. Generated images are not resolvable " + "locally due to Firebase signed URL tokens; the report shows the Firebase path " + "and a forensic note. Documents are displayed with full metadata and a note " + "confirming they were submitted by the user. Soft‑deleted conversations are " + "flagged on every associated message row." + ), + "author": "Guilherme Guilherme", + "version": "0.5", + "date": "2026-05-03", + "requirements": "none", + "category": "AI Chatbot - Nova", + "notes": ( + "Message timestamps are stored as Unix milliseconds (INTEGER) and are " + "converted to UTC for display and timeline submission. " + "HistoryDetail.type: 0 = USER, 1 = ASSISTANT. " + "Attachment columns are empty when no attachment is linked to a message. " + "A conversation flagged as DELETED means History.softDeleted = 1; the record " + "remains in the database after user deletion and is forensically recoverable. " + "chatBotModel is an integer mapped to known AI model names where possible. " + "Image origin is correctly identified by the parent message role: " + "USER messages contain user‑submitted images (e.g., vision queries); " + "ASSISTANT messages contain AI‑generated images. " + "All images are stored on Firebase Storage; local cache filenames are hashes " + "of signed URLs (tokens not on device), so automatic matching is impossible. " + "Documents are also stored on Firebase; no local copy is kept. " + "TSV export contains plain‑text equivalents for all attachment fields." + ), + "paths": ("*/com.scaleup.chatai/databases/chat-ai.db",), + "function": "get_nova_chatbot_conversations", + } +} + +import os +import shutil +import sqlite3 +import datetime +import html as html_module +from scripts.artifact_report import ArtifactHtmlReport +import scripts.ilapfuncs + +# --------------------------------------------------------------------------- +# Known mappings for the chatBotModel integer field. +# --------------------------------------------------------------------------- +CHAT_BOT_MODEL_MAP = { + 0: "ChatGPT 3.5", + 1: "GPT-5", + 2: "GPT-4o", + 3: "Bard / Image Gen.", + 4: "Image Generator", + 5: "Vision", + 6: "Google Vision", + 7: "Document", + 8: "LLaMA 2", + 9: "Nova", + 10: "Gemini", + 11: "Superbot", + 12: "Logo Generator", + 13: "Tattoo Generator", + 14: "Web Search", + 15: "Claude", + 16: "DeepSeek", + 17: "Signature Generator", + 18: "Mistral", + 19: "Grok", + 20: "DeepSeek R1", + 21: "AI Filter", + 22: "Voice Chat", + 23: "Snap & Solve", + 24: "Study Planner", + 25: "Quiz Maker", + 26: "Essay Helper", + 27: "Gemini 3 Pro", + 28: "GPT-5.1", + 29: "GPT-4o Mini", +} + +IMAGE_STATE_MAP = { + 0: "Pending", + 1: "Success", + 2: "Failed", +} + +DOCUMENT_TYPE_MAP = { + 0: "Local File", + 1: "Remote File", +} + +MIME_ICON_MAP = { + "application/pdf": "📄", + "application/msword": "📝", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "📝", + "application/vnd.ms-excel": "📊", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "📊", + "text/plain": "📃", + "text/csv": "📊", + "image/jpeg": "🖼️", + "image/png": "🖼️", + "image/gif": "🖼️", + "image/webp": "🖼️", +} + +# --------------------------------------------------------------------------- +# Scalar helpers +# --------------------------------------------------------------------------- + + +def _convert_ms_timestamp(ms): + if ms is None: + return "" + try: + return datetime.datetime.utcfromtimestamp(ms / 1000).strftime( + "%Y-%m-%d %H:%M:%S UTC" + ) + except (OSError, OverflowError, ValueError): + return str(ms) + + +def _resolve_model(model_int): + if model_int is None: + return "Unknown" + name = CHAT_BOT_MODEL_MAP.get(model_int) + return f"{name} ({model_int})" if name else f"Unknown Model ({model_int})" + + +def _resolve_image_state(state_int): + if state_int is None: + return "" + label = IMAGE_STATE_MAP.get(state_int) + return f"{label} ({state_int})" if label else f"Unknown State ({state_int})" + + +def _resolve_document_type(type_int): + if type_int is None: + return "" + label = DOCUMENT_TYPE_MAP.get(type_int) + return f"{label} ({type_int})" if label else f"Unknown Type ({type_int})" + + +def _format_role(type_int): + return {0: "USER", 1: "ASSISTANT"}.get(type_int, f"UNKNOWN ({type_int})") + + +def _format_soft_deleted(value): + return "DELETED" if value == 1 else "No" + + +def _format_file_size(size_bytes): + if size_bytes is None: + return "" + try: + size_bytes = int(size_bytes) + if size_bytes < 1024: + return f"{size_bytes} B" + elif size_bytes < 1024**2: + return f"{size_bytes / 1024:.1f} KB" + elif size_bytes < 1024**3: + return f"{size_bytes / (1024**2):.1f} MB" + else: + return f"{size_bytes / (1024**3):.2f} GB" + except (ValueError, TypeError): + return str(size_bytes) + + +def _e(text): + return html_module.escape(str(text)) if text else "" + + +# --------------------------------------------------------------------------- +# Image resolution – always None (no local preview) +# --------------------------------------------------------------------------- +def _resolve_image_file(db_url, seeker, images_dir): + return None + + +# --------------------------------------------------------------------------- +# Rich HTML cell builders +# --------------------------------------------------------------------------- + + +def _build_image_html(msg_type, img_urls, img_prompts, img_states, resolved_filenames): + """ + Build HTML cell for images. Uses msg_type to determine origin. + """ + if not img_urls: + return "" + + urls = [u.strip() for u in img_urls.split(",") if u.strip()] + prompts = ( + [p.strip() for p in img_prompts.split(",") if p.strip()] if img_prompts else [] + ) + + parts = [] + for i, url in enumerate(urls): + prompt = prompts[i] if i < len(prompts) else "" + filename = resolved_filenames[i] if i < len(resolved_filenames) else None + + cell = '
' + + # Prompt + if prompt: + cell += f'
Prompt: {_e(prompt)}
' + + # Forensic note based on msg_type + if msg_type == 0: # USER → user‑submitted (vision query) + cell += ( + f'
' + f" 📤 User‑submitted image
" + f" This image was uploaded by the device user (e.g., as part of a vision query). " + f" The file content is stored on Firebase Storage and is not cached locally." + f"
" + ) + else: # ASSISTANT or unknown → AI‑generated + cell += ( + f'
' + f" 🤖 AI‑generated image
" + f" This image was created by the AI based on the user prompt. " + f" It is stored on Firebase Storage; a temporary local copy may exist " + f" in cache/image_manager_disk_cache/*.0 but the filename " + f" is a hash of a signed URL that includes a token not stored on the device. " + f" Manual inspection of .0 files is recommended.
" + f" Forensic action: Examine .0 files directly as JPEG." + f"
" + ) + + # Firebase path (was "Internal path") + cell += ( + f'
' + f" Firebase path:
" + f' {_e(url)}' + f"
" + ) + cell += "
" + + if i < len(urls) - 1: + cell += ( + '
' + ) + parts.append(cell) + + return "".join(parts) + + +def _build_document_html(doc_names, doc_mime_types, doc_sizes, doc_urls, doc_types): + """ + Build HTML cell for documents – no local preview, shows Firebase path and forensic note. + """ + if not doc_names: + return "" + + names = [n.strip() for n in doc_names.split(",") if n.strip()] + mimes = ( + [m.strip() for m in doc_mime_types.split(",") if m.strip()] + if doc_mime_types + else [] + ) + sizes = [s.strip() for s in doc_sizes.split(",") if s.strip()] if doc_sizes else [] + urls = [u.strip() for u in doc_urls.split(",") if u.strip()] if doc_urls else [] + types = [t.strip() for t in doc_types.split(",") if t.strip()] if doc_types else [] + + parts = [] + for i, name in enumerate(names): + mime = mimes[i] if i < len(mimes) else "" + size_raw = sizes[i] if i < len(sizes) else None + url = urls[i] if i < len(urls) else "" + dtype_raw = types[i] if i < len(types) else None + + icon = MIME_ICON_MAP.get(mime, "📎") + size_label = ( + _format_file_size(int(size_raw)) + if size_raw and size_raw.lstrip("-").isdigit() + else "" + ) + dtype_label = ( + _resolve_document_type(int(dtype_raw)) + if dtype_raw and dtype_raw.lstrip("-").isdigit() + else "" + ) + + cell = ( + f'
' + f'
{icon}
' + f'
{_e(name)}
' + ) + if mime: + cell += f"
MIME Type: {_e(mime)}
" + if size_label: + cell += f"
Size: {_e(size_label)}
" + if url: + cell += ( + f"
Firebase path:
" + f' {_e(url)}
' + ) + if dtype_label: + cell += f"
Source Type: {_e(dtype_label)}
" + + # Forensic note – same as standalone document module + cell += ( + f'
' + f" ⚠️ Forensic note: This file was submitted by the" + f" user to the AI assistant as part of this conversation." + f"
" + f"
" + ) + + if i < len(names) - 1: + cell += ( + '
' + ) + parts.append(cell) + + return "".join(parts) + + +# --------------------------------------------------------------------------- +# Plain-text builders for TSV / timeline +# --------------------------------------------------------------------------- + + +def _build_image_tsv( + msg_type, img_urls, img_prompts, img_states, img_mime_types, img_pipelines +): + """Flat plain‑text representation for TSV.""" + if not img_urls: + return "" + origin = "user-submitted" if msg_type == 0 else "ai-generated" + parts = [f"Origin: {origin}", f"URL: {img_urls}"] + if img_prompts: + parts.append(f"Prompt: {img_prompts}") + if img_states: + states = ", ".join( + _resolve_image_state(int(s.strip())) + for s in img_states.split(",") + if s.strip().lstrip("-").isdigit() + ) + if states: + parts.append(f"State: {states}") + if img_mime_types: + parts.append(f"MIME: {img_mime_types}") + if img_pipelines: + parts.append(f"Pipeline: {img_pipelines}") + return " | ".join(parts) + + +def _build_document_tsv(doc_names, doc_mime_types, doc_sizes, doc_urls, doc_types): + """Flat plain‑text representation for TSV.""" + if not doc_names: + return "" + size_label = ( + _format_file_size(doc_sizes) + if doc_sizes and doc_sizes.lstrip("-").isdigit() + else "" + ) + dtype_label = "" + if doc_types: + first_type = doc_types.split(",")[0].strip() + if first_type.lstrip("-").isdigit(): + dtype_label = _resolve_document_type(int(first_type)) + else: + dtype_label = first_type + parts = [f"Name: {doc_names}"] + if doc_mime_types: + parts.append(f"MIME: {doc_mime_types}") + if size_label: + parts.append(f"Size: {size_label}") + if doc_urls: + parts.append(f"Path: {doc_urls}") + if dtype_label: + parts.append(f"Type: {dtype_label}") + parts.append("Note: File submitted by user to AI assistant") + return " | ".join(parts) + + +# --------------------------------------------------------------------------- +# SQL (unchanged) +# --------------------------------------------------------------------------- +QUERY = """ +SELECT + h.id AS conv_id, + h.UUID AS conv_uuid, + h.title AS conv_title, + h.chatBotModel AS chat_bot_model, + h.softDeleted AS soft_deleted, + h.syncState AS conv_sync_state, + + hd.id AS msg_id, + hd.UUID AS msg_uuid, + hd.type AS msg_type, + hd.text AS msg_text, + hd.token AS msg_token, + hd.reasoningContent AS msg_reasoning, + hd.createdAt AS msg_created_at, + hd.syncState AS msg_sync_state, + + GROUP_CONCAT(DISTINCT hdi.url) AS img_urls, + GROUP_CONCAT(DISTINCT hdi.prompt) AS img_prompts, + GROUP_CONCAT(DISTINCT hdi.state) AS img_states, + GROUP_CONCAT(DISTINCT hdi.mimeType) AS img_mime_types, + GROUP_CONCAT(DISTINCT hdi.pipeline) AS img_pipelines, + + GROUP_CONCAT(DISTINCT hdd.name) AS doc_names, + GROUP_CONCAT(DISTINCT hdd.mimeType) AS doc_mime_types, + GROUP_CONCAT(DISTINCT hdd.size) AS doc_sizes, + GROUP_CONCAT(DISTINCT hdd.url) AS doc_urls, + GROUP_CONCAT(DISTINCT hdd.type) AS doc_types, + + GROUP_CONCAT(DISTINCT hdl.url) AS link_urls + +FROM History h +INNER JOIN HistoryDetail hd + ON hd.historyID = h.id +LEFT JOIN HistoryDetailImage hdi + ON hdi.historyDetailID = hd.id +LEFT JOIN HistoryDetailDocument hdd + ON hdd.historyDetailID = hd.id +LEFT JOIN HistoryDetailLink hdl + ON hdl.historyDetailID = hd.id +GROUP BY hd.id +ORDER BY h.id ASC, hd.createdAt ASC +""" + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +def get_nova_chatbot_conversations(files_found, report_folder, seeker, wrap_text): + for file_found in files_found: + file_found = str(file_found) + if not file_found.endswith("chat-ai.db"): + continue + + try: + db = sqlite3.connect(file_found) + cursor = db.cursor() + cursor.execute(QUERY) + rows_raw = cursor.fetchall() + db.close() + except Exception as e: + scripts.ilapfuncs.logfunc( + f"[nova_chatbot_conversations] Error reading {file_found}: {e}" + ) + continue + + if not rows_raw: + scripts.ilapfuncs.logfunc( + f"[nova_chatbot_conversations] No records found in {file_found}." + ) + continue + + images_dir = os.path.join(report_folder, "nova_images") + os.makedirs(images_dir, exist_ok=True) + + headers = [ + "Conv. ID", + "Conv. UUID", + "Conv. Title", + "AI Model", + "Conv. Deleted", + "Conv. Sync State", + "Msg. ID", + "Msg. UUID", + "Role", + "Message Text", + "Token Count", + "Reasoning Content", + "Message Timestamp (UTC)", + "Msg. Sync State", + "Image Attachment", + "Document Attachment", + "Link URL(s)", + ] + + html_rows = [] + tsv_rows = [] + + for row in rows_raw: + ( + conv_id, + conv_uuid, + conv_title, + chat_bot_model, + soft_deleted, + conv_sync_state, + msg_id, + msg_uuid, + msg_type, + msg_text, + msg_token, + msg_reasoning, + msg_created_at, + msg_sync_state, + img_urls, + img_prompts, + img_states, + img_mime_types, + img_pipelines, + doc_names, + doc_mime_types, + doc_sizes, + doc_urls, + doc_types, + link_urls, + ) = row + + # Resolve images (always None, but we need a list parallel to URLs) + resolved_filenames = [] + if img_urls: + for raw_url in img_urls.split(","): + raw_url = raw_url.strip() + resolved_filenames.append( + _resolve_image_file(raw_url, seeker, images_dir) + ) + + # Common scalar columns + common = ( + conv_id, + conv_uuid or "", + conv_title or "", + _resolve_model(chat_bot_model), + _format_soft_deleted(soft_deleted), + conv_sync_state if conv_sync_state is not None else "", + msg_id, + msg_uuid or "", + _format_role(msg_type), + msg_text or "", + msg_token if msg_token is not None else "", + msg_reasoning or "", + _convert_ms_timestamp(msg_created_at), + msg_sync_state if msg_sync_state is not None else "", + ) + + # HTML cells + img_html = _build_image_html( + msg_type, img_urls, img_prompts, img_states, resolved_filenames + ) + doc_html = _build_document_html( + doc_names, doc_mime_types, doc_sizes, doc_urls, doc_types + ) + html_rows.append(common + (img_html, doc_html, link_urls or "")) + + # TSV cells + img_tsv = _build_image_tsv( + msg_type, + img_urls, + img_prompts, + img_states, + img_mime_types, + img_pipelines, + ) + doc_tsv = _build_document_tsv( + doc_names, doc_mime_types, doc_sizes, doc_urls, doc_types + ) + tsv_rows.append(common + (img_tsv, doc_tsv, link_urls or "")) + + # HTML report + report_name = "Nova AI Chatbot - Conversations (Full Detail)" + report = ArtifactHtmlReport(report_name) + report.start_artifact_report(report_folder, report_name) + report.add_script() + report.write_artifact_data_table( + headers, html_rows, file_found, html_escape=False + ) + report.end_artifact_report() + + # TSV and timeline + scripts.ilapfuncs.tsv(report_folder, headers, tsv_rows, report_name, file_found) + scripts.ilapfuncs.timeline(report_folder, report_name, tsv_rows, headers) diff --git a/scripts/artifacts/AIChatbotNovaHistory.py b/scripts/artifacts/AIChatbotNovaHistory.py new file mode 100644 index 00000000..9939b8cf --- /dev/null +++ b/scripts/artifacts/AIChatbotNovaHistory.py @@ -0,0 +1,276 @@ +__artifacts_v2__ = { + "nova_chatbot_history": { + "name": "Nova AI Chatbot - Conversation History", + "description": ( + "Extracts the conversation index from the AI Chatbot - Nova app " + "(com.scaleup.chatai) from the History table. " + "Each row represents one conversation and includes the conversation title, " + "AI model used, starred and soft-deleted status, all relevant timestamps, " + "and sync metadata. Each row is further enriched with three summary columns " + "derived from HistoryDetail: total message count, timestamp of the last " + "message, and the text of the first user message — providing immediate " + "investigative context without requiring the full message detail report. " + "Soft-deleted conversations are flagged on every row." + ), + "author": "Guilherme Guilherme", + "version": "0.2", + "date": "2025-04-27", + "requirements": "none", + "category": "AI Chatbot - Nova", + "notes": ( + "Database: com.scaleup.chatai/databases/chat-ai.db. " + "All timestamps (createdAt, updatedAt, lastModifiedAt) are stored as Unix " + "milliseconds (INTEGER) and converted to UTC strings for display. " + "chatBotModel is an integer mapped to known AI model names where possible; " + "unknown values are shown as 'Unknown Model (N)'. " + "softDeleted = 1 indicates the conversation was deleted by the user but " + "remains physically present in the database and is forensically recoverable. " + "starred = 1 indicates the user bookmarked the conversation. " + "assistantId identifies a custom AI assistant persona assigned to the " + "conversation when not NULL. " + "captionHistoryId links to an associated caption or summary history entry " + "when present. " + "message_count, last_message_at, and first_user_message are aggregated " + "from HistoryDetail via LEFT JOIN so conversations with zero messages are " + "still returned. first_user_message reflects the earliest USER-role message " + "text (HistoryDetail.type = 0)." + ), + "paths": ("*/com.scaleup.chatai/databases/chat-ai.db",), + "function": "get_nova_chatbot_history", + } +} + +import sqlite3 +import datetime +from scripts.artifact_report import ArtifactHtmlReport +import scripts.ilapfuncs + +# --------------------------------------------------------------------------- +# Known mappings for the chatBotModel integer field. +# Source: FirestoreHistory.EngineTypes enum ordinals from decompiled APK source +# (com.scaleup.chatai.ui.conversation.FirestoreHistory). +# The integer stored in the database is the ENUM ORDINAL (0-based position), +# NOT the botId from chatbotAgentMap. These are two independent systems. +# Image-generating engines: 3 (legacy Bard ordinal reused), 4, 12, 13, 17. +# NOTE: ordinal 3 ('bard') was reused for image generation in newer app versions; +# presence of HistoryDetailImage records confirms image generation regardless of label. +# NOTE: ordinal 20 ('deepSeekR1') — if reasoningContent is NULL the actual API +# call may have used DeepSeek V3; the field reflects the UI selector, not the API. +# --------------------------------------------------------------------------- +CHAT_BOT_MODEL_MAP = { + 0: "ChatGPT 3.5", # gpt-3.5 + 1: "GPT-5", # gpt-5 + 2: "GPT-4o", # gpt-4o + 3: "Bard / Image Gen.", # bard (legacy; reused for image generation) + 4: "Image Generator", # image-generator + 5: "Vision", # vision + 6: "Google Vision", # googleVision + 7: "Document", # document + 8: "LLaMA 2", # llama2 + 9: "Nova", # nova + 10: "Gemini", # gemini + 11: "Superbot", # superbot + 12: "Logo Generator", # logo-generator + 13: "Tattoo Generator", # tattoo-generator + 14: "Web Search", # webSearch + 15: "Claude", # claude + 16: "DeepSeek", # deepSeek + 17: "Signature Generator", # signature-generator + 18: "Mistral", # mistral + 19: "Grok", # grok + 20: "DeepSeek R1", # deepSeekR1 + 21: "AI Filter", # aiFilter + 22: "Voice Chat", # voiceChat + 23: "Snap & Solve", # snapAndSolve + 24: "Study Planner", # studyPlanner + 25: "Quiz Maker", # quizMaker + 26: "Essay Helper", # essayHelper + 27: "Gemini 3 Pro", # gemini-3-pro + 28: "GPT-5.1", # gpt-5.1 + 29: "GPT-4o Mini", # 4o-mini +} + +# --------------------------------------------------------------------------- +# SQL +# One row per History entry, enriched with three summary columns from +# HistoryDetail via a LEFT JOIN + GROUP BY so conversations with zero +# messages are still returned. +# --------------------------------------------------------------------------- +QUERY = """ +SELECT + h.id AS conv_id, + h.UUID AS conv_uuid, + h.title AS title, + h.chatBotModel AS chat_bot_model, + h.assistantId AS assistant_id, + h.captionHistoryId AS caption_history_id, + h.starred AS starred, + h.softDeleted AS soft_deleted, + h.syncState AS sync_state, + h.syncRetryCount AS sync_retry_count, + h.createdAt AS created_at, + h.updatedAt AS updated_at, + h.lastModifiedAt AS last_modified_at, + COUNT(hd.id) AS message_count, + MAX(hd.createdAt) AS last_msg_ts, + MIN(CASE WHEN hd.type = 0 THEN hd.text END) AS first_user_msg +FROM History h +LEFT JOIN HistoryDetail hd + ON hd.historyID = h.id +GROUP BY h.id +ORDER BY h.createdAt ASC +""" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _convert_ms_timestamp(ms): + """Convert a Unix millisecond timestamp to a human-readable UTC string.""" + if ms is None: + return "" + try: + return datetime.datetime.utcfromtimestamp(ms / 1000).strftime( + "%Y-%m-%d %H:%M:%S UTC" + ) + except (OSError, OverflowError, ValueError): + return str(ms) + + +def _resolve_model(model_int): + """Return a labelled model name, falling back to the raw integer for unknowns.""" + if model_int is None: + return "Unknown" + name = CHAT_BOT_MODEL_MAP.get(model_int) + return f"{name} ({model_int})" if name else f"Unknown Model ({model_int})" + + +def _format_soft_deleted(value): + """Return a clearly labelled string for the softDeleted field.""" + return "DELETED" if value == 1 else "No" + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +def get_nova_chatbot_history(files_found, report_folder, seeker, wrap_text): + """ + Entry point for the nova_chatbot_history artifact. + + Queries the History table enriched with per-conversation summary data + from HistoryDetail. Outputs HTML report, TSV, and timeline. + """ + + for file_found in files_found: + file_found = str(file_found) + + if not file_found.endswith("chat-ai.db"): + continue + + try: + db = sqlite3.connect(file_found) + cursor = db.cursor() + cursor.execute(QUERY) + rows_raw = cursor.fetchall() + db.close() + + except Exception as e: + scripts.ilapfuncs.logfunc( + f"[nova_chatbot_history] Error reading {file_found}: {e}" + ) + continue + + if not rows_raw: + scripts.ilapfuncs.logfunc( + f"[nova_chatbot_history] No records found in {file_found}." + ) + continue + + headers = [ + # --- Identity --- + "Conv. ID", + "Conv. UUID", + "Title", + # --- Model --- + "AI Model", + "Assistant ID", + "Caption History ID", + # --- Flags --- + "Starred", + "Soft Deleted", + "Sync State", + "Sync Retry Count", + # --- Timestamps --- + "Created At (UTC)", + "Updated At (UTC)", + "Last Modified At (UTC)", + # --- Summary from HistoryDetail --- + "Message Count", + "Last Message At (UTC)", + "First User Message", + ] + + rows = [] + for row in rows_raw: + ( + conv_id, + conv_uuid, + title, + chat_bot_model, + assistant_id, + caption_history_id, + starred, + soft_deleted, + sync_state, + sync_retry_count, + created_at, + updated_at, + last_modified_at, + message_count, + last_msg_ts, + first_user_msg, + ) = row + + rows.append( + ( + conv_id, + conv_uuid or "", + title or "", + _resolve_model(chat_bot_model), + assistant_id if assistant_id is not None else "", + caption_history_id or "", + "Yes" if starred else "No", + _format_soft_deleted(soft_deleted), + sync_state if sync_state is not None else "", + sync_retry_count if sync_retry_count is not None else "", + _convert_ms_timestamp(created_at), + _convert_ms_timestamp(updated_at), + _convert_ms_timestamp(last_modified_at), + message_count if message_count is not None else 0, + _convert_ms_timestamp(last_msg_ts), + first_user_msg or "", + ) + ) + + # --- HTML report --- + report_name = "Nova AI Chatbot - History" + report = ArtifactHtmlReport(report_name) + report.start_artifact_report(report_folder, report_name) + report.add_script() + report.write_artifact_data_table( + headers, + rows, + file_found, + html_escape=True, + ) + report.end_artifact_report() + + # --- TSV output --- + scripts.ilapfuncs.tsv(report_folder, headers, rows, report_name, file_found) + + # --- Timeline (uses Created At, index 10) --- + scripts.ilapfuncs.timeline(report_folder, report_name, rows, headers) diff --git a/scripts/artifacts/AIChatbotNovaHistoryDetail.py b/scripts/artifacts/AIChatbotNovaHistoryDetail.py new file mode 100644 index 00000000..0ff38a74 --- /dev/null +++ b/scripts/artifacts/AIChatbotNovaHistoryDetail.py @@ -0,0 +1,329 @@ +__artifacts_v2__ = { + "nova_chatbot_history_detail": { + "name": "Nova AI Chatbot - Message Detail", + "description": ( + "Extracts every individual message from the AI Chatbot - Nova app " + "(com.scaleup.chatai) from the HistoryDetail table. " + "Each row represents one message and is enriched with parent conversation " + "context joined from History: conversation title, AI model used, and " + "soft-deleted status. Three attachment presence flags are added via " + "correlated EXISTS subqueries against HistoryDetailImage, " + "HistoryDetailDocument, and HistoryDetailLink, indicating whether each " + "message has an associated image, document, or link without duplicating " + "rows or loading attachment content. " + "Enables message-level timeline reconstruction and rapid attachment triage " + "across all conversations." + ), + "author": "Guilherme Guilherme", + "version": "0.2", + "date": "2025-04-27", + "requirements": "none", + "category": "AI Chatbot - Nova", + "notes": ( + "Database: com.scaleup.chatai/databases/chat-ai.db. " + "All timestamps (createdAt, lastModifiedAt) are stored as Unix milliseconds " + "(INTEGER) and converted to UTC strings for display. " + "HistoryDetail.type: 0 = USER (message sent by the device user), " + "1 = ASSISTANT (response generated by the AI model). " + "token counts reflect the number of tokens consumed by each message; " + "ASSISTANT messages with 0 tokens typically indicate image-generation " + "responses where no text tokens were billed. " + "reasoningContent contains chain-of-thought reasoning text when the " + "underlying model produces it (e.g. DeepSeek-R1 reasoning traces). " + "has_image = Yes means at least one record exists in HistoryDetailImage " + "for this message. " + "has_document = Yes means at least one record exists in HistoryDetailDocument " + "for this message — the user submitted a file to the AI. " + "has_link = Yes means at least one record exists in HistoryDetailLink. " + "softDeleted is inherited from the parent History record; DELETED means the " + "conversation was removed by the user but all messages remain physically in " + "the database and are forensically recoverable. " + "syncState and syncRetryCount reflect cloud synchronisation status." + ), + "paths": ("*/com.scaleup.chatai/databases/chat-ai.db",), + "function": "get_nova_chatbot_history_detail", + } +} + +import sqlite3 +import datetime +from scripts.artifact_report import ArtifactHtmlReport +import scripts.ilapfuncs + +# --------------------------------------------------------------------------- +# Known mappings for the chatBotModel integer field. +# Source: FirestoreHistory.EngineTypes enum ordinals from decompiled APK source +# (com.scaleup.chatai.ui.conversation.FirestoreHistory). +# The integer stored in the database is the ENUM ORDINAL (0-based position), +# NOT the botId from chatbotAgentMap. These are two independent systems. +# Image-generating engines: 3 (legacy Bard ordinal reused), 4, 12, 13, 17. +# NOTE: ordinal 3 ('bard') was reused for image generation in newer app versions; +# presence of HistoryDetailImage records confirms image generation regardless of label. +# NOTE: ordinal 20 ('deepSeekR1') — if reasoningContent is NULL the actual API +# call may have used DeepSeek V3; the field reflects the UI selector, not the API. +# --------------------------------------------------------------------------- +CHAT_BOT_MODEL_MAP = { + 0: "ChatGPT 3.5", # gpt-3.5 + 1: "GPT-5", # gpt-5 + 2: "GPT-4o", # gpt-4o + 3: "Bard / Image Gen.", # bard (legacy; reused for image generation) + 4: "Image Generator", # image-generator + 5: "Vision", # vision + 6: "Google Vision", # googleVision + 7: "Document", # document + 8: "LLaMA 2", # llama2 + 9: "Nova", # nova + 10: "Gemini", # gemini + 11: "Superbot", # superbot + 12: "Logo Generator", # logo-generator + 13: "Tattoo Generator", # tattoo-generator + 14: "Web Search", # webSearch + 15: "Claude", # claude + 16: "DeepSeek", # deepSeek + 17: "Signature Generator", # signature-generator + 18: "Mistral", # mistral + 19: "Grok", # grok + 20: "DeepSeek R1", # deepSeekR1 + 21: "AI Filter", # aiFilter + 22: "Voice Chat", # voiceChat + 23: "Snap & Solve", # snapAndSolve + 24: "Study Planner", # studyPlanner + 25: "Quiz Maker", # quizMaker + 26: "Essay Helper", # essayHelper + 27: "Gemini 3 Pro", # gemini-3-pro + 28: "GPT-5.1", # gpt-5.1 + 29: "GPT-4o Mini", # 4o-mini +} + +# --------------------------------------------------------------------------- +# SQL +# One row per HistoryDetail message. +# Parent conversation context (title, model, soft-deleted) is joined from +# History. Attachment presence is detected via correlated EXISTS subqueries — +# lightweight boolean checks that avoid duplicating rows from the attachment +# tables. +# --------------------------------------------------------------------------- +QUERY = """ +SELECT + -- Message identity + hd.id AS msg_id, + hd.UUID AS msg_uuid, + hd.historyID AS conv_id, + + -- Parent conversation context (from History) + h.UUID AS conv_uuid, + h.title AS conv_title, + h.chatBotModel AS chat_bot_model, + h.softDeleted AS soft_deleted, + + -- Message content + hd.type AS msg_type, + hd.text AS msg_text, + hd.token AS token_count, + hd.reasoningContent AS reasoning_content, + + -- Message timestamps + hd.createdAt AS created_at, + hd.lastModifiedAt AS last_modified_at, + + -- Sync metadata + hd.syncState AS sync_state, + hd.syncRetryCount AS sync_retry_count, + + -- Attachment flags (correlated EXISTS — no row multiplication) + CASE WHEN EXISTS ( + SELECT 1 FROM HistoryDetailImage i + WHERE i.historyDetailID = hd.id + ) THEN 1 ELSE 0 END AS has_image, + + CASE WHEN EXISTS ( + SELECT 1 FROM HistoryDetailDocument d + WHERE d.historyDetailID = hd.id + ) THEN 1 ELSE 0 END AS has_document, + + CASE WHEN EXISTS ( + SELECT 1 FROM HistoryDetailLink l + WHERE l.historyDetailID = hd.id + ) THEN 1 ELSE 0 END AS has_link + +FROM HistoryDetail hd +INNER JOIN History h + ON h.id = hd.historyID +ORDER BY hd.historyID ASC, hd.createdAt ASC +""" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _convert_ms_timestamp(ms): + """Convert a Unix millisecond timestamp to a human-readable UTC string.""" + if ms is None: + return "" + try: + return datetime.datetime.utcfromtimestamp(ms / 1000).strftime( + "%Y-%m-%d %H:%M:%S UTC" + ) + except (OSError, OverflowError, ValueError): + return str(ms) + + +def _resolve_model(model_int): + """Return a labelled model name, falling back to the raw integer for unknowns.""" + if model_int is None: + return "Unknown" + name = CHAT_BOT_MODEL_MAP.get(model_int) + return f"{name} ({model_int})" if name else f"Unknown Model ({model_int})" + + +def _format_role(type_int): + """Map HistoryDetail.type to a forensically clear role label.""" + if type_int == 0: + return "USER" + if type_int == 1: + return "ASSISTANT" + return f"UNKNOWN ({type_int})" + + +def _format_soft_deleted(value): + """Return a clearly labelled string for the softDeleted field.""" + return "DELETED" if value == 1 else "No" + + +def _flag(value): + """Return Yes/No for a boolean integer flag.""" + return "Yes" if value else "No" + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +def get_nova_chatbot_history_detail(files_found, report_folder, seeker, wrap_text): + """ + Entry point for the nova_chatbot_history_detail artifact. + + Queries every message in HistoryDetail, enriched with parent conversation + context from History and attachment presence flags from the three attachment + tables. Outputs HTML report, TSV, and timeline. + """ + + for file_found in files_found: + file_found = str(file_found) + + if not file_found.endswith("chat-ai.db"): + continue + + try: + db = sqlite3.connect(file_found) + cursor = db.cursor() + cursor.execute(QUERY) + rows_raw = cursor.fetchall() + db.close() + + except Exception as e: + scripts.ilapfuncs.logfunc( + f"[nova_chatbot_history_detail] Error reading {file_found}: {e}" + ) + continue + + if not rows_raw: + scripts.ilapfuncs.logfunc( + f"[nova_chatbot_history_detail] No records found in {file_found}." + ) + continue + + headers = [ + # --- Message identity --- + "Msg. ID", + "Msg. UUID", + "Conv. ID", + # --- Parent conversation context --- + "Conv. UUID", + "Conv. Title", + "AI Model", + "Conv. Deleted", + # --- Message content --- + "Role", + "Message Text", + "Token Count", + "Reasoning Content", + # --- Timestamps --- + "Message Timestamp (UTC)", + "Last Modified At (UTC)", + # --- Sync metadata --- + "Sync State", + "Sync Retry Count", + # --- Attachment flags --- + "Has Image", + "Has Document", + "Has Link", + ] + + rows = [] + for row in rows_raw: + ( + msg_id, + msg_uuid, + conv_id, + conv_uuid, + conv_title, + chat_bot_model, + soft_deleted, + msg_type, + msg_text, + token_count, + reasoning_content, + created_at, + last_modified_at, + sync_state, + sync_retry_count, + has_image, + has_document, + has_link, + ) = row + + rows.append( + ( + msg_id, + msg_uuid or "", + conv_id, + conv_uuid or "", + conv_title or "", + _resolve_model(chat_bot_model), + _format_soft_deleted(soft_deleted), + _format_role(msg_type), + msg_text or "", + token_count if token_count is not None else "", + reasoning_content or "", + _convert_ms_timestamp(created_at), + _convert_ms_timestamp(last_modified_at), + sync_state if sync_state is not None else "", + sync_retry_count if sync_retry_count is not None else "", + _flag(has_image), + _flag(has_document), + _flag(has_link), + ) + ) + + # --- HTML report --- + report_name = "Nova AI Chatbot - HistoryDetail" + report = ArtifactHtmlReport(report_name) + report.start_artifact_report(report_folder, report_name) + report.add_script() + report.write_artifact_data_table( + headers, + rows, + file_found, + html_escape=True, + ) + report.end_artifact_report() + + # --- TSV output --- + scripts.ilapfuncs.tsv(report_folder, headers, rows, report_name, file_found) + + # --- Timeline (message-level granularity, index 11 = Message Timestamp) --- + scripts.ilapfuncs.timeline(report_folder, report_name, rows, headers) diff --git a/scripts/artifacts/AIChatbotNovaHistoryDetailDocument.py b/scripts/artifacts/AIChatbotNovaHistoryDetailDocument.py new file mode 100644 index 00000000..4d1c7aef --- /dev/null +++ b/scripts/artifacts/AIChatbotNovaHistoryDetailDocument.py @@ -0,0 +1,411 @@ +__artifacts_v2__ = { + "nova_chatbot_documents": { + "name": "Nova AI Chatbot - Submitted Documents", + "description": ( + "Extracts all document records submitted by the user to the AI from the " + "AI Chatbot - Nova app (HistoryDetailDocument table). Each row represents " + "one document and is enriched with parent message context from HistoryDetail " + "and parent conversation context from History. " + "Documents are stored on Firebase Storage; the database stores only the " + "Firebase object path. No local cache of user‑submitted documents is kept " + "on the device. The metadata and a forensic note are displayed in the HTML " + "report. The full file content is not available for preview. " + "A forensic note is shown on every row confirming the file was actively " + "submitted by the device user to the AI assistant." + ), + "author": "Guilherme Guilherme", + "version": "0.2", + "date": "2025-04-27", + "requirements": "none", + "category": "AI Chatbot - Nova", + "notes": ( + "Database: com.scaleup.chatai/databases/chat-ai.db. " + "HistoryDetailDocument.url stores a Firebase path such as " + "'/document///document-input-'. " + "The file content is not stored locally on the device; it lives in " + "Firebase Storage. The metadata record is still valuable for forensic " + "timeline and user activity. " + "type: 0 = Local File (uploaded from the device), 1 = Remote File. " + "size is stored in bytes and converted to a human-readable string. " + "mimeType identifies the document format (e.g. application/pdf). " + "softDeleted is inherited from the parent History record; DELETED means " + "the conversation was removed by the user but the document record remains " + "physically in the database and is forensically recoverable. " + "The user message text associated with the document reveals the query the " + "user submitted alongside the file to the AI." + ), + "paths": ("*/com.scaleup.chatai/databases/chat-ai.db",), + "function": "get_nova_chatbot_documents", + } +} + +import os +import shutil +import sqlite3 +import datetime +import html as html_module +from scripts.artifact_report import ArtifactHtmlReport +import scripts.ilapfuncs + +# --------------------------------------------------------------------------- +# Known mappings for the chatBotModel integer field. +# Source: FirestoreHistory.EngineTypes enum ordinals from decompiled APK source +# (com.scaleup.chatai.ui.conversation.FirestoreHistory). +# The integer stored in the database is the ENUM ORDINAL (0-based position), +# NOT the botId from chatbotAgentMap. These are two independent systems. +# Image-generating engines: 3 (legacy Bard ordinal reused), 4, 12, 13, 17. +# NOTE: ordinal 3 ('bard') was reused for image generation in newer app versions; +# presence of HistoryDetailImage records confirms image generation regardless of label. +# NOTE: ordinal 20 ('deepSeekR1') — if reasoningContent is NULL the actual API +# call may have used DeepSeek V3; the field reflects the UI selector, not the API. +# --------------------------------------------------------------------------- +CHAT_BOT_MODEL_MAP = { + 0: "ChatGPT 3.5", # gpt-3.5 + 1: "GPT-5", # gpt-5 + 2: "GPT-4o", # gpt-4o + 3: "Bard / Image Gen.", # bard (legacy; reused for image generation) + 4: "Image Generator", # image-generator + 5: "Vision", # vision + 6: "Google Vision", # googleVision + 7: "Document", # document + 8: "LLaMA 2", # llama2 + 9: "Nova", # nova + 10: "Gemini", # gemini + 11: "Superbot", # superbot + 12: "Logo Generator", # logo-generator + 13: "Tattoo Generator", # tattoo-generator + 14: "Web Search", # webSearch + 15: "Claude", # claude + 16: "DeepSeek", # deepSeek + 17: "Signature Generator", # signature-generator + 18: "Mistral", # mistral + 19: "Grok", # grok + 20: "DeepSeek R1", # deepSeekR1 + 21: "AI Filter", # aiFilter + 22: "Voice Chat", # voiceChat + 23: "Snap & Solve", # snapAndSolve + 24: "Study Planner", # studyPlanner + 25: "Quiz Maker", # quizMaker + 26: "Essay Helper", # essayHelper + 27: "Gemini 3 Pro", # gemini-3-pro + 28: "GPT-5.1", # gpt-5.1 + 29: "GPT-4o Mini", # 4o-mini +} + +DOCUMENT_TYPE_MAP = { + 0: "Local File", + 1: "Remote File", +} + +MIME_ICON_MAP = { + "application/pdf": "📄", + "application/msword": "📝", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "📝", + "application/vnd.ms-excel": "📊", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "📊", + "text/plain": "📃", + "text/csv": "📊", + "image/jpeg": "🖼️", + "image/png": "🖼️", + "image/gif": "🖼️", + "image/webp": "🖼️", +} + +# --------------------------------------------------------------------------- +# SQL +# One row per HistoryDetailDocument, enriched with parent message and +# conversation context. +# --------------------------------------------------------------------------- +QUERY = """ +SELECT + -- Document record + d.id AS doc_id, + d.historyDetailID AS msg_id, + d.url AS doc_url, + d.name AS doc_name, + d.type AS doc_type, + d.size AS doc_size, + d.mimeType AS mime_type, + + -- Parent message context (HistoryDetail) + hd.historyID AS conv_id, + hd.type AS msg_type, + hd.text AS msg_text, + hd.createdAt AS msg_created_at, + + -- Parent conversation context (History) + h.UUID AS conv_uuid, + h.title AS conv_title, + h.chatBotModel AS chat_bot_model, + h.softDeleted AS soft_deleted + +FROM HistoryDetailDocument d +INNER JOIN HistoryDetail hd ON hd.id = d.historyDetailID +INNER JOIN History h ON h.id = hd.historyID +ORDER BY d.id ASC +""" + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _e(text): + return html_module.escape(str(text)) if text else "" + +def _convert_ms_timestamp(ms): + if ms is None: + return "" + try: + return datetime.datetime.utcfromtimestamp(ms / 1000).strftime( + "%Y-%m-%d %H:%M:%S UTC" + ) + except (OSError, OverflowError, ValueError): + return str(ms) + +def _resolve_model(model_int): + if model_int is None: + return "Unknown" + name = CHAT_BOT_MODEL_MAP.get(model_int) + return f"{name} ({model_int})" if name else f"Unknown Model ({model_int})" + +def _resolve_doc_type(type_int): + if type_int is None: + return "" + label = DOCUMENT_TYPE_MAP.get(type_int) + return f"{label} ({type_int})" if label else f"Unknown ({type_int})" + +def _format_file_size(size_bytes): + if size_bytes is None: + return "" + try: + size_bytes = int(size_bytes) + if size_bytes < 1024: + return f"{size_bytes} B" + elif size_bytes < 1024**2: + return f"{size_bytes / 1024:.1f} KB" + elif size_bytes < 1024**3: + return f"{size_bytes / (1024**2):.1f} MB" + else: + return f"{size_bytes / (1024**3):.2f} GB" + except (ValueError, TypeError): + return str(size_bytes) + +def _format_soft_deleted(value): + return "DELETED" if value == 1 else "No" + +def _format_role(type_int): + return {0: "USER", 1: "ASSISTANT"}.get(type_int, f"UNKNOWN ({type_int})") + +# --------------------------------------------------------------------------- +# Document file resolution (always returns None – no local copy) +# --------------------------------------------------------------------------- +def _resolve_document_file(doc_url, doc_name, seeker, docs_dir): + """ + Documents are stored on Firebase Storage. The database stores only the + Firebase object path. No local cache of user‑submitted documents is + kept on the device. Therefore this function always returns None. + The HTML cell will show a notice explaining the file is not available + locally. + """ + return None + +# --------------------------------------------------------------------------- +# HTML cell builder +# --------------------------------------------------------------------------- + +def _build_document_cell(doc_name, mime_type, doc_size, doc_url, doc_type, filename): + """ + Build a self-contained HTML cell for one document record showing: + - File icon + name (plain text, no link) + - MIME type, size, source type, and Firebase path + - Forensic note confirming the user submitted this file to the AI + - Notice that the file content is stored on Firebase and not available + """ + icon = MIME_ICON_MAP.get(mime_type, "📎") + size_label = _format_file_size(doc_size) + type_label = _resolve_doc_type(doc_type) + + cell = f'
' + cell += f'
{icon} {_e(doc_name)}
' + + if mime_type: + cell += f"
MIME Type: {_e(mime_type)}
" + if size_label: + cell += f"
Size: {_e(size_label)}
" + if type_label: + cell += f"
Source Type: {_e(type_label)}
" + if doc_url: + cell += ( + f'
' + f" Firebase Path:
" + f' {_e(doc_url)}' + f"
" + ) + + # Notice that the file is not stored locally + cell += ( + f'
' + f" ☁️ File stored on Firebase Storage
" + f" The document content is not available on the device. " + f" The database record confirms the user submitted this file to the AI; " + f" the file itself resides in Firebase Storage and is not cached locally." + f"
" + ) + + # Forensic note (original) + cell += ( + f'
' + f" ⚠️ Forensic note: This file was actively submitted " + f" by the device user to the AI assistant as part of this conversation." + f"
" + ) + cell += "
" + return cell + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +def get_nova_chatbot_documents(files_found, report_folder, seeker, wrap_text): + """ + Entry point for the nova_chatbot_documents artifact. + + Extracts every HistoryDetailDocument record, resolves the document file from + the device extraction, and produces an HTML report with document metadata + cards and download links, TSV export, and timeline output. + """ + for file_found in files_found: + file_found = str(file_found) + if not file_found.endswith("chat-ai.db"): + continue + + try: + db = sqlite3.connect(file_found) + cursor = db.cursor() + cursor.execute(QUERY) + rows_raw = cursor.fetchall() + db.close() + except Exception as e: + scripts.ilapfuncs.logfunc( + f"[nova_chatbot_documents] Error reading {file_found}: {e}" + ) + continue + + if not rows_raw: + scripts.ilapfuncs.logfunc( + f"[nova_chatbot_documents] No document records found in {file_found}." + ) + continue + + docs_dir = os.path.join(report_folder, "nova_documents") + os.makedirs(docs_dir, exist_ok=True) + + headers = [ + # Document identity + "Doc. ID", + "Msg. ID", + "Conv. ID", + # Conversation context + "Conv. UUID", + "Conv. Title", + "AI Model", + "Conv. Deleted", + # Message context + "Msg. Role", + "Msg. Text", + "Msg. Timestamp (UTC)", + # Document card (HTML) + "Document & Metadata", + # Plain fields for TSV + "File Name", + "MIME Type", + "Size", + "Source Type", + "Firebase Path", # changed from "Internal Path" + ] + + html_rows = [] + tsv_rows = [] + + for row in rows_raw: + ( + doc_id, + msg_id, + doc_url, + doc_name, + doc_type, + doc_size, + mime_type, + conv_id, + msg_type, + msg_text, + msg_created_at, + conv_uuid, + conv_title, + chat_bot_model, + soft_deleted, + ) = row + + # Resolve document from extraction (always returns None) + filename = _resolve_document_file(doc_url, doc_name, seeker, docs_dir) + + common = ( + doc_id, + msg_id, + conv_id, + conv_uuid or "", + conv_title or "", + _resolve_model(chat_bot_model), + _format_soft_deleted(soft_deleted), + _format_role(msg_type), + msg_text or "", + _convert_ms_timestamp(msg_created_at), + ) + + doc_cell = _build_document_cell( + doc_name, mime_type, doc_size, doc_url, doc_type, filename + ) + + html_rows.append( + common + + ( + doc_cell, + doc_name or "", + mime_type or "", + _format_file_size(doc_size), + _resolve_doc_type(doc_type), + doc_url or "", + ) + ) + + tsv_rows.append( + common + + ( + "", # no HTML in TSV + doc_name or "", + mime_type or "", + _format_file_size(doc_size), + _resolve_doc_type(doc_type), + doc_url or "", + ) + ) + + # HTML report + report_name = "Nova AI Chatbot - HistoryDetailDocuments" + report = ArtifactHtmlReport(report_name) + report.start_artifact_report(report_folder, report_name) + report.add_script() + report.write_artifact_data_table( + headers, html_rows, file_found, html_escape=False + ) + report.end_artifact_report() + + # TSV + scripts.ilapfuncs.tsv(report_folder, headers, tsv_rows, report_name, file_found) + + # Timeline (Msg. Timestamp, index 9) + scripts.ilapfuncs.timeline(report_folder, report_name, tsv_rows, headers) diff --git a/scripts/artifacts/AIChatbotNovaHistoryDetailImage.py b/scripts/artifacts/AIChatbotNovaHistoryDetailImage.py new file mode 100644 index 00000000..c322efd5 --- /dev/null +++ b/scripts/artifacts/AIChatbotNovaHistoryDetailImage.py @@ -0,0 +1,395 @@ +__artifacts_v2__ = { + "nova_chatbot_images": { + "name": "Nova AI Chatbot - Images (Generated & Submitted)", + "description": ( + "Extracts all image records from the AI Chatbot - Nova app " + "(HistoryDetailImage table). Each row represents one image and is enriched " + "with the parent message context from HistoryDetail and the parent " + "conversation context from History. " + "Images are correctly identified by the parent message role: " + "USER messages contain images submitted by the device user (e.g., vision queries); " + "ASSISTANT messages contain images generated by the AI. " + "Both types are stored on Firebase Storage; no local copies are predictably " + "available offline. The report shows the prompt, metadata, and a forensic note " + "explaining the image origin and storage behaviour. Generation state, pipeline, " + "and style ID are included for AI‑generated images where applicable." + ), + "author": "Guilherme Guilherme", + "version": "0.5", + "date": "2026-05-03", + "requirements": "none", + "category": "AI Chatbot - Nova", + "notes": ( + "Database: com.scaleup.chatai/databases/chat-ai.db. " + "HistoryDetailImage.url stores a Firebase Storage object path. " + "Identification of image origin is based on the parent message's type: " + "0 = USER → user‑submitted image; 1 = ASSISTANT → AI‑generated image. " + "The source column in HistoryDetailImage is ignored because it is often " + "misleading (e.g., vision images are marked as source=0 but are user‑submitted). " + "AI‑generated images are temporarily cached in cache/image_manager_disk_cache/*.0 " + "but the filenames are SHA‑256 hashes of signed URLs containing a token not " + "stored on the device; automatic matching is impossible. User‑submitted images " + "are not cached locally. " + "state: 1=Success, 0=Pending, 2=Failed (only relevant for AI‑generated). " + "pipeline identifies the generation engine (e.g. flux_tpu). " + "styleId references the visual style preset selected by the user. " + "softDeleted is inherited from the parent History record." + ), + "paths": ("*/com.scaleup.chatai/databases/chat-ai.db",), + "function": "get_nova_chatbot_images", + } +} + +import os +import shutil +import sqlite3 +import datetime +import html as html_module +from scripts.artifact_report import ArtifactHtmlReport +import scripts.ilapfuncs + +# --------------------------------------------------------------------------- +# Known mappings for the chatBotModel integer field. +# --------------------------------------------------------------------------- +CHAT_BOT_MODEL_MAP = { + 0: "ChatGPT 3.5", + 1: "GPT-5", + 2: "GPT-4o", + 3: "Bard / Image Gen.", + 4: "Image Generator", + 5: "Vision", + 6: "Google Vision", + 7: "Document", + 8: "LLaMA 2", + 9: "Nova", + 10: "Gemini", + 11: "Superbot", + 12: "Logo Generator", + 13: "Tattoo Generator", + 14: "Web Search", + 15: "Claude", + 16: "DeepSeek", + 17: "Signature Generator", + 18: "Mistral", + 19: "Grok", + 20: "DeepSeek R1", + 21: "AI Filter", + 22: "Voice Chat", + 23: "Snap & Solve", + 24: "Study Planner", + 25: "Quiz Maker", + 26: "Essay Helper", + 27: "Gemini 3 Pro", + 28: "GPT-5.1", + 29: "GPT-4o Mini", +} + +IMAGE_STATE_MAP = { + 0: "Pending", + 1: "Success", + 2: "Failed", +} + +# --------------------------------------------------------------------------- +# SQL (includes msg_type from HistoryDetail) +# --------------------------------------------------------------------------- +QUERY = """ +SELECT + i.id AS img_id, + i.historyDetailID AS msg_id, + i.url AS img_url, + i.prompt AS prompt, + i.state AS state, + i.mimeType AS mime_type, + i.styleId AS style_id, + i.source AS source, + i.sourceUrl AS source_url, + i.pipeline AS pipeline, + + hd.historyID AS conv_id, + hd.type AS msg_type, + hd.text AS msg_text, + hd.createdAt AS msg_created_at, + + h.UUID AS conv_uuid, + h.title AS conv_title, + h.chatBotModel AS chat_bot_model, + h.softDeleted AS soft_deleted + +FROM HistoryDetailImage i +INNER JOIN HistoryDetail hd ON hd.id = i.historyDetailID +INNER JOIN History h ON h.id = hd.historyID +ORDER BY i.id ASC +""" + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _e(text): + return html_module.escape(str(text)) if text else "" + + +def _convert_ms_timestamp(ms): + if ms is None: + return "" + try: + return datetime.datetime.utcfromtimestamp(ms / 1000).strftime( + "%Y-%m-%d %H:%M:%S UTC" + ) + except (OSError, OverflowError, ValueError): + return str(ms) + + +def _resolve_model(model_int): + if model_int is None: + return "Unknown" + name = CHAT_BOT_MODEL_MAP.get(model_int) + return f"{name} ({model_int})" if name else f"Unknown Model ({model_int})" + + +def _resolve_state(state_int): + if state_int is None: + return "" + label = IMAGE_STATE_MAP.get(state_int) + return f"{label} ({state_int})" if label else f"Unknown ({state_int})" + + +def _format_soft_deleted(value): + return "DELETED" if value == 1 else "No" + + +def _format_role(type_int): + return {0: "USER", 1: "ASSISTANT"}.get(type_int, f"UNKNOWN ({type_int})") + + +def _get_image_origin(msg_type): + """Return 'user-submitted' for msg_type=0, 'AI-generated' for msg_type=1.""" + if msg_type == 0: + return ("user-submitted", "User‑submitted image") + elif msg_type == 1: + return ("ai-generated", "AI‑generated image") + else: + return ("unknown", "Unknown origin") + + +# --------------------------------------------------------------------------- +# Image file resolution (always None) +# --------------------------------------------------------------------------- +def _resolve_image_file(db_url, seeker, images_dir): + return None + + +# --------------------------------------------------------------------------- +# HTML cell builder +# --------------------------------------------------------------------------- + + +def _build_image_cell( + img_url, prompt, state, mime_type, pipeline, style_id, msg_type, filename +): + """ + Build HTML cell using msg_type to determine image origin. + """ + origin_key, origin_label = _get_image_origin(msg_type) + cell = '
' + + if prompt: + cell += f'
Prompt: {_e(prompt)}
' + + if origin_key == "ai-generated": + cell += ( + f'
' + f" 🤖 AI‑generated image
" + f" This image was created by the AI based on the user prompt. " + f" It is stored on Firebase Storage; a temporary local copy may exist " + f" in cache/image_manager_disk_cache/*.0 but the filename " + f" is a hash of a signed URL that includes a token not stored on the device. " + f" Manual inspection of .0 files is recommended.
" + f" Forensic action: Examine .0 files directly as JPEG." + f"
" + ) + elif origin_key == "user-submitted": + cell += ( + f'
' + f" 📤 User‑submitted image
" + f" This image was actively uploaded by the device user (e.g., as part of a vision query). " + f" The file content is stored on Firebase Storage and is not cached locally. " + f" Only the metadata record remains on the device." + f"
" + ) + else: + cell += ( + f'
' + f" ❓ Unknown image origin
" + f" The parent message type is {_e(str(msg_type))} – cannot determine if user‑submitted or AI‑generated." + f"
" + ) + + if pipeline: + cell += f"
Pipeline: {_e(pipeline)}
" + if style_id is not None: + cell += f"
Style ID: {_e(str(style_id))}
" + if mime_type: + cell += f"
MIME Type: {_e(mime_type)}
" + + cell += ( + f'
' + f" Firebase path:
" + f' {_e(img_url)}' + f"
" + ) + cell += "
" + return cell + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +def get_nova_chatbot_images(files_found, report_folder, seeker, wrap_text): + for file_found in files_found: + file_found = str(file_found) + if not file_found.endswith("chat-ai.db"): + continue + + try: + db = sqlite3.connect(file_found) + cursor = db.cursor() + cursor.execute(QUERY) + rows_raw = cursor.fetchall() + db.close() + except Exception as e: + scripts.ilapfuncs.logfunc( + f"[nova_chatbot_images] Error reading {file_found}: {e}" + ) + continue + + if not rows_raw: + scripts.ilapfuncs.logfunc( + f"[nova_chbot_images] No image records found in {file_found}." + ) + continue + + images_dir = os.path.join(report_folder, "nova_images") + os.makedirs(images_dir, exist_ok=True) + + headers = [ + "Image ID", + "Msg. ID", + "Conv. ID", + "Conv. UUID", + "Conv. Title", + "AI Model", + "Conv. Deleted", + "Msg. Role", + "Msg. Text", + "Msg. Timestamp (UTC)", + "Image Preview & Metadata", + "Prompt", + "State", + "Pipeline", + "Style ID", + "MIME Type", + "Firebase Path", + ] + + html_rows = [] + tsv_rows = [] + + for row in rows_raw: + ( + img_id, + msg_id, + img_url, + prompt, + state, + mime_type, + style_id, + source, + source_url, + pipeline, + conv_id, + msg_type, + msg_text, + msg_created_at, + conv_uuid, + conv_title, + chat_bot_model, + soft_deleted, + ) = row + + filename = _resolve_image_file(img_url, seeker, images_dir) + + common = ( + img_id, + msg_id, + conv_id, + conv_uuid or "", + conv_title or "", + _resolve_model(chat_bot_model), + _format_soft_deleted(soft_deleted), + _format_role(msg_type), + msg_text or "", + _convert_ms_timestamp(msg_created_at), + ) + + img_cell = _build_image_cell( + img_url, + prompt, + state, + mime_type, + pipeline, + style_id, + msg_type, + filename, + ) + + # For TSV we also include the origin (derived from msg_type) + origin_label = ( + "User-submitted" + if msg_type == 0 + else "AI-generated" + if msg_type == 1 + else "Unknown" + ) + + html_rows.append( + common + + ( + img_cell, + prompt or "", + _resolve_state(state), + pipeline or "", + style_id if style_id is not None else "", + mime_type or "", + img_url or "", + ) + ) + + tsv_rows.append( + common + + ( + "", # no HTML in TSV + prompt or "", + _resolve_state(state), + pipeline or "", + style_id if style_id is not None else "", + mime_type or "", + img_url or "", + ) + ) + + report_name = "Nova AI Chatbot - HistoryDetailImage" + report = ArtifactHtmlReport(report_name) + report.start_artifact_report(report_folder, report_name) + report.add_script() + report.write_artifact_data_table( + headers, html_rows, file_found, html_escape=False + ) + report.end_artifact_report() + + scripts.ilapfuncs.tsv(report_folder, headers, tsv_rows, report_name, file_found) + scripts.ilapfuncs.timeline(report_folder, report_name, tsv_rows, headers) diff --git a/scripts/artifacts/AIChatbotNovaHistoryDetailLink.py b/scripts/artifacts/AIChatbotNovaHistoryDetailLink.py new file mode 100644 index 00000000..30a343c9 --- /dev/null +++ b/scripts/artifacts/AIChatbotNovaHistoryDetailLink.py @@ -0,0 +1,296 @@ +__artifacts_v2__ = { + "nova_chatbot_links": { + "name": "Nova AI Chatbot - Shared Links", + "description": ( + "Extracts all link records from the AI Chatbot - Nova app " + "(HistoryDetailLink table). Each row represents one URL shared within " + "a conversation and is enriched with parent message context from " + "HistoryDetail and parent conversation context from History, including " + "the message text that accompanied the link, the role of the sender " + "(USER or ASSISTANT), the AI model used in the conversation, and the " + "soft-deleted status of the parent conversation. " + "Links are rendered as clickable anchors in the HTML report. " + "The table is currently empty in observed samples but the module is " + "future-proof and will extract records if the table is populated in " + "other device images or application versions." + ), + "author": "Guilherme Guilherme", + "version": "0.2", + "date": "2025-04-27", + "requirements": "none", + "category": "AI Chatbot - Nova", + "notes": ( + "Database: com.scaleup.chatai/databases/chat-ai.db. " + "HistoryDetailLink.url stores the full URL shared in the message. " + "Links may be shared by the USER (e.g. a webpage submitted for AI " + "analysis) or by the ASSISTANT (e.g. a reference link in a response). " + "The role of the message is determined by HistoryDetail.type: " + "0 = USER, 1 = ASSISTANT. " + "softDeleted is inherited from the parent History record; DELETED means " + "the conversation was removed by the user but the link record remains " + "physically in the database and is forensically recoverable. " + "If this report contains no rows the HistoryDetailLink table was empty " + "in the examined database — this is normal for the current app version." + ), + "paths": ("*/com.scaleup.chatai/databases/chat-ai.db",), + "function": "get_nova_chatbot_links", + } +} + +import sqlite3 +import datetime +import html as html_module +from scripts.artifact_report import ArtifactHtmlReport +import scripts.ilapfuncs + +# --------------------------------------------------------------------------- +# Known mappings for the chatBotModel integer field. +# Source: FirestoreHistory.EngineTypes enum ordinals from decompiled APK source +# (com.scaleup.chatai.ui.conversation.FirestoreHistory). +# The integer stored in the database is the ENUM ORDINAL (0-based position), +# NOT the botId from chatbotAgentMap. These are two independent systems. +# Image-generating engines: 3 (legacy Bard ordinal reused), 4, 12, 13, 17. +# NOTE: ordinal 3 ('bard') was reused for image generation in newer app versions; +# presence of HistoryDetailImage records confirms image generation regardless of label. +# NOTE: ordinal 20 ('deepSeekR1') — if reasoningContent is NULL the actual API +# call may have used DeepSeek V3; the field reflects the UI selector, not the API. +# --------------------------------------------------------------------------- +CHAT_BOT_MODEL_MAP = { + 0: "ChatGPT 3.5", # gpt-3.5 + 1: "GPT-5", # gpt-5 + 2: "GPT-4o", # gpt-4o + 3: "Bard / Image Gen.", # bard (legacy; reused for image generation) + 4: "Image Generator", # image-generator + 5: "Vision", # vision + 6: "Google Vision", # googleVision + 7: "Document", # document + 8: "LLaMA 2", # llama2 + 9: "Nova", # nova + 10: "Gemini", # gemini + 11: "Superbot", # superbot + 12: "Logo Generator", # logo-generator + 13: "Tattoo Generator", # tattoo-generator + 14: "Web Search", # webSearch + 15: "Claude", # claude + 16: "DeepSeek", # deepSeek + 17: "Signature Generator", # signature-generator + 18: "Mistral", # mistral + 19: "Grok", # grok + 20: "DeepSeek R1", # deepSeekR1 + 21: "AI Filter", # aiFilter + 22: "Voice Chat", # voiceChat + 23: "Snap & Solve", # snapAndSolve + 24: "Study Planner", # studyPlanner + 25: "Quiz Maker", # quizMaker + 26: "Essay Helper", # essayHelper + 27: "Gemini 3 Pro", # gemini-3-pro + 28: "GPT-5.1", # gpt-5.1 + 29: "GPT-4o Mini", # 4o-mini +} + +# --------------------------------------------------------------------------- +# SQL +# One row per HistoryDetailLink, enriched with parent message and +# conversation context. +# --------------------------------------------------------------------------- +QUERY = """ +SELECT + -- Link record + l.id AS link_id, + l.historyDetailID AS msg_id, + l.url AS link_url, + + -- Parent message context (HistoryDetail) + hd.historyID AS conv_id, + hd.type AS msg_type, + hd.text AS msg_text, + hd.createdAt AS msg_created_at, + + -- Parent conversation context (History) + h.UUID AS conv_uuid, + h.title AS conv_title, + h.chatBotModel AS chat_bot_model, + h.softDeleted AS soft_deleted + +FROM HistoryDetailLink l +INNER JOIN HistoryDetail hd ON hd.id = l.historyDetailID +INNER JOIN History h ON h.id = hd.historyID +ORDER BY l.id ASC +""" + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _e(text): + return html_module.escape(str(text)) if text else "" + + +def _convert_ms_timestamp(ms): + if ms is None: + return "" + try: + return datetime.datetime.utcfromtimestamp(ms / 1000).strftime( + "%Y-%m-%d %H:%M:%S UTC" + ) + except (OSError, OverflowError, ValueError): + return str(ms) + + +def _resolve_model(model_int): + if model_int is None: + return "Unknown" + name = CHAT_BOT_MODEL_MAP.get(model_int) + return f"{name} ({model_int})" if name else f"Unknown Model ({model_int})" + + +def _format_soft_deleted(value): + return "DELETED" if value == 1 else "No" + + +def _format_role(type_int): + return {0: "USER", 1: "ASSISTANT"}.get(type_int, f"UNKNOWN ({type_int})") + + +def _build_link_cell(url): + """Render a URL as a clearly labelled clickable anchor.""" + if not url: + return "" + return ( + f'
' + f' 🔗
' + f' {_e(url)}' + f"
" + ) + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +def get_nova_chatbot_links(files_found, report_folder, seeker, wrap_text): + """ + Entry point for the nova_chatbot_links artifact. + + Extracts every HistoryDetailLink record enriched with parent message and + conversation context. Outputs HTML report, TSV, and timeline. + Handles an empty HistoryDetailLink table gracefully. + """ + for file_found in files_found: + file_found = str(file_found) + if not file_found.endswith("chat-ai.db"): + continue + + try: + db = sqlite3.connect(file_found) + cursor = db.cursor() + cursor.execute(QUERY) + rows_raw = cursor.fetchall() + db.close() + except Exception as e: + scripts.ilapfuncs.logfunc( + f"[nova_chatbot_links] Error reading {file_found}: {e}" + ) + continue + + # Gracefully handle an empty table — log and produce an empty report + # so the examiner knows the module ran and the table had no records. + if not rows_raw: + scripts.ilapfuncs.logfunc( + f"[nova_chatbot_links] HistoryDetailLink table is empty in {file_found}." + ) + report_name = "Nova AI Chatbot - HistoryDetailLinks" + report = ArtifactHtmlReport(report_name) + report.start_artifact_report(report_folder, report_name) + report.add_script() + report.write_artifact_data_table( + [ + "Link ID", + "Msg. ID", + "Conv. ID", + "Conv. UUID", + "Conv. Title", + "AI Model", + "Conv. Deleted", + "Msg. Role", + "Msg. Text", + "Msg. Timestamp (UTC)", + "Link URL", + ], + [], + file_found, + html_escape=False, + ) + report.end_artifact_report() + continue + + headers = [ + # Link identity + "Link ID", + "Msg. ID", + "Conv. ID", + # Conversation context + "Conv. UUID", + "Conv. Title", + "AI Model", + "Conv. Deleted", + # Message context + "Msg. Role", + "Msg. Text", + "Msg. Timestamp (UTC)", + # Link (HTML rendered) + "Link URL", + ] + + html_rows = [] + tsv_rows = [] + + for row in rows_raw: + ( + link_id, + msg_id, + link_url, + conv_id, + msg_type, + msg_text, + msg_created_at, + conv_uuid, + conv_title, + chat_bot_model, + soft_deleted, + ) = row + + common = ( + link_id, + msg_id, + conv_id, + conv_uuid or "", + conv_title or "", + _resolve_model(chat_bot_model), + _format_soft_deleted(soft_deleted), + _format_role(msg_type), + msg_text or "", + _convert_ms_timestamp(msg_created_at), + ) + + html_rows.append(common + (_build_link_cell(link_url),)) + tsv_rows.append(common + (link_url or "",)) + + # HTML report + report_name = "Nova AI Chatbot - HistoryDetailLinks" + report = ArtifactHtmlReport(report_name) + report.start_artifact_report(report_folder, report_name) + report.add_script() + report.write_artifact_data_table( + headers, html_rows, file_found, html_escape=False + ) + report.end_artifact_report() + + # TSV + scripts.ilapfuncs.tsv(report_folder, headers, tsv_rows, report_name, file_found) + + # Timeline (Msg. Timestamp, index 9) + scripts.ilapfuncs.timeline(report_folder, report_name, tsv_rows, headers) From abac85ba5dabaf61c7de4bb7c4b99ae4e386f728 Mon Sep 17 00:00:00 2001 From: Guilherme Guilherme Date: Tue, 19 May 2026 11:49:18 +0100 Subject: [PATCH 02/18] Add icons --- scripts/report_icons.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/report_icons.py b/scripts/report_icons.py index 857fed29..762c4414 100644 --- a/scripts/report_icons.py +++ b/scripts/report_icons.py @@ -38,6 +38,7 @@ 'default': 'user' }, 'AGGREGATE DICTIONARY': 'book', + 'AI CHATBOT - NOVA': 'message-circle', 'AIRDROP DISCOVERABLE': 'search', 'AIRDROP EMAILS': 'send', 'AIRDROP NUMBERS': 'smartphone', From c585946ba3b8c463a761d86aeb479a57bd066829 Mon Sep 17 00:00:00 2001 From: Guilherme Guilherme Date: Wed, 20 May 2026 19:58:10 +0100 Subject: [PATCH 03/18] Revert "Add icons" This reverts commit abac85ba5dabaf61c7de4bb7c4b99ae4e386f728. --- scripts/report_icons.py | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/report_icons.py b/scripts/report_icons.py index 762c4414..857fed29 100644 --- a/scripts/report_icons.py +++ b/scripts/report_icons.py @@ -38,7 +38,6 @@ 'default': 'user' }, 'AGGREGATE DICTIONARY': 'book', - 'AI CHATBOT - NOVA': 'message-circle', 'AIRDROP DISCOVERABLE': 'search', 'AIRDROP EMAILS': 'send', 'AIRDROP NUMBERS': 'smartphone', From eb345bb8a304e91bf01ad2edf3b09978867282e3 Mon Sep 17 00:00:00 2001 From: Guilherme Guilherme Date: Wed, 20 May 2026 19:59:54 +0100 Subject: [PATCH 04/18] Forget Changes --- .../artifacts/AIChatbotNovaConversations.py | 3 +- .../AIChatbotNovaHistoryDetailDocument.py | 1 - .../AIChatbotNovaHistoryDetailImage.py | 19 +- scripts/artifacts/AIChatbotNovaMediastore.py | 216 ++++++++++++++++++ scripts/report_icons.py | 1 + 5 files changed, 227 insertions(+), 13 deletions(-) create mode 100644 scripts/artifacts/AIChatbotNovaMediastore.py diff --git a/scripts/artifacts/AIChatbotNovaConversations.py b/scripts/artifacts/AIChatbotNovaConversations.py index f2679b8e..b8d71704 100644 --- a/scripts/artifacts/AIChatbotNovaConversations.py +++ b/scripts/artifacts/AIChatbotNovaConversations.py @@ -39,7 +39,6 @@ } import os -import shutil import sqlite3 import datetime import html as html_module @@ -200,7 +199,7 @@ def _build_image_html(msg_type, img_urls, img_prompts, img_states, resolved_file parts = [] for i, url in enumerate(urls): prompt = prompts[i] if i < len(prompts) else "" - filename = resolved_filenames[i] if i < len(resolved_filenames) else None + # filename = resolved_filenames[i] if i < len(resolved_filenames) else None cell = '
' diff --git a/scripts/artifacts/AIChatbotNovaHistoryDetailDocument.py b/scripts/artifacts/AIChatbotNovaHistoryDetailDocument.py index 4d1c7aef..750a8578 100644 --- a/scripts/artifacts/AIChatbotNovaHistoryDetailDocument.py +++ b/scripts/artifacts/AIChatbotNovaHistoryDetailDocument.py @@ -40,7 +40,6 @@ } import os -import shutil import sqlite3 import datetime import html as html_module diff --git a/scripts/artifacts/AIChatbotNovaHistoryDetailImage.py b/scripts/artifacts/AIChatbotNovaHistoryDetailImage.py index c322efd5..bcde5bd6 100644 --- a/scripts/artifacts/AIChatbotNovaHistoryDetailImage.py +++ b/scripts/artifacts/AIChatbotNovaHistoryDetailImage.py @@ -41,7 +41,6 @@ } import os -import shutil import sqlite3 import datetime import html as html_module @@ -321,7 +320,7 @@ def get_nova_chatbot_images(files_found, report_folder, seeker, wrap_text): soft_deleted, ) = row - filename = _resolve_image_file(img_url, seeker, images_dir) + # filename = _resolve_image_file(img_url, seeker, images_dir) common = ( img_id, @@ -344,17 +343,17 @@ def get_nova_chatbot_images(files_found, report_folder, seeker, wrap_text): pipeline, style_id, msg_type, - filename, + # filename ) # For TSV we also include the origin (derived from msg_type) - origin_label = ( - "User-submitted" - if msg_type == 0 - else "AI-generated" - if msg_type == 1 - else "Unknown" - ) + # origin_label = ( + # "User-submitted" + # if msg_type == 0 + # else "AI-generated" + # if msg_type == 1 + # else "Unknown" + # ) html_rows.append( common diff --git a/scripts/artifacts/AIChatbotNovaMediastore.py b/scripts/artifacts/AIChatbotNovaMediastore.py new file mode 100644 index 00000000..0d518882 --- /dev/null +++ b/scripts/artifacts/AIChatbotNovaMediastore.py @@ -0,0 +1,216 @@ +__artifacts_v2__ = { + "nova_mediastore": { + "name": "Nova AI Chatbot - MediaStore Aggregation", + "description": ( + "Extracts and correlates metadata for files created, downloaded, or shared " + "by the Nova AI Chatbot app that are cataloged inside Android's MediaStore database. " + "By querying the central system media provider index, this module resolves virtualized " + "Scoped Storage paths and identifies artifacts physically saved across shared system " + "folders (such as /sdcard/Movies/ or /sdcard/Download/) where owner_package_name matches " + "the Nova AI identifier. Images and videos are displayed as clickable thumbnails with metadata." + ), + "author": "Guilherme Guilherme", + "version": "1.0", + "date": "2026-05-20", + "requirements": "none", + "category": "AI Chatbot - Nova", + "notes": ( + "Target database: com.android.providers.media/databases/external.db. " + "The module parses the 'files' table, pulling records explicitly linked to " + "'com.scaleup.chatai' via the owner_package_name attribute. " + "It establishes clickable previews for media rows directly to the file's raw path." + ), + "paths": ("*/com.android.providers.media/databases/external.db*",), + "function": "get_nova_mediastore", + } +} + +import os +import csv +import datetime +import html as html_module +from scripts.artifact_report import ArtifactHtmlReport +import scripts.ilapfuncs +from scripts.ilapfuncs import open_sqlite_db_readonly + + +def _e(text): + return html_module.escape(str(text)) if text else "" + + +def _convert_timestamp(ts_sec): + if ts_sec is None: + return "" + try: + return datetime.datetime.utcfromtimestamp(ts_sec).strftime( + "%Y-%m-%d %H:%M:%S UTC" + ) + except (OSError, OverflowError, ValueError): + return str(ts_sec) + + +def _format_file_size(size_bytes): + if size_bytes is None: + return "" + try: + size_bytes = int(size_bytes) + if size_bytes < 1024: + return f"{size_bytes} B" + elif size_bytes < 1024**2: + return f"{size_bytes / 1024:.1f} KB" + elif size_bytes < 1024**2: + return f"{size_bytes / (1024**2):.1f} MB" + else: + return f"{size_bytes / (1024**3):.2f} GB" + except (ValueError, TypeError): + return str(size_bytes) + + +def get_nova_mediastore(files_found, report_folder, seeker, wrap_text): + """ + Entry point for the nova_mediastore artifact. + Queries the Android MediaProvider system database to locate all indexed entries + belonging to Nova AI, tracking down actual storage paths of media items. + """ + db_file = None + for file_found in files_found: + if file_found.endswith("external.db"): + db_file = file_found + break + + if not db_file: + scripts.ilapfuncs.logfunc( + "[nova_mediastore] MediaStore 'external.db' database not found in extraction." + ) + return + + try: + db = open_sqlite_db_readonly(db_file) + cursor = db.cursor() + # Querying MediaStore files schema filtering specifically by Nova package name + cursor.execute( + """ + SELECT + _id, + _data, + _size, + date_added, + date_modified, + mime_type, + title + FROM files + WHERE owner_package_name = 'com.scaleup.chatai' + ORDER BY date_added DESC + """ + ) + all_rows = cursor.fetchall() + except Exception as e: + scripts.ilapfuncs.logfunc( + f"[nova_mediastore] Failed to query external.db: {e}" + ) + return + + if not all_rows: + scripts.ilapfuncs.logfunc( + "[nova_mediastore] No entries found for package 'com.scaleup.chatai' inside MediaStore." + ) + db.close() + return + + headers = [ + "Media & Preview", + "Physical Storage Path", + "File Size", + "Date Added (UTC)", + "Date Modified (UTC)", + "MIME Type", + ] + html_rows = [] + tsv_rows = [] + + for row in all_rows: + media_id = row[0] + raw_path = str(row[1]) if row[1] else "" + size_bytes = row[2] + added_ts = row[3] + modified_ts = row[4] + mime_type = str(row[5]) if row[5] else "" + title = str(row[6]) if row[6] else "Unknown" + + size_str = _format_file_size(size_bytes) + added_str = _convert_timestamp(added_ts) + modified_str = _convert_timestamp(modified_ts) + + # Build absolute URL link to the mapped data file location + abs_url = "file://" + os.path.abspath(raw_path) + base_name = os.path.basename(raw_path) if raw_path else title + + # Create a preview column based on file MIME type categorization + if mime_type.startswith("image/"): + preview_html = ( + f'
' + f' ' + f' {_e(base_name)}' + f"
" + f" {_e(base_name)}" + f"
" + ) + elif mime_type.startswith("video/"): + preview_html = ( + f'
' + f'
" + f' {_e(base_name)} (🎬 Video)' + f"
" + ) + else: + # For documents or audio recordings, output a non-media generic folder block representation + preview_html = ( + f'
' + f' 📄
' + f' {_e(base_name)}' + f"
" + ) + + html_rows.append( + (preview_html, raw_path, size_str, added_str, modified_str, mime_type) + ) + tsv_rows.append( + (base_name, raw_path, size_str, added_str, modified_str, mime_type) + ) + + db.close() + + # Generate HTML report + report_name = "Nova AI Chatbot - MediaStore Aggregation" + report = ArtifactHtmlReport(report_name) + report.start_artifact_report(report_folder, report_name) + report.add_script() + report.write_artifact_data_table( + headers, html_rows, db_file, html_escape=False + ) + report.end_artifact_report() + + # Generate TSV Export + tsv_path = os.path.join(report_folder, f"{report_name}.tsv") + with open(tsv_path, "w", newline="", encoding="utf-8") as tsvfile: + writer = csv.writer(tsvfile, delimiter="\t") + writer.writerow( + [ + "Filename", + "Physical Storage Path", + "File Size", + "Date Added (UTC)", + "Date Modified (UTC)", + "MIME Type", + ] + ) + writer.writerows(tsv_rows) + + scripts.ilapfuncs.logfunc( + f"[nova_mediastore] Successfully mapped {len(all_rows)} files via MediaStore indexing using file:// URLs." + ) diff --git a/scripts/report_icons.py b/scripts/report_icons.py index 857fed29..762c4414 100644 --- a/scripts/report_icons.py +++ b/scripts/report_icons.py @@ -38,6 +38,7 @@ 'default': 'user' }, 'AGGREGATE DICTIONARY': 'book', + 'AI CHATBOT - NOVA': 'message-circle', 'AIRDROP DISCOVERABLE': 'search', 'AIRDROP EMAILS': 'send', 'AIRDROP NUMBERS': 'smartphone', From fc8a36a063a8291202ff32183281859711de8ac2 Mon Sep 17 00:00:00 2001 From: Guilherme Guilherme Date: Wed, 20 May 2026 20:00:27 +0100 Subject: [PATCH 05/18] Revert "Forget Changes" This reverts commit eb345bb8a304e91bf01ad2edf3b09978867282e3. --- .../artifacts/AIChatbotNovaConversations.py | 3 +- .../AIChatbotNovaHistoryDetailDocument.py | 1 + .../AIChatbotNovaHistoryDetailImage.py | 19 +- scripts/artifacts/AIChatbotNovaMediastore.py | 216 ------------------ scripts/report_icons.py | 1 - 5 files changed, 13 insertions(+), 227 deletions(-) delete mode 100644 scripts/artifacts/AIChatbotNovaMediastore.py diff --git a/scripts/artifacts/AIChatbotNovaConversations.py b/scripts/artifacts/AIChatbotNovaConversations.py index b8d71704..f2679b8e 100644 --- a/scripts/artifacts/AIChatbotNovaConversations.py +++ b/scripts/artifacts/AIChatbotNovaConversations.py @@ -39,6 +39,7 @@ } import os +import shutil import sqlite3 import datetime import html as html_module @@ -199,7 +200,7 @@ def _build_image_html(msg_type, img_urls, img_prompts, img_states, resolved_file parts = [] for i, url in enumerate(urls): prompt = prompts[i] if i < len(prompts) else "" - # filename = resolved_filenames[i] if i < len(resolved_filenames) else None + filename = resolved_filenames[i] if i < len(resolved_filenames) else None cell = '
' diff --git a/scripts/artifacts/AIChatbotNovaHistoryDetailDocument.py b/scripts/artifacts/AIChatbotNovaHistoryDetailDocument.py index 750a8578..4d1c7aef 100644 --- a/scripts/artifacts/AIChatbotNovaHistoryDetailDocument.py +++ b/scripts/artifacts/AIChatbotNovaHistoryDetailDocument.py @@ -40,6 +40,7 @@ } import os +import shutil import sqlite3 import datetime import html as html_module diff --git a/scripts/artifacts/AIChatbotNovaHistoryDetailImage.py b/scripts/artifacts/AIChatbotNovaHistoryDetailImage.py index bcde5bd6..c322efd5 100644 --- a/scripts/artifacts/AIChatbotNovaHistoryDetailImage.py +++ b/scripts/artifacts/AIChatbotNovaHistoryDetailImage.py @@ -41,6 +41,7 @@ } import os +import shutil import sqlite3 import datetime import html as html_module @@ -320,7 +321,7 @@ def get_nova_chatbot_images(files_found, report_folder, seeker, wrap_text): soft_deleted, ) = row - # filename = _resolve_image_file(img_url, seeker, images_dir) + filename = _resolve_image_file(img_url, seeker, images_dir) common = ( img_id, @@ -343,17 +344,17 @@ def get_nova_chatbot_images(files_found, report_folder, seeker, wrap_text): pipeline, style_id, msg_type, - # filename + filename, ) # For TSV we also include the origin (derived from msg_type) - # origin_label = ( - # "User-submitted" - # if msg_type == 0 - # else "AI-generated" - # if msg_type == 1 - # else "Unknown" - # ) + origin_label = ( + "User-submitted" + if msg_type == 0 + else "AI-generated" + if msg_type == 1 + else "Unknown" + ) html_rows.append( common diff --git a/scripts/artifacts/AIChatbotNovaMediastore.py b/scripts/artifacts/AIChatbotNovaMediastore.py deleted file mode 100644 index 0d518882..00000000 --- a/scripts/artifacts/AIChatbotNovaMediastore.py +++ /dev/null @@ -1,216 +0,0 @@ -__artifacts_v2__ = { - "nova_mediastore": { - "name": "Nova AI Chatbot - MediaStore Aggregation", - "description": ( - "Extracts and correlates metadata for files created, downloaded, or shared " - "by the Nova AI Chatbot app that are cataloged inside Android's MediaStore database. " - "By querying the central system media provider index, this module resolves virtualized " - "Scoped Storage paths and identifies artifacts physically saved across shared system " - "folders (such as /sdcard/Movies/ or /sdcard/Download/) where owner_package_name matches " - "the Nova AI identifier. Images and videos are displayed as clickable thumbnails with metadata." - ), - "author": "Guilherme Guilherme", - "version": "1.0", - "date": "2026-05-20", - "requirements": "none", - "category": "AI Chatbot - Nova", - "notes": ( - "Target database: com.android.providers.media/databases/external.db. " - "The module parses the 'files' table, pulling records explicitly linked to " - "'com.scaleup.chatai' via the owner_package_name attribute. " - "It establishes clickable previews for media rows directly to the file's raw path." - ), - "paths": ("*/com.android.providers.media/databases/external.db*",), - "function": "get_nova_mediastore", - } -} - -import os -import csv -import datetime -import html as html_module -from scripts.artifact_report import ArtifactHtmlReport -import scripts.ilapfuncs -from scripts.ilapfuncs import open_sqlite_db_readonly - - -def _e(text): - return html_module.escape(str(text)) if text else "" - - -def _convert_timestamp(ts_sec): - if ts_sec is None: - return "" - try: - return datetime.datetime.utcfromtimestamp(ts_sec).strftime( - "%Y-%m-%d %H:%M:%S UTC" - ) - except (OSError, OverflowError, ValueError): - return str(ts_sec) - - -def _format_file_size(size_bytes): - if size_bytes is None: - return "" - try: - size_bytes = int(size_bytes) - if size_bytes < 1024: - return f"{size_bytes} B" - elif size_bytes < 1024**2: - return f"{size_bytes / 1024:.1f} KB" - elif size_bytes < 1024**2: - return f"{size_bytes / (1024**2):.1f} MB" - else: - return f"{size_bytes / (1024**3):.2f} GB" - except (ValueError, TypeError): - return str(size_bytes) - - -def get_nova_mediastore(files_found, report_folder, seeker, wrap_text): - """ - Entry point for the nova_mediastore artifact. - Queries the Android MediaProvider system database to locate all indexed entries - belonging to Nova AI, tracking down actual storage paths of media items. - """ - db_file = None - for file_found in files_found: - if file_found.endswith("external.db"): - db_file = file_found - break - - if not db_file: - scripts.ilapfuncs.logfunc( - "[nova_mediastore] MediaStore 'external.db' database not found in extraction." - ) - return - - try: - db = open_sqlite_db_readonly(db_file) - cursor = db.cursor() - # Querying MediaStore files schema filtering specifically by Nova package name - cursor.execute( - """ - SELECT - _id, - _data, - _size, - date_added, - date_modified, - mime_type, - title - FROM files - WHERE owner_package_name = 'com.scaleup.chatai' - ORDER BY date_added DESC - """ - ) - all_rows = cursor.fetchall() - except Exception as e: - scripts.ilapfuncs.logfunc( - f"[nova_mediastore] Failed to query external.db: {e}" - ) - return - - if not all_rows: - scripts.ilapfuncs.logfunc( - "[nova_mediastore] No entries found for package 'com.scaleup.chatai' inside MediaStore." - ) - db.close() - return - - headers = [ - "Media & Preview", - "Physical Storage Path", - "File Size", - "Date Added (UTC)", - "Date Modified (UTC)", - "MIME Type", - ] - html_rows = [] - tsv_rows = [] - - for row in all_rows: - media_id = row[0] - raw_path = str(row[1]) if row[1] else "" - size_bytes = row[2] - added_ts = row[3] - modified_ts = row[4] - mime_type = str(row[5]) if row[5] else "" - title = str(row[6]) if row[6] else "Unknown" - - size_str = _format_file_size(size_bytes) - added_str = _convert_timestamp(added_ts) - modified_str = _convert_timestamp(modified_ts) - - # Build absolute URL link to the mapped data file location - abs_url = "file://" + os.path.abspath(raw_path) - base_name = os.path.basename(raw_path) if raw_path else title - - # Create a preview column based on file MIME type categorization - if mime_type.startswith("image/"): - preview_html = ( - f'
' - f' ' - f' {_e(base_name)}' - f"
" - f" {_e(base_name)}" - f"
" - ) - elif mime_type.startswith("video/"): - preview_html = ( - f'
' - f'
" - f' {_e(base_name)} (🎬 Video)' - f"
" - ) - else: - # For documents or audio recordings, output a non-media generic folder block representation - preview_html = ( - f'
' - f' 📄
' - f' {_e(base_name)}' - f"
" - ) - - html_rows.append( - (preview_html, raw_path, size_str, added_str, modified_str, mime_type) - ) - tsv_rows.append( - (base_name, raw_path, size_str, added_str, modified_str, mime_type) - ) - - db.close() - - # Generate HTML report - report_name = "Nova AI Chatbot - MediaStore Aggregation" - report = ArtifactHtmlReport(report_name) - report.start_artifact_report(report_folder, report_name) - report.add_script() - report.write_artifact_data_table( - headers, html_rows, db_file, html_escape=False - ) - report.end_artifact_report() - - # Generate TSV Export - tsv_path = os.path.join(report_folder, f"{report_name}.tsv") - with open(tsv_path, "w", newline="", encoding="utf-8") as tsvfile: - writer = csv.writer(tsvfile, delimiter="\t") - writer.writerow( - [ - "Filename", - "Physical Storage Path", - "File Size", - "Date Added (UTC)", - "Date Modified (UTC)", - "MIME Type", - ] - ) - writer.writerows(tsv_rows) - - scripts.ilapfuncs.logfunc( - f"[nova_mediastore] Successfully mapped {len(all_rows)} files via MediaStore indexing using file:// URLs." - ) diff --git a/scripts/report_icons.py b/scripts/report_icons.py index 762c4414..857fed29 100644 --- a/scripts/report_icons.py +++ b/scripts/report_icons.py @@ -38,7 +38,6 @@ 'default': 'user' }, 'AGGREGATE DICTIONARY': 'book', - 'AI CHATBOT - NOVA': 'message-circle', 'AIRDROP DISCOVERABLE': 'search', 'AIRDROP EMAILS': 'send', 'AIRDROP NUMBERS': 'smartphone', From da5712bc49b0ef2c084b8a1397aa1d476058bc48 Mon Sep 17 00:00:00 2001 From: Guilherme Guilherme Date: Wed, 20 May 2026 23:38:47 +0100 Subject: [PATCH 06/18] Add User Media Submissions --- .../artifacts/AIChatbotNovaCachedImages.py | 71 +++- scripts/artifacts/AIChatbotNovaMediastore.py | 381 ++++++++++++++++++ scripts/report_icons.py | 1 + 3 files changed, 438 insertions(+), 15 deletions(-) create mode 100644 scripts/artifacts/AIChatbotNovaMediastore.py diff --git a/scripts/artifacts/AIChatbotNovaCachedImages.py b/scripts/artifacts/AIChatbotNovaCachedImages.py index f58839ce..fa782d1e 100644 --- a/scripts/artifacts/AIChatbotNovaCachedImages.py +++ b/scripts/artifacts/AIChatbotNovaCachedImages.py @@ -10,19 +10,22 @@ "Each image is displayed as a clickable thumbnail with file metadata." ), "author": "Guilherme Guilherme", - "version": "1.3", - "date": "2026-05-03", + "version": "1.4", + "date": "2026-05-20", "requirements": "none", "category": "AI Chatbot - Nova", "notes": ( "The Glide disk cache location: cache/image_manager_disk_cache/*.0. " "Each .0 file is a raw JPEG. The filename is a SHA-256 hash of the signed Firebase URL. " - "The module directly links to the original file using absolute paths. " + "The module searches for the cache directory using multiple fallback paths. " "For the preview to work, the report must be opened on the same computer that extracted " - "the data, and the browser must allow file:// links (most do when the report is also " - "opened from a file:// location)." + "the data, and the browser must allow file:// links." + ), + "paths": ( + "*/com.scaleup.chatai/cache/image_manager_disk_cache", + "*/data/data/com.scaleup.chatai/cache/image_manager_disk_cache", + "*/*/com.scaleup.chatai/cache/image_manager_disk_cache", ), - "paths": ("*/com.scaleup.chatai/cache/image_manager_disk_cache",), "function": "get_nova_cache_images", } } @@ -75,23 +78,66 @@ def get_nova_cache_images(files_found, report_folder, seeker, wrap_text): """ # Collect all unique cache directories from the glob matches cache_dirs = set() + for path in files_found: path = str(path) if os.path.isdir(path): cache_dirs.add(path) else: parent = os.path.dirname(path) - cache_dirs.add(parent) + if os.path.isdir(parent): + cache_dirs.add(parent) + + # If no cache directories found by glob, try manual fallback paths + if not cache_dirs: + extraction_root = getattr(seeker, "search_dir", "") + scripts.ilapfuncs.logfunc( + f"[nova_cache_images] Searching for cache in: {extraction_root}" + ) + + # Try common paths + fallback_paths = [ + os.path.join( + extraction_root, + "data", + "data", + "com.scaleup.chatai", + "cache", + "image_manager_disk_cache", + ), + os.path.join( + extraction_root, + "data", + "com.scaleup.chatai", + "cache", + "image_manager_disk_cache", + ), + os.path.join( + extraction_root, + "com.scaleup.chatai", + "cache", + "image_manager_disk_cache", + ), + ] + + for fb_path in fallback_paths: + if os.path.isdir(fb_path): + cache_dirs.add(fb_path) + scripts.ilapfuncs.logfunc( + f"[nova_cache_images] Found cache via fallback: {fb_path}" + ) + break if not cache_dirs: scripts.ilapfuncs.logfunc("[nova_cache_images] No cache directory found.") return - all_images = [] # list of dict with metadata and absolute path + all_images = [] for cache_dir in cache_dirs: if not os.path.isdir(cache_dir): continue + scripts.ilapfuncs.logfunc(f"[nova_cache_images] Scanning: {cache_dir}") for fname in os.listdir(cache_dir): if not fname.endswith(".0"): continue @@ -117,7 +163,6 @@ def get_nova_cache_images(files_found, report_folder, seeker, wrap_text): all_images.sort(key=lambda x: x["mtime"], reverse=True) - # Prepare HTML rows headers = [ "Thumbnail & Filename", "File Size", @@ -128,10 +173,8 @@ def get_nova_cache_images(files_found, report_folder, seeker, wrap_text): tsv_rows = [] for img in all_images: - # Convert absolute path to file:// URL abs_url = "file://" + os.path.abspath(img["abs_path"]) - # For display, use the basename as label - display_name = f"{img['original_name']}" + display_name = img["original_name"] thumbnail_html = ( f'
' f' ' @@ -148,7 +191,6 @@ def get_nova_cache_images(files_found, report_folder, seeker, wrap_text): html_rows.append((thumbnail_html, size_str, mtime_str, img["original_name"])) tsv_rows.append((display_name, size_str, mtime_str, img["original_name"])) - # Generate HTML report directly inside report_folder (top-level _HTML) report_name = "Nova AI Chatbot - Cached Images" report = ArtifactHtmlReport(report_name) report.start_artifact_report(report_folder, report_name) @@ -158,7 +200,6 @@ def get_nova_cache_images(files_found, report_folder, seeker, wrap_text): ) report.end_artifact_report() - # TSV export tsv_path = os.path.join(report_folder, f"{report_name}.tsv") with open(tsv_path, "w", newline="", encoding="utf-8") as tsvfile: writer = csv.writer(tsvfile, delimiter="\t") @@ -168,5 +209,5 @@ def get_nova_cache_images(files_found, report_folder, seeker, wrap_text): writer.writerows(tsv_rows) scripts.ilapfuncs.logfunc( - f"[nova_cache_images] Displayed {len(all_images)} cached images using file:// links." + f"[nova_cache_images] Displayed {len(all_images)} cached images." ) diff --git a/scripts/artifacts/AIChatbotNovaMediastore.py b/scripts/artifacts/AIChatbotNovaMediastore.py new file mode 100644 index 00000000..dc0c65b6 --- /dev/null +++ b/scripts/artifacts/AIChatbotNovaMediastore.py @@ -0,0 +1,381 @@ +__artifacts_v2__ = { + "nova_user_submissions": { + "name": "Nova AI Chatbot - User Media Submissions", + "description": ( + "Identifies ALL media files submitted by the user to Nova AI Chatbot. " + "This includes uploaded documents and photos captured using the in-app camera. " + "The artifact lists recovered filenames, user context, timestamps, MIME types, " + "and resolved physical paths from the extracted filesystem." + ), + "author": "Guilherme Guilherme", + "version": "2.7", + "date": "2026-05-20", + "requirements": "none", + "category": "AI Chatbot - Nova", + "notes": ( + "Sources: chat-ai.db and Android MediaStore databases. " + "Resolves extracted filesystem paths and falls back to filename search when needed. " + "This module does not embed previews; it focuses on metadata and physical path reporting." + ), + "paths": ( + "*/com.scaleup.chatai/databases/chat-ai.db", + "*/com.android.providers.media/databases/external*.db", + "*/com.google.android.providers.media.module/databases/external*.db", + ), + "function": "get_nova_user_submissions", + } +} + +import os +import csv +import sqlite3 +import datetime +import html as html_module +from scripts.artifact_report import ArtifactHtmlReport +import scripts.ilapfuncs + + +def _e(text): + return html_module.escape(str(text)) if text else "" + + +def _convert_ms_timestamp(ms): + if ms is None: + return "" + try: + return datetime.datetime.utcfromtimestamp(ms / 1000).strftime( + "%Y-%m-%d %H:%M:%S UTC" + ) + except Exception: + return str(ms) + + +def _convert_sec_timestamp(ts): + if ts is None: + return "" + try: + return datetime.datetime.utcfromtimestamp(int(ts)).strftime( + "%Y-%m-%d %H:%M:%S UTC" + ) + except Exception: + return str(ts) + + +def _format_file_size(size_bytes): + if size_bytes is None: + return "" + try: + size_bytes = int(size_bytes) + if size_bytes < 1024: + return f"{size_bytes} B" + if size_bytes < 1024**2: + return f"{size_bytes / 1024:.1f} KB" + if size_bytes < 1024**3: + return f"{size_bytes / (1024**2):.1f} MB" + return f"{size_bytes / (1024**3):.2f} GB" + except Exception: + return str(size_bytes) + + +def _normalize_media_path(media_path): + if not media_path: + return None + p = media_path.replace("\\", "/") + if p.startswith("/storage/emulated/0/"): + p = p.replace("/storage/emulated/0/", "/data/media/0/", 1) + return p + + +def _resolve_extraction_path(extraction_root, media_path): + if not extraction_root or not media_path: + return None + + normalized = _normalize_media_path(media_path) + candidate = os.path.normpath(os.path.join(extraction_root, normalized.lstrip("/"))) + if os.path.exists(candidate): + return candidate + + fname = os.path.basename(normalized) + filename_candidate = os.path.normpath(os.path.join(extraction_root, fname)) + if os.path.exists(filename_candidate): + return filename_candidate + + data_media_candidate = os.path.normpath( + os.path.join(extraction_root, "data/media/0", fname) + ) + if os.path.exists(data_media_candidate): + return data_media_candidate + + for root, dirs, files in os.walk(extraction_root): + if fname in files: + return os.path.join(root, fname) + + return None + + +def get_nova_user_submissions(files_found, report_folder, seeker, wrap_text): + nova_db = None + media_db = None + + for file_found in files_found: + file_found = str(file_found) + if "chat-ai.db" in file_found: + nova_db = file_found + elif "external" in file_found and file_found.endswith(".db"): + media_db = file_found + + if not nova_db: + scripts.ilapfuncs.logfunc("[nova_user_submissions] Nova database not found.") + return + + extraction_root = getattr(seeker, "search_dir", "") + scripts.ilapfuncs.logfunc( + f"[nova_user_submissions] Extraction root: {extraction_root}" + ) + + media_lookup = {} + + if media_db and os.path.exists(media_db): + try: + db = sqlite3.connect(media_db) + cursor = db.cursor() + cursor.execute(""" + SELECT _display_name, _data, _size, date_added, mime_type + FROM files + WHERE _data IS NOT NULL + """) + for ( + display_name, + data_path, + size, + date_added, + mime_type, + ) in cursor.fetchall(): + normalized_path = _normalize_media_path(data_path or "") + if not normalized_path: + continue + + if not any( + x in normalized_path.lower() + for x in ["/download/", "/nova/", "/com.scaleup.chatai/"] + ): + continue + + key = (display_name or os.path.basename(normalized_path)).lower() + media_lookup[key] = { + "media_path": normalized_path, + "extraction_path": _resolve_extraction_path( + extraction_root, normalized_path + ), + "size": size, + "timestamp": date_added, + "mime": mime_type or "", + } + + db.close() + except Exception as e: + scripts.ilapfuncs.logfunc( + f"[nova_user_submissions] Error reading MediaStore: {e}" + ) + + all_items = [] + + query_docs = """ + SELECT + hdd.name, + hdd.url, + hdd.mimeType, + hdd.size, + hd.text, + hd.createdAt, + h.title + FROM HistoryDetailDocument hdd + INNER JOIN HistoryDetail hd ON hd.id = hdd.historyDetailID + INNER JOIN History h ON h.id = hd.historyID + WHERE hd.type = 0 + ORDER BY hd.createdAt DESC + """ + + try: + db = sqlite3.connect(nova_db) + cursor = db.cursor() + cursor.execute(query_docs) + for ( + file_name, + firebase_url, + mime_type, + size_db, + message, + created_at, + conversation, + ) in cursor.fetchall(): + media_match = media_lookup.get((file_name or "").lower()) + all_items.append( + { + "type": "submitted_document", + "name": file_name or "Unknown", + "firebase_url": firebase_url or "", + "mime": mime_type or "", + "size_db": size_db, + "message": message or "", + "timestamp": created_at, + "conversation": conversation or "Untitled", + "media_path": media_match["media_path"] if media_match else None, + "extraction_path": media_match["extraction_path"] + if media_match + else None, + } + ) + db.close() + except Exception as e: + scripts.ilapfuncs.logfunc( + f"[nova_user_submissions] Error querying documents: {e}" + ) + + if media_db and os.path.exists(media_db): + try: + db = sqlite3.connect(media_db) + cursor = db.cursor() + cursor.execute(""" + SELECT _display_name, _data, _size, date_added, mime_type + FROM files + WHERE bucket_display_name = 'Nova' OR _data LIKE '%/Nova/%' + ORDER BY date_added DESC + """) + for ( + display_name, + data_path, + size, + date_added, + mime_type, + ) in cursor.fetchall(): + normalized_path = _normalize_media_path(data_path or "") + extraction_path = _resolve_extraction_path( + extraction_root, normalized_path + ) + all_items.append( + { + "type": "camera_photo", + "name": display_name + or os.path.basename(normalized_path or "") + or "Unknown", + "mime": mime_type or "image/jpeg", + "size_db": size, + "message": "", + "timestamp": date_added, + "conversation": "Camera photo (not associated with a message)", + "media_path": normalized_path, + "extraction_path": extraction_path, + } + ) + db.close() + except Exception as e: + scripts.ilapfuncs.logfunc( + f"[nova_user_submissions] Error querying camera photos: {e}" + ) + + deduped = [] + seen = set() + for item in all_items: + key = (item["name"].lower(), item.get("media_path") or "") + if key in seen: + continue + seen.add(key) + deduped.append(item) + + deduped.sort(key=lambda x: x.get("timestamp", 0) or 0, reverse=True) + + if not deduped: + scripts.ilapfuncs.logfunc("[nova_user_submissions] No media found.") + return + + headers = ( + "File Name", + "Type", + "User Message / Context", + "Conversation", + "Date (UTC)", + "Size", + "MIME Type", + "Physical Path", + ) + + rows = [] + tsv_rows = [] + + for item in deduped: + type_label = ( + "📤 Submitted to AI" + if item["type"] == "submitted_document" + else "📷 Camera Photo" + ) + context = item.get("message") or "No message recorded" + if isinstance(context, str) and len(context) > 150: + context = _e(context[:150] + "...") + date_str = ( + _convert_ms_timestamp(item["timestamp"]) + if item["type"] == "submitted_document" + else _convert_sec_timestamp(item["timestamp"]) + ) + size_str = _format_file_size(item.get("size_db")) + + if item.get("extraction_path") and os.path.exists(item["extraction_path"]): + physical_path = item["extraction_path"] + elif item.get("media_path"): + physical_path = item["media_path"] + else: + physical_path = "Cloud-only (Firebase Storage)" + + rows.append( + ( + _e(item["name"]), + type_label, + context, + _e(item["conversation"]), + date_str, + size_str, + _e(item.get("mime") or ""), + _e(physical_path), + ) + ) + + tsv_rows.append( + ( + item["name"], + type_label, + context, + item["conversation"], + date_str, + size_str, + item.get("mime") or "", + physical_path, + ) + ) + + report_name = "Nova AI Chatbot - User Media Submissions" + report = ArtifactHtmlReport(report_name) + report.start_artifact_report(report_folder, report_name) + report.add_script() + report.write_artifact_data_table(headers, rows, nova_db, html_escape=False) + report.end_artifact_report() + + tsv_path = os.path.join(report_folder, f"{report_name}.tsv") + with open(tsv_path, "w", newline="", encoding="utf-8") as tsvfile: + writer = csv.writer(tsvfile, delimiter="\t") + writer.writerow( + [ + "File Name", + "Type", + "User Message", + "Conversation", + "Date (UTC)", + "Size", + "MIME Type", + "Physical Path", + ] + ) + writer.writerows(tsv_rows) + + scripts.ilapfuncs.logfunc( + f"[nova_user_submissions] Found {len(deduped)} total items." + ) diff --git a/scripts/report_icons.py b/scripts/report_icons.py index 857fed29..762c4414 100644 --- a/scripts/report_icons.py +++ b/scripts/report_icons.py @@ -38,6 +38,7 @@ 'default': 'user' }, 'AGGREGATE DICTIONARY': 'book', + 'AI CHATBOT - NOVA': 'message-circle', 'AIRDROP DISCOVERABLE': 'search', 'AIRDROP EMAILS': 'send', 'AIRDROP NUMBERS': 'smartphone', From 5e366d80942d9c91d1457b59dc6c847ded4d2f5c Mon Sep 17 00:00:00 2001 From: Guilherme Guilherme Date: Thu, 21 May 2026 16:57:18 +0100 Subject: [PATCH 07/18] Fix Modules --- .../artifacts/AIChatbotNovaCachedImages.py | 237 ++------ .../artifacts/AIChatbotNovaConversations.py | 4 +- scripts/artifacts/AIChatbotNovaHistory.py | 368 +++++-------- .../artifacts/AIChatbotNovaHistoryDetail.py | 436 ++++++--------- .../AIChatbotNovaHistoryDetailDocument.py | 505 ++++++------------ .../AIChatbotNovaHistoryDetailImage.py | 440 +++++---------- .../AIChatbotNovaHistoryDetailLink.py | 6 +- scripts/artifacts/AIChatbotNovaMediastore.py | 474 ++++++---------- 8 files changed, 825 insertions(+), 1645 deletions(-) diff --git a/scripts/artifacts/AIChatbotNovaCachedImages.py b/scripts/artifacts/AIChatbotNovaCachedImages.py index fa782d1e..8ccf705d 100644 --- a/scripts/artifacts/AIChatbotNovaCachedImages.py +++ b/scripts/artifacts/AIChatbotNovaCachedImages.py @@ -1,213 +1,94 @@ __artifacts_v2__ = { "nova_cache_images": { - "name": "Nova AI Chatbot - Cached Images (Glide Disk Cache)", + "name": "Cached Images (Glide Disk Cache)", "description": ( - "Extracts all cached image files from the Nova AI Chatbot app's Glide disk cache " - "(cache/image_manager_disk_cache/*.0). These .0 files are raw JPEG images that were " - "downloaded from Firebase Storage and cached locally. The module embeds the original " - "cache file paths as file:// URLs in the HTML report, allowing direct preview from " - "the extracted data folder. No copying is performed, preserving forensic integrity. " - "Each image is displayed as a clickable thumbnail with file metadata." + "Extracts cached image files from the Nova AI Chatbot Glide disk cache " + "(cache/image_manager_disk_cache/*.0). These files are raw JPEG images " + "downloaded from Firebase Storage and cached locally." ), "author": "Guilherme Guilherme", - "version": "1.4", - "date": "2026-05-20", + "version": "2.0", + "date": "2026-05-21", "requirements": "none", "category": "AI Chatbot - Nova", - "notes": ( - "The Glide disk cache location: cache/image_manager_disk_cache/*.0. " - "Each .0 file is a raw JPEG. The filename is a SHA-256 hash of the signed Firebase URL. " - "The module searches for the cache directory using multiple fallback paths. " - "For the preview to work, the report must be opened on the same computer that extracted " - "the data, and the browser must allow file:// links." - ), + "notes": "Glide disk cache location: cache/image_manager_disk_cache/*.0.", "paths": ( - "*/com.scaleup.chatai/cache/image_manager_disk_cache", - "*/data/data/com.scaleup.chatai/cache/image_manager_disk_cache", - "*/*/com.scaleup.chatai/cache/image_manager_disk_cache", + "*/com.scaleup.chatai/cache/image_manager_disk_cache/*", + "*/data/data/com.scaleup.chatai/cache/image_manager_disk_cache/*", ), "function": "get_nova_cache_images", + "output_types": "standard", + "artifact_icon": "image", } } import os -import csv import datetime -import html as html_module +from datetime import timezone from scripts.artifact_report import ArtifactHtmlReport -import scripts.ilapfuncs +from scripts.ilapfuncs import logfunc, tsv, media_to_html -def _e(text): - return html_module.escape(str(text)) if text else "" +def get_nova_cache_images(files_found, report_folder, seeker, wrap_text): + logfunc("Processing data for Nova Cached Images") + data_list = [] -def _convert_timestamp(ts_sec): - if ts_sec is None: - return "" - try: - return datetime.datetime.utcfromtimestamp(ts_sec).strftime( - "%Y-%m-%d %H:%M:%S UTC" - ) - except (OSError, OverflowError, ValueError): - return str(ts_sec) + for file_found in files_found: + file_found = str(file_found) + if os.path.isdir(file_found): + continue + fname = os.path.basename(file_found) + if not fname.endswith(".0"): + continue -def _format_file_size(size_bytes): - if size_bytes is None: - return "" - try: - size_bytes = int(size_bytes) - if size_bytes < 1024: - return f"{size_bytes} B" - elif size_bytes < 1024**2: - return f"{size_bytes / 1024:.1f} KB" - elif size_bytes < 1024**3: - return f"{size_bytes / (1024**2):.1f} MB" - else: - return f"{size_bytes / (1024**3):.2f} GB" - except (ValueError, TypeError): - return str(size_bytes) + try: + stat = os.stat(file_found) + size_bytes = stat.st_size + # Modern, non-deprecated timezone conversion + mtime = datetime.datetime.fromtimestamp(stat.st_mtime, timezone.utc) + mtime_str = mtime.strftime("%Y-%m-%d %H:%M:%S UTC") -def get_nova_cache_images(files_found, report_folder, seeker, wrap_text): - """ - Entry point for the nova_cache_images artifact. - Scans for *.0 files, and generates an HTML gallery with direct file:// links - to the original cache files. No copying is performed. - """ - # Collect all unique cache directories from the glob matches - cache_dirs = set() - - for path in files_found: - path = str(path) - if os.path.isdir(path): - cache_dirs.add(path) - else: - parent = os.path.dirname(path) - if os.path.isdir(parent): - cache_dirs.add(parent) - - # If no cache directories found by glob, try manual fallback paths - if not cache_dirs: - extraction_root = getattr(seeker, "search_dir", "") - scripts.ilapfuncs.logfunc( - f"[nova_cache_images] Searching for cache in: {extraction_root}" - ) - - # Try common paths - fallback_paths = [ - os.path.join( - extraction_root, - "data", - "data", - "com.scaleup.chatai", - "cache", - "image_manager_disk_cache", - ), - os.path.join( - extraction_root, - "data", - "com.scaleup.chatai", - "cache", - "image_manager_disk_cache", - ), - os.path.join( - extraction_root, - "com.scaleup.chatai", - "cache", - "image_manager_disk_cache", - ), - ] - - for fb_path in fallback_paths: - if os.path.isdir(fb_path): - cache_dirs.add(fb_path) - scripts.ilapfuncs.logfunc( - f"[nova_cache_images] Found cache via fallback: {fb_path}" - ) - break - - if not cache_dirs: - scripts.ilapfuncs.logfunc("[nova_cache_images] No cache directory found.") - return + # Mandatory framework call: copies images to output structure and populates LAVA tracking manifests + media_to_html(fname, file_found, report_folder) - all_images = [] + # Parse path so it consistently normalizes from the extraction /data node onward + normalized_path = file_found.replace("\\", "/") + if "/data/" in normalized_path: + display_path = "/data/" + normalized_path.split("/data/", 1)[1] + elif "data/data/" in normalized_path: + display_path = "/data/data/" + normalized_path.split("data/data/", 1)[1] + else: + display_path = normalized_path - for cache_dir in cache_dirs: - if not os.path.isdir(cache_dir): - continue - scripts.ilapfuncs.logfunc(f"[nova_cache_images] Scanning: {cache_dir}") - for fname in os.listdir(cache_dir): - if not fname.endswith(".0"): - continue - src_path = os.path.join(cache_dir, fname) - try: - stat = os.stat(src_path) - all_images.append( - { - "original_name": fname, - "abs_path": src_path, - "size": stat.st_size, - "mtime": stat.st_mtime, - } - ) - except Exception as e: - scripts.ilapfuncs.logfunc( - f"[nova_cache_images] Error reading {src_path}: {e}" - ) - - if not all_images: - scripts.ilapfuncs.logfunc("[nova_cache_images] No .0 cache files found.") - return + data_list.append((fname, size_bytes, mtime_str, display_path)) - all_images.sort(key=lambda x: x["mtime"], reverse=True) + except Exception as e: + logfunc(f"[nova_cache_images] Error reading {file_found}: {e}") - headers = [ - "Thumbnail & Filename", - "File Size", - "Last Modified (UTC)", - "Original Cache Filename", - ] - html_rows = [] - tsv_rows = [] - - for img in all_images: - abs_url = "file://" + os.path.abspath(img["abs_path"]) - display_name = img["original_name"] - thumbnail_html = ( - f'
' - f' ' - f' {_e(display_name)}' - f"
" - f" {_e(display_name)}" - f"
" - ) - size_str = _format_file_size(img["size"]) - mtime_str = _convert_timestamp(img["mtime"]) - html_rows.append((thumbnail_html, size_str, mtime_str, img["original_name"])) - tsv_rows.append((display_name, size_str, mtime_str, img["original_name"])) - - report_name = "Nova AI Chatbot - Cached Images" + if not data_list: + logfunc("No Nova Cached Images data found.") + return + + report_name = "Cached Images" report = ArtifactHtmlReport(report_name) report.start_artifact_report(report_folder, report_name) report.add_script() + + headers = ( + "Original Cache Filename", + "File Size (Bytes)", + "Last Modified (UTC)", + "Path", + ) + + # HTML injection vulnerabilities are entirely eliminated by delegating escaping to the framework report.write_artifact_data_table( - headers, html_rows, "cache/image_manager_disk_cache", html_escape=False + headers, data_list, report_folder, table_id="NovaCacheImages", html_escape=True ) report.end_artifact_report() - tsv_path = os.path.join(report_folder, f"{report_name}.tsv") - with open(tsv_path, "w", newline="", encoding="utf-8") as tsvfile: - writer = csv.writer(tsvfile, delimiter="\t") - writer.writerow( - ["Filename", "File Size", "Last Modified (UTC)", "Original Cache Filename"] - ) - writer.writerows(tsv_rows) - - scripts.ilapfuncs.logfunc( - f"[nova_cache_images] Displayed {len(all_images)} cached images." - ) + tsv(report_folder, headers, data_list, report_name) + logfunc(f"[nova_cache_images] Displayed {len(data_list)} cached image entries.") diff --git a/scripts/artifacts/AIChatbotNovaConversations.py b/scripts/artifacts/AIChatbotNovaConversations.py index f2679b8e..3fdce765 100644 --- a/scripts/artifacts/AIChatbotNovaConversations.py +++ b/scripts/artifacts/AIChatbotNovaConversations.py @@ -1,6 +1,6 @@ __artifacts_v2__ = { "nova_chatbot_conversations": { - "name": "Nova AI Chatbot - Conversations (Full Detail)", + "name": "Conversations (Full Detail)", "description": ( "Reconstructs full conversations from the AI Chatbot - Nova app by joining " "History, HistoryDetail, HistoryDetailImage, HistoryDetailDocument, and " @@ -565,7 +565,7 @@ def get_nova_chatbot_conversations(files_found, report_folder, seeker, wrap_text tsv_rows.append(common + (img_tsv, doc_tsv, link_urls or "")) # HTML report - report_name = "Nova AI Chatbot - Conversations (Full Detail)" + report_name = "Conversations (Full Detail)" report = ArtifactHtmlReport(report_name) report.start_artifact_report(report_folder, report_name) report.add_script() diff --git a/scripts/artifacts/AIChatbotNovaHistory.py b/scripts/artifacts/AIChatbotNovaHistory.py index 9939b8cf..3ff37131 100644 --- a/scripts/artifacts/AIChatbotNovaHistory.py +++ b/scripts/artifacts/AIChatbotNovaHistory.py @@ -1,276 +1,188 @@ __artifacts_v2__ = { "nova_chatbot_history": { - "name": "Nova AI Chatbot - Conversation History", + "name": "Conversation History", "description": ( "Extracts the conversation index from the AI Chatbot - Nova app " - "(com.scaleup.chatai) from the History table. " - "Each row represents one conversation and includes the conversation title, " - "AI model used, starred and soft-deleted status, all relevant timestamps, " - "and sync metadata. Each row is further enriched with three summary columns " - "derived from HistoryDetail: total message count, timestamp of the last " - "message, and the text of the first user message — providing immediate " - "investigative context without requiring the full message detail report. " - "Soft-deleted conversations are flagged on every row." + "(com.scaleup.chatai) from the History table." ), "author": "Guilherme Guilherme", - "version": "0.2", - "date": "2025-04-27", + "version": "1.0", + "date": "2026-05-21", "requirements": "none", "category": "AI Chatbot - Nova", - "notes": ( - "Database: com.scaleup.chatai/databases/chat-ai.db. " - "All timestamps (createdAt, updatedAt, lastModifiedAt) are stored as Unix " - "milliseconds (INTEGER) and converted to UTC strings for display. " - "chatBotModel is an integer mapped to known AI model names where possible; " - "unknown values are shown as 'Unknown Model (N)'. " - "softDeleted = 1 indicates the conversation was deleted by the user but " - "remains physically present in the database and is forensically recoverable. " - "starred = 1 indicates the user bookmarked the conversation. " - "assistantId identifies a custom AI assistant persona assigned to the " - "conversation when not NULL. " - "captionHistoryId links to an associated caption or summary history entry " - "when present. " - "message_count, last_message_at, and first_user_message are aggregated " - "from HistoryDetail via LEFT JOIN so conversations with zero messages are " - "still returned. first_user_message reflects the earliest USER-role message " - "text (HistoryDetail.type = 0)." - ), + "notes": "Database: com.scaleup.chatai/databases/chat-ai.db", "paths": ("*/com.scaleup.chatai/databases/chat-ai.db",), "function": "get_nova_chatbot_history", + "output_types": "standard", + "artifact_icon": "message-square", } } -import sqlite3 import datetime +from datetime import timezone from scripts.artifact_report import ArtifactHtmlReport -import scripts.ilapfuncs +from scripts.ilapfuncs import logfunc, tsv, timeline, open_sqlite_db_readonly -# --------------------------------------------------------------------------- -# Known mappings for the chatBotModel integer field. -# Source: FirestoreHistory.EngineTypes enum ordinals from decompiled APK source -# (com.scaleup.chatai.ui.conversation.FirestoreHistory). -# The integer stored in the database is the ENUM ORDINAL (0-based position), -# NOT the botId from chatbotAgentMap. These are two independent systems. -# Image-generating engines: 3 (legacy Bard ordinal reused), 4, 12, 13, 17. -# NOTE: ordinal 3 ('bard') was reused for image generation in newer app versions; -# presence of HistoryDetailImage records confirms image generation regardless of label. -# NOTE: ordinal 20 ('deepSeekR1') — if reasoningContent is NULL the actual API -# call may have used DeepSeek V3; the field reflects the UI selector, not the API. -# --------------------------------------------------------------------------- CHAT_BOT_MODEL_MAP = { - 0: "ChatGPT 3.5", # gpt-3.5 - 1: "GPT-5", # gpt-5 - 2: "GPT-4o", # gpt-4o - 3: "Bard / Image Gen.", # bard (legacy; reused for image generation) - 4: "Image Generator", # image-generator - 5: "Vision", # vision - 6: "Google Vision", # googleVision - 7: "Document", # document - 8: "LLaMA 2", # llama2 - 9: "Nova", # nova - 10: "Gemini", # gemini - 11: "Superbot", # superbot - 12: "Logo Generator", # logo-generator - 13: "Tattoo Generator", # tattoo-generator - 14: "Web Search", # webSearch - 15: "Claude", # claude - 16: "DeepSeek", # deepSeek - 17: "Signature Generator", # signature-generator - 18: "Mistral", # mistral - 19: "Grok", # grok - 20: "DeepSeek R1", # deepSeekR1 - 21: "AI Filter", # aiFilter - 22: "Voice Chat", # voiceChat - 23: "Snap & Solve", # snapAndSolve - 24: "Study Planner", # studyPlanner - 25: "Quiz Maker", # quizMaker - 26: "Essay Helper", # essayHelper - 27: "Gemini 3 Pro", # gemini-3-pro - 28: "GPT-5.1", # gpt-5.1 - 29: "GPT-4o Mini", # 4o-mini + 0: "ChatGPT 3.5", + 1: "GPT-5", + 2: "GPT-4o", + 3: "Bard / Image Gen.", + 4: "Image Generator", + 5: "Vision", + 6: "Google Vision", + 7: "Document", + 8: "LLaMA 2", + 9: "Nova", + 10: "Gemini", + 11: "Superbot", + 12: "Logo Generator", + 13: "Tattoo Generator", + 14: "Web Search", + 15: "Claude", + 16: "DeepSeek", + 17: "Signature Generator", + 18: "Mistral", + 19: "Grok", + 20: "DeepSeek R1", + 21: "AI Filter", + 22: "Voice Chat", + 23: "Snap & Solve", + 24: "Study Planner", + 25: "Quiz Maker", + 26: "Essay Helper", + 27: "Gemini 3 Pro", + 28: "GPT-5.1", + 29: "GPT-4o Mini", } -# --------------------------------------------------------------------------- -# SQL -# One row per History entry, enriched with three summary columns from -# HistoryDetail via a LEFT JOIN + GROUP BY so conversations with zero -# messages are still returned. -# --------------------------------------------------------------------------- QUERY = """ SELECT - h.id AS conv_id, - h.UUID AS conv_uuid, - h.title AS title, - h.chatBotModel AS chat_bot_model, - h.assistantId AS assistant_id, - h.captionHistoryId AS caption_history_id, - h.starred AS starred, - h.softDeleted AS soft_deleted, - h.syncState AS sync_state, - h.syncRetryCount AS sync_retry_count, - h.createdAt AS created_at, - h.updatedAt AS updated_at, - h.lastModifiedAt AS last_modified_at, - COUNT(hd.id) AS message_count, - MAX(hd.createdAt) AS last_msg_ts, - MIN(CASE WHEN hd.type = 0 THEN hd.text END) AS first_user_msg + h.id, + h.UUID, + h.title, + h.chatBotModel, + h.assistantId, + h.captionHistoryId, + h.starred, + h.softDeleted, + h.syncState, + h.syncRetryCount, + h.createdAt, + h.updatedAt, + h.lastModifiedAt, + COUNT(hd.id), + MAX(hd.createdAt), + MIN(CASE WHEN hd.type = 0 THEN hd.text END) FROM History h -LEFT JOIN HistoryDetail hd - ON hd.historyID = h.id +LEFT JOIN HistoryDetail hd ON hd.historyID = h.id GROUP BY h.id ORDER BY h.createdAt ASC """ -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - def _convert_ms_timestamp(ms): - """Convert a Unix millisecond timestamp to a human-readable UTC string.""" + """Safely converts Unix millisecond timestamp using modern timezone.utc.""" if ms is None: return "" try: - return datetime.datetime.utcfromtimestamp(ms / 1000).strftime( + return datetime.datetime.fromtimestamp(float(ms) / 1000, timezone.utc).strftime( "%Y-%m-%d %H:%M:%S UTC" ) except (OSError, OverflowError, ValueError): return str(ms) -def _resolve_model(model_int): - """Return a labelled model name, falling back to the raw integer for unknowns.""" - if model_int is None: - return "Unknown" - name = CHAT_BOT_MODEL_MAP.get(model_int) - return f"{name} ({model_int})" if name else f"Unknown Model ({model_int})" - - -def _format_soft_deleted(value): - """Return a clearly labelled string for the softDeleted field.""" - return "DELETED" if value == 1 else "No" - - -# --------------------------------------------------------------------------- -# Entry point -# --------------------------------------------------------------------------- - - def get_nova_chatbot_history(files_found, report_folder, seeker, wrap_text): - """ - Entry point for the nova_chatbot_history artifact. + logfunc("Processing data for Conversation History") - Queries the History table enriched with per-conversation summary data - from HistoryDetail. Outputs HTML report, TSV, and timeline. - """ + # Clean the file list of any SQLite journal artifacts + files_found = [ + x for x in files_found if not x.endswith(("-journal", "-wal", "-shm")) + ] + file_found = next((str(x) for x in files_found if "chat-ai.db" in str(x)), None) - for file_found in files_found: - file_found = str(file_found) + if not file_found: + logfunc("[nova_chatbot_history] Nova database file not found.") + return - if not file_found.endswith("chat-ai.db"): - continue - - try: - db = sqlite3.connect(file_found) - cursor = db.cursor() - cursor.execute(QUERY) - rows_raw = cursor.fetchall() - db.close() - - except Exception as e: - scripts.ilapfuncs.logfunc( - f"[nova_chatbot_history] Error reading {file_found}: {e}" - ) - continue - - if not rows_raw: - scripts.ilapfuncs.logfunc( - f"[nova_chatbot_history] No records found in {file_found}." + try: + db = open_sqlite_db_readonly(file_found) + cursor = db.cursor() + cursor.execute(QUERY) + rows_raw = cursor.fetchall() + db.close() + except Exception as e: + logfunc(f"[nova_chatbot_history] Error reading {file_found}: {e}") + return + + if not rows_raw: + logfunc(f"[nova_chatbot_history] No records found in {file_found}.") + return + + headers = ( + "Conv. ID", + "Conv. UUID", + "Title", + "AI Model", + "Assistant ID", + "Caption History ID", + "Starred", + "Soft Deleted", + "Sync State", + "Sync Retry Count", + "Created At (UTC)", + "Updated At (UTC)", + "Last Modified At (UTC)", + "Message Count", + "Last Message At (UTC)", + "First User Message", + ) + + rows = [] + for row in rows_raw: + model_int = row[3] + model_name = "Unknown" + if model_int is not None: + name_lookup = CHAT_BOT_MODEL_MAP.get(model_int) + model_name = ( + f"{name_lookup} ({model_int})" + if name_lookup + else f"Unknown Model ({model_int})" ) - continue - headers = [ - # --- Identity --- - "Conv. ID", - "Conv. UUID", - "Title", - # --- Model --- - "AI Model", - "Assistant ID", - "Caption History ID", - # --- Flags --- - "Starred", - "Soft Deleted", - "Sync State", - "Sync Retry Count", - # --- Timestamps --- - "Created At (UTC)", - "Updated At (UTC)", - "Last Modified At (UTC)", - # --- Summary from HistoryDetail --- - "Message Count", - "Last Message At (UTC)", - "First User Message", - ] - - rows = [] - for row in rows_raw: + rows.append( ( - conv_id, - conv_uuid, - title, - chat_bot_model, - assistant_id, - caption_history_id, - starred, - soft_deleted, - sync_state, - sync_retry_count, - created_at, - updated_at, - last_modified_at, - message_count, - last_msg_ts, - first_user_msg, - ) = row - - rows.append( - ( - conv_id, - conv_uuid or "", - title or "", - _resolve_model(chat_bot_model), - assistant_id if assistant_id is not None else "", - caption_history_id or "", - "Yes" if starred else "No", - _format_soft_deleted(soft_deleted), - sync_state if sync_state is not None else "", - sync_retry_count if sync_retry_count is not None else "", - _convert_ms_timestamp(created_at), - _convert_ms_timestamp(updated_at), - _convert_ms_timestamp(last_modified_at), - message_count if message_count is not None else 0, - _convert_ms_timestamp(last_msg_ts), - first_user_msg or "", - ) + row[0], + row[1] or "", + row[2] or "", + model_name, + row[4] if row[4] is not None else "", + row[5] or "", + "Yes" if row[6] else "No", + "DELETED" if row[7] == 1 else "No", + row[8] if row[8] is not None else "", + row[9] if row[9] is not None else "", + _convert_ms_timestamp(row[10]), + _convert_ms_timestamp(row[11]), + _convert_ms_timestamp(row[12]), + row[13] if row[13] is not None else 0, + _convert_ms_timestamp(row[14]), + row[15] or "", ) - - # --- HTML report --- - report_name = "Nova AI Chatbot - History" - report = ArtifactHtmlReport(report_name) - report.start_artifact_report(report_folder, report_name) - report.add_script() - report.write_artifact_data_table( - headers, - rows, - file_found, - html_escape=True, ) - report.end_artifact_report() - # --- TSV output --- - scripts.ilapfuncs.tsv(report_folder, headers, rows, report_name, file_found) + report_name = "History" + report = ArtifactHtmlReport(report_name) + report.start_artifact_report(report_folder, report_name) + report.add_script() + + # html_escape=True hands escaping entirely to the framework backend safely + report.write_artifact_data_table(headers, rows, file_found, html_escape=True) + report.end_artifact_report() - # --- Timeline (uses Created At, index 10) --- - scripts.ilapfuncs.timeline(report_folder, report_name, rows, headers) + tsv(report_folder, headers, rows, report_name, file_found) + timeline(report_folder, report_name, rows, headers) + logfunc( + f"[nova_chatbot_history] Processed {len(rows)} conversation history records." + ) diff --git a/scripts/artifacts/AIChatbotNovaHistoryDetail.py b/scripts/artifacts/AIChatbotNovaHistoryDetail.py index 0ff38a74..f146b0a1 100644 --- a/scripts/artifacts/AIChatbotNovaHistoryDetail.py +++ b/scripts/artifacts/AIChatbotNovaHistoryDetail.py @@ -1,329 +1,203 @@ __artifacts_v2__ = { "nova_chatbot_history_detail": { - "name": "Nova AI Chatbot - Message Detail", + "name": "HistoryDetail", "description": ( "Extracts every individual message from the AI Chatbot - Nova app " - "(com.scaleup.chatai) from the HistoryDetail table. " - "Each row represents one message and is enriched with parent conversation " - "context joined from History: conversation title, AI model used, and " - "soft-deleted status. Three attachment presence flags are added via " - "correlated EXISTS subqueries against HistoryDetailImage, " - "HistoryDetailDocument, and HistoryDetailLink, indicating whether each " - "message has an associated image, document, or link without duplicating " - "rows or loading attachment content. " - "Enables message-level timeline reconstruction and rapid attachment triage " - "across all conversations." + "(com.scaleup.chatai) from the HistoryDetail table, enriched with parent " + "conversation context and attachment existence flags." ), "author": "Guilherme Guilherme", - "version": "0.2", - "date": "2025-04-27", + "version": "1.0", + "date": "2026-05-21", "requirements": "none", "category": "AI Chatbot - Nova", - "notes": ( - "Database: com.scaleup.chatai/databases/chat-ai.db. " - "All timestamps (createdAt, lastModifiedAt) are stored as Unix milliseconds " - "(INTEGER) and converted to UTC strings for display. " - "HistoryDetail.type: 0 = USER (message sent by the device user), " - "1 = ASSISTANT (response generated by the AI model). " - "token counts reflect the number of tokens consumed by each message; " - "ASSISTANT messages with 0 tokens typically indicate image-generation " - "responses where no text tokens were billed. " - "reasoningContent contains chain-of-thought reasoning text when the " - "underlying model produces it (e.g. DeepSeek-R1 reasoning traces). " - "has_image = Yes means at least one record exists in HistoryDetailImage " - "for this message. " - "has_document = Yes means at least one record exists in HistoryDetailDocument " - "for this message — the user submitted a file to the AI. " - "has_link = Yes means at least one record exists in HistoryDetailLink. " - "softDeleted is inherited from the parent History record; DELETED means the " - "conversation was removed by the user but all messages remain physically in " - "the database and are forensically recoverable. " - "syncState and syncRetryCount reflect cloud synchronisation status." - ), + "notes": "Database: com.scaleup.chatai/databases/chat-ai.db", "paths": ("*/com.scaleup.chatai/databases/chat-ai.db",), "function": "get_nova_chatbot_history_detail", + "output_types": "standard", + "artifact_icon": "message-circle", } } -import sqlite3 import datetime +from datetime import timezone from scripts.artifact_report import ArtifactHtmlReport -import scripts.ilapfuncs +from scripts.ilapfuncs import logfunc, tsv, timeline, open_sqlite_db_readonly -# --------------------------------------------------------------------------- -# Known mappings for the chatBotModel integer field. -# Source: FirestoreHistory.EngineTypes enum ordinals from decompiled APK source -# (com.scaleup.chatai.ui.conversation.FirestoreHistory). -# The integer stored in the database is the ENUM ORDINAL (0-based position), -# NOT the botId from chatbotAgentMap. These are two independent systems. -# Image-generating engines: 3 (legacy Bard ordinal reused), 4, 12, 13, 17. -# NOTE: ordinal 3 ('bard') was reused for image generation in newer app versions; -# presence of HistoryDetailImage records confirms image generation regardless of label. -# NOTE: ordinal 20 ('deepSeekR1') — if reasoningContent is NULL the actual API -# call may have used DeepSeek V3; the field reflects the UI selector, not the API. -# --------------------------------------------------------------------------- CHAT_BOT_MODEL_MAP = { - 0: "ChatGPT 3.5", # gpt-3.5 - 1: "GPT-5", # gpt-5 - 2: "GPT-4o", # gpt-4o - 3: "Bard / Image Gen.", # bard (legacy; reused for image generation) - 4: "Image Generator", # image-generator - 5: "Vision", # vision - 6: "Google Vision", # googleVision - 7: "Document", # document - 8: "LLaMA 2", # llama2 - 9: "Nova", # nova - 10: "Gemini", # gemini - 11: "Superbot", # superbot - 12: "Logo Generator", # logo-generator - 13: "Tattoo Generator", # tattoo-generator - 14: "Web Search", # webSearch - 15: "Claude", # claude - 16: "DeepSeek", # deepSeek - 17: "Signature Generator", # signature-generator - 18: "Mistral", # mistral - 19: "Grok", # grok - 20: "DeepSeek R1", # deepSeekR1 - 21: "AI Filter", # aiFilter - 22: "Voice Chat", # voiceChat - 23: "Snap & Solve", # snapAndSolve - 24: "Study Planner", # studyPlanner - 25: "Quiz Maker", # quizMaker - 26: "Essay Helper", # essayHelper - 27: "Gemini 3 Pro", # gemini-3-pro - 28: "GPT-5.1", # gpt-5.1 - 29: "GPT-4o Mini", # 4o-mini + 0: "ChatGPT 3.5", + 1: "GPT-5", + 2: "GPT-4o", + 3: "Bard / Image Gen.", + 4: "Image Generator", + 5: "Vision", + 6: "Google Vision", + 7: "Document", + 8: "LLaMA 2", + 9: "Nova", + 10: "Gemini", + 11: "Superbot", + 12: "Logo Generator", + 13: "Tattoo Generator", + 14: "Web Search", + 15: "Claude", + 16: "DeepSeek", + 17: "Signature Generator", + 18: "Mistral", + 19: "Grok", + 20: "DeepSeek R1", + 21: "AI Filter", + 22: "Voice Chat", + 23: "Snap & Solve", + 24: "Study Planner", + 25: "Quiz Maker", + 26: "Essay Helper", + 27: "Gemini 3 Pro", + 28: "GPT-5.1", + 29: "GPT-4o Mini", } -# --------------------------------------------------------------------------- -# SQL -# One row per HistoryDetail message. -# Parent conversation context (title, model, soft-deleted) is joined from -# History. Attachment presence is detected via correlated EXISTS subqueries — -# lightweight boolean checks that avoid duplicating rows from the attachment -# tables. -# --------------------------------------------------------------------------- QUERY = """ SELECT - -- Message identity - hd.id AS msg_id, - hd.UUID AS msg_uuid, - hd.historyID AS conv_id, - - -- Parent conversation context (from History) - h.UUID AS conv_uuid, - h.title AS conv_title, - h.chatBotModel AS chat_bot_model, - h.softDeleted AS soft_deleted, - - -- Message content - hd.type AS msg_type, - hd.text AS msg_text, - hd.token AS token_count, - hd.reasoningContent AS reasoning_content, - - -- Message timestamps - hd.createdAt AS created_at, - hd.lastModifiedAt AS last_modified_at, - - -- Sync metadata - hd.syncState AS sync_state, - hd.syncRetryCount AS sync_retry_count, - - -- Attachment flags (correlated EXISTS — no row multiplication) - CASE WHEN EXISTS ( - SELECT 1 FROM HistoryDetailImage i - WHERE i.historyDetailID = hd.id - ) THEN 1 ELSE 0 END AS has_image, - - CASE WHEN EXISTS ( - SELECT 1 FROM HistoryDetailDocument d - WHERE d.historyDetailID = hd.id - ) THEN 1 ELSE 0 END AS has_document, - - CASE WHEN EXISTS ( - SELECT 1 FROM HistoryDetailLink l - WHERE l.historyDetailID = hd.id - ) THEN 1 ELSE 0 END AS has_link - + hd.id, + hd.UUID, + hd.historyID, + h.UUID, + h.title, + h.chatBotModel, + h.softDeleted, + hd.type, + hd.text, + hd.token, + hd.reasoningContent, + hd.createdAt, + hd.lastModifiedAt, + hd.syncState, + hd.syncRetryCount, + EXISTS(SELECT 1 FROM HistoryDetailImage i WHERE i.historyDetailID = hd.id), + EXISTS(SELECT 1 FROM HistoryDetailDocument d WHERE d.historyDetailID = hd.id), + EXISTS(SELECT 1 FROM HistoryDetailLink l WHERE l.historyDetailID = hd.id) FROM HistoryDetail hd -INNER JOIN History h - ON h.id = hd.historyID +INNER JOIN History h ON h.id = hd.historyID ORDER BY hd.historyID ASC, hd.createdAt ASC """ -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - def _convert_ms_timestamp(ms): - """Convert a Unix millisecond timestamp to a human-readable UTC string.""" + """Safely converts Unix millisecond timestamp using modern timezone.utc.""" if ms is None: return "" try: - return datetime.datetime.utcfromtimestamp(ms / 1000).strftime( + return datetime.datetime.fromtimestamp(float(ms) / 1000, timezone.utc).strftime( "%Y-%m-%d %H:%M:%S UTC" ) except (OSError, OverflowError, ValueError): return str(ms) -def _resolve_model(model_int): - """Return a labelled model name, falling back to the raw integer for unknowns.""" - if model_int is None: - return "Unknown" - name = CHAT_BOT_MODEL_MAP.get(model_int) - return f"{name} ({model_int})" if name else f"Unknown Model ({model_int})" - - -def _format_role(type_int): - """Map HistoryDetail.type to a forensically clear role label.""" - if type_int == 0: - return "USER" - if type_int == 1: - return "ASSISTANT" - return f"UNKNOWN ({type_int})" - - -def _format_soft_deleted(value): - """Return a clearly labelled string for the softDeleted field.""" - return "DELETED" if value == 1 else "No" - - -def _flag(value): - """Return Yes/No for a boolean integer flag.""" - return "Yes" if value else "No" - - -# --------------------------------------------------------------------------- -# Entry point -# --------------------------------------------------------------------------- - - def get_nova_chatbot_history_detail(files_found, report_folder, seeker, wrap_text): - """ - Entry point for the nova_chatbot_history_detail artifact. - - Queries every message in HistoryDetail, enriched with parent conversation - context from History and attachment presence flags from the three attachment - tables. Outputs HTML report, TSV, and timeline. - """ - - for file_found in files_found: - file_found = str(file_found) + logfunc("Processing data for HistoryDetail") - if not file_found.endswith("chat-ai.db"): - continue + # Filter out secondary transactional database engines + files_found = [ + x for x in files_found if not x.endswith(("-journal", "-wal", "-shm")) + ] + file_found = next((str(x) for x in files_found if "chat-ai.db" in str(x)), None) - try: - db = sqlite3.connect(file_found) - cursor = db.cursor() - cursor.execute(QUERY) - rows_raw = cursor.fetchall() - db.close() + if not file_found: + logfunc("[nova_chatbot_history_detail] Nova database file not found.") + return - except Exception as e: - scripts.ilapfuncs.logfunc( - f"[nova_chatbot_history_detail] Error reading {file_found}: {e}" - ) - continue - - if not rows_raw: - scripts.ilapfuncs.logfunc( - f"[nova_chatbot_history_detail] No records found in {file_found}." + try: + db = open_sqlite_db_readonly(file_found) + cursor = db.cursor() + cursor.execute(QUERY) + rows_raw = cursor.fetchall() + db.close() + except Exception as e: + logfunc(f"[nova_chatbot_history_detail] Error reading {file_found}: {e}") + return + + if not rows_raw: + logfunc(f"[nova_chatbot_history_detail] No records found in {file_found}.") + return + + headers = ( + "Msg. ID", + "Msg. UUID", + "Conv. ID", + "Conv. UUID", + "Conv. Title", + "AI Model", + "Conv. Deleted", + "Role", + "Message Text", + "Token Count", + "Reasoning Content", + "Message Timestamp (UTC)", + "Last Modified At (UTC)", + "Sync State", + "Sync Retry Count", + "Has Image", + "Has Document", + "Has Link", + ) + + rows = [] + for row in rows_raw: + model_int = row[5] + model_name = "Unknown" + if model_int is not None: + name_lookup = CHAT_BOT_MODEL_MAP.get(model_int) + model_name = ( + f"{name_lookup} ({model_int})" + if name_lookup + else f"Unknown Model ({model_int})" ) - continue - headers = [ - # --- Message identity --- - "Msg. ID", - "Msg. UUID", - "Conv. ID", - # --- Parent conversation context --- - "Conv. UUID", - "Conv. Title", - "AI Model", - "Conv. Deleted", - # --- Message content --- - "Role", - "Message Text", - "Token Count", - "Reasoning Content", - # --- Timestamps --- - "Message Timestamp (UTC)", - "Last Modified At (UTC)", - # --- Sync metadata --- - "Sync State", - "Sync Retry Count", - # --- Attachment flags --- - "Has Image", - "Has Document", - "Has Link", - ] + role_int = row[7] + role_label = ( + "USER" + if role_int == 0 + else "ASSISTANT" + if role_int == 1 + else f"UNKNOWN ({role_int})" + ) - rows = [] - for row in rows_raw: + rows.append( ( - msg_id, - msg_uuid, - conv_id, - conv_uuid, - conv_title, - chat_bot_model, - soft_deleted, - msg_type, - msg_text, - token_count, - reasoning_content, - created_at, - last_modified_at, - sync_state, - sync_retry_count, - has_image, - has_document, - has_link, - ) = row - - rows.append( - ( - msg_id, - msg_uuid or "", - conv_id, - conv_uuid or "", - conv_title or "", - _resolve_model(chat_bot_model), - _format_soft_deleted(soft_deleted), - _format_role(msg_type), - msg_text or "", - token_count if token_count is not None else "", - reasoning_content or "", - _convert_ms_timestamp(created_at), - _convert_ms_timestamp(last_modified_at), - sync_state if sync_state is not None else "", - sync_retry_count if sync_retry_count is not None else "", - _flag(has_image), - _flag(has_document), - _flag(has_link), - ) + row[0], + row[1] or "", + row[2], + row[3] or "", + row[4] or "", + model_name, + "DELETED" if row[6] == 1 else "No", + role_label, + row[8] or "", + row[9] if row[9] is not None else "", + row[10] or "", + _convert_ms_timestamp(row[11]), + _convert_ms_timestamp(row[12]), + row[13] if row[13] is not None else "", + row[14] if row[14] is not None else "", + "Yes" if row[15] else "No", + "Yes" if row[16] else "No", + "Yes" if row[17] else "No", ) - - # --- HTML report --- - report_name = "Nova AI Chatbot - HistoryDetail" - report = ArtifactHtmlReport(report_name) - report.start_artifact_report(report_folder, report_name) - report.add_script() - report.write_artifact_data_table( - headers, - rows, - file_found, - html_escape=True, ) - report.end_artifact_report() - # --- TSV output --- - scripts.ilapfuncs.tsv(report_folder, headers, rows, report_name, file_found) + report_name = "HistoryDetail" + report = ArtifactHtmlReport(report_name) + report.start_artifact_report(report_folder, report_name) + report.add_script() + + # Delegates script-side sanitization directly to framework tables safely + report.write_artifact_data_table(headers, rows, file_found, html_escape=True) + report.end_artifact_report() - # --- Timeline (message-level granularity, index 11 = Message Timestamp) --- - scripts.ilapfuncs.timeline(report_folder, report_name, rows, headers) + tsv(report_folder, headers, rows, report_name, file_found) + timeline(report_folder, report_name, rows, headers) + logfunc( + f"[nova_chatbot_history_detail] Processed {len(rows)} message detail records." + ) diff --git a/scripts/artifacts/AIChatbotNovaHistoryDetailDocument.py b/scripts/artifacts/AIChatbotNovaHistoryDetailDocument.py index 4d1c7aef..91d4cf5b 100644 --- a/scripts/artifacts/AIChatbotNovaHistoryDetailDocument.py +++ b/scripts/artifacts/AIChatbotNovaHistoryDetailDocument.py @@ -1,178 +1,96 @@ __artifacts_v2__ = { "nova_chatbot_documents": { - "name": "Nova AI Chatbot - Submitted Documents", + "name": "HistoryDetailDocuments", "description": ( - "Extracts all document records submitted by the user to the AI from the " - "AI Chatbot - Nova app (HistoryDetailDocument table). Each row represents " - "one document and is enriched with parent message context from HistoryDetail " - "and parent conversation context from History. " - "Documents are stored on Firebase Storage; the database stores only the " - "Firebase object path. No local cache of user‑submitted documents is kept " - "on the device. The metadata and a forensic note are displayed in the HTML " - "report. The full file content is not available for preview. " - "A forensic note is shown on every row confirming the file was actively " - "submitted by the device user to the AI assistant." + "Extracts document records submitted by the user to the AI from the " + "HistoryDetailDocument table, enriched with parent message and conversation context." ), "author": "Guilherme Guilherme", - "version": "0.2", - "date": "2025-04-27", + "version": "1.0", + "date": "2026-05-21", "requirements": "none", "category": "AI Chatbot - Nova", - "notes": ( - "Database: com.scaleup.chatai/databases/chat-ai.db. " - "HistoryDetailDocument.url stores a Firebase path such as " - "'/document///document-input-'. " - "The file content is not stored locally on the device; it lives in " - "Firebase Storage. The metadata record is still valuable for forensic " - "timeline and user activity. " - "type: 0 = Local File (uploaded from the device), 1 = Remote File. " - "size is stored in bytes and converted to a human-readable string. " - "mimeType identifies the document format (e.g. application/pdf). " - "softDeleted is inherited from the parent History record; DELETED means " - "the conversation was removed by the user but the document record remains " - "physically in the database and is forensically recoverable. " - "The user message text associated with the document reveals the query the " - "user submitted alongside the file to the AI." - ), + "notes": "Database: com.scaleup.chatai/databases/chat-ai.db", "paths": ("*/com.scaleup.chatai/databases/chat-ai.db",), "function": "get_nova_chatbot_documents", + "output_types": "standard", + "artifact_icon": "file-text", } } -import os -import shutil -import sqlite3 import datetime -import html as html_module +from datetime import timezone from scripts.artifact_report import ArtifactHtmlReport -import scripts.ilapfuncs +from scripts.ilapfuncs import logfunc, tsv, timeline, open_sqlite_db_readonly -# --------------------------------------------------------------------------- -# Known mappings for the chatBotModel integer field. -# Source: FirestoreHistory.EngineTypes enum ordinals from decompiled APK source -# (com.scaleup.chatai.ui.conversation.FirestoreHistory). -# The integer stored in the database is the ENUM ORDINAL (0-based position), -# NOT the botId from chatbotAgentMap. These are two independent systems. -# Image-generating engines: 3 (legacy Bard ordinal reused), 4, 12, 13, 17. -# NOTE: ordinal 3 ('bard') was reused for image generation in newer app versions; -# presence of HistoryDetailImage records confirms image generation regardless of label. -# NOTE: ordinal 20 ('deepSeekR1') — if reasoningContent is NULL the actual API -# call may have used DeepSeek V3; the field reflects the UI selector, not the API. -# --------------------------------------------------------------------------- CHAT_BOT_MODEL_MAP = { - 0: "ChatGPT 3.5", # gpt-3.5 - 1: "GPT-5", # gpt-5 - 2: "GPT-4o", # gpt-4o - 3: "Bard / Image Gen.", # bard (legacy; reused for image generation) - 4: "Image Generator", # image-generator - 5: "Vision", # vision - 6: "Google Vision", # googleVision - 7: "Document", # document - 8: "LLaMA 2", # llama2 - 9: "Nova", # nova - 10: "Gemini", # gemini - 11: "Superbot", # superbot - 12: "Logo Generator", # logo-generator - 13: "Tattoo Generator", # tattoo-generator - 14: "Web Search", # webSearch - 15: "Claude", # claude - 16: "DeepSeek", # deepSeek - 17: "Signature Generator", # signature-generator - 18: "Mistral", # mistral - 19: "Grok", # grok - 20: "DeepSeek R1", # deepSeekR1 - 21: "AI Filter", # aiFilter - 22: "Voice Chat", # voiceChat - 23: "Snap & Solve", # snapAndSolve - 24: "Study Planner", # studyPlanner - 25: "Quiz Maker", # quizMaker - 26: "Essay Helper", # essayHelper - 27: "Gemini 3 Pro", # gemini-3-pro - 28: "GPT-5.1", # gpt-5.1 - 29: "GPT-4o Mini", # 4o-mini -} - -DOCUMENT_TYPE_MAP = { - 0: "Local File", - 1: "Remote File", + 0: "ChatGPT 3.5", + 1: "GPT-5", + 2: "GPT-4o", + 3: "Bard / Image Gen.", + 4: "Image Generator", + 5: "Vision", + 6: "Google Vision", + 7: "Document", + 8: "LLaMA 2", + 9: "Nova", + 10: "Gemini", + 11: "Superbot", + 12: "Logo Generator", + 13: "Tattoo Generator", + 14: "Web Search", + 15: "Claude", + 16: "DeepSeek", + 17: "Signature Generator", + 18: "Mistral", + 19: "Grok", + 20: "DeepSeek R1", + 21: "AI Filter", + 22: "Voice Chat", + 23: "Snap & Solve", + 24: "Study Planner", + 25: "Quiz Maker", + 26: "Essay Helper", + 27: "Gemini 3 Pro", + 28: "GPT-5.1", + 29: "GPT-4o Mini", } -MIME_ICON_MAP = { - "application/pdf": "📄", - "application/msword": "📝", - "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "📝", - "application/vnd.ms-excel": "📊", - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "📊", - "text/plain": "📃", - "text/csv": "📊", - "image/jpeg": "🖼️", - "image/png": "🖼️", - "image/gif": "🖼️", - "image/webp": "🖼️", -} - -# --------------------------------------------------------------------------- -# SQL -# One row per HistoryDetailDocument, enriched with parent message and -# conversation context. -# --------------------------------------------------------------------------- QUERY = """ SELECT - -- Document record - d.id AS doc_id, - d.historyDetailID AS msg_id, - d.url AS doc_url, - d.name AS doc_name, - d.type AS doc_type, - d.size AS doc_size, - d.mimeType AS mime_type, - - -- Parent message context (HistoryDetail) - hd.historyID AS conv_id, - hd.type AS msg_type, - hd.text AS msg_text, - hd.createdAt AS msg_created_at, - - -- Parent conversation context (History) - h.UUID AS conv_uuid, - h.title AS conv_title, - h.chatBotModel AS chat_bot_model, - h.softDeleted AS soft_deleted - + d.id, + d.historyDetailID, + d.url, + d.name, + d.type, + d.size, + d.mimeType, + hd.historyID, + hd.type, + hd.text, + hd.createdAt, + h.UUID, + h.title, + h.chatBotModel, + h.softDeleted FROM HistoryDetailDocument d INNER JOIN HistoryDetail hd ON hd.id = d.historyDetailID INNER JOIN History h ON h.id = hd.historyID ORDER BY d.id ASC """ -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -def _e(text): - return html_module.escape(str(text)) if text else "" def _convert_ms_timestamp(ms): + """Safely converts Unix millisecond timestamp using modern timezone.utc.""" if ms is None: return "" try: - return datetime.datetime.utcfromtimestamp(ms / 1000).strftime( + return datetime.datetime.fromtimestamp(float(ms) / 1000, timezone.utc).strftime( "%Y-%m-%d %H:%M:%S UTC" ) except (OSError, OverflowError, ValueError): return str(ms) -def _resolve_model(model_int): - if model_int is None: - return "Unknown" - name = CHAT_BOT_MODEL_MAP.get(model_int) - return f"{name} ({model_int})" if name else f"Unknown Model ({model_int})" - -def _resolve_doc_type(type_int): - if type_int is None: - return "" - label = DOCUMENT_TYPE_MAP.get(type_int) - return f"{label} ({type_int})" if label else f"Unknown ({type_int})" def _format_file_size(size_bytes): if size_bytes is None: @@ -183,229 +101,122 @@ def _format_file_size(size_bytes): return f"{size_bytes} B" elif size_bytes < 1024**2: return f"{size_bytes / 1024:.1f} KB" - elif size_bytes < 1024**3: - return f"{size_bytes / (1024**2):.1f} MB" else: - return f"{size_bytes / (1024**3):.2f} GB" + return f"{size_bytes / (1024**2):.1f} MB" except (ValueError, TypeError): return str(size_bytes) -def _format_soft_deleted(value): - return "DELETED" if value == 1 else "No" - -def _format_role(type_int): - return {0: "USER", 1: "ASSISTANT"}.get(type_int, f"UNKNOWN ({type_int})") - -# --------------------------------------------------------------------------- -# Document file resolution (always returns None – no local copy) -# --------------------------------------------------------------------------- -def _resolve_document_file(doc_url, doc_name, seeker, docs_dir): - """ - Documents are stored on Firebase Storage. The database stores only the - Firebase object path. No local cache of user‑submitted documents is - kept on the device. Therefore this function always returns None. - The HTML cell will show a notice explaining the file is not available - locally. - """ - return None - -# --------------------------------------------------------------------------- -# HTML cell builder -# --------------------------------------------------------------------------- -def _build_document_cell(doc_name, mime_type, doc_size, doc_url, doc_type, filename): - """ - Build a self-contained HTML cell for one document record showing: - - File icon + name (plain text, no link) - - MIME type, size, source type, and Firebase path - - Forensic note confirming the user submitted this file to the AI - - Notice that the file content is stored on Firebase and not available - """ - icon = MIME_ICON_MAP.get(mime_type, "📎") - size_label = _format_file_size(doc_size) - type_label = _resolve_doc_type(doc_type) +def get_nova_chatbot_documents(files_found, report_folder, seeker, wrap_text): + logfunc("Processing data for HistoryDetail Submitted Documents") - cell = f'
' - cell += f'
{icon} {_e(doc_name)}
' + # Filter out secondary transactional database engines + files_found = [ + x for x in files_found if not x.endswith(("-journal", "-wal", "-shm")) + ] + file_found = next((str(x) for x in files_found if "chat-ai.db" in str(x)), None) - if mime_type: - cell += f"
MIME Type: {_e(mime_type)}
" - if size_label: - cell += f"
Size: {_e(size_label)}
" - if type_label: - cell += f"
Source Type: {_e(type_label)}
" - if doc_url: - cell += ( - f'
' - f" Firebase Path:
" - f' {_e(doc_url)}' - f"
" - ) + if not file_found: + logfunc("[nova_chatbot_documents] Nova database file not found.") + return - # Notice that the file is not stored locally - cell += ( - f'
' - f" ☁️ File stored on Firebase Storage
" - f" The document content is not available on the device. " - f" The database record confirms the user submitted this file to the AI; " - f" the file itself resides in Firebase Storage and is not cached locally." - f"
" - ) - - # Forensic note (original) - cell += ( - f'
' - f" ⚠️ Forensic note: This file was actively submitted " - f" by the device user to the AI assistant as part of this conversation." - f"
" + try: + db = open_sqlite_db_readonly(file_found) + cursor = db.cursor() + cursor.execute(QUERY) + rows_raw = cursor.fetchall() + db.close() + except Exception as e: + logfunc(f"[nova_chatbot_documents] Error reading {file_found}: {e}") + return + + if not rows_raw: + logfunc(f"[nova_chatbot_documents] No document records found in {file_found}.") + return + + headers = ( + "Doc. ID", + "Msg. ID", + "Conv. ID", + "Conv. UUID", + "Conv. Title", + "AI Model", + "Conv. Deleted", + "Media Submitted By", + "Msg. Text", + "Msg. Timestamp (UTC)", + "File Name", + "MIME Type", + "Size", + "Source Type", + "Firebase Storage Path", + "Forensic Notes", ) - cell += "
" - return cell -# --------------------------------------------------------------------------- -# Entry point -# --------------------------------------------------------------------------- - -def get_nova_chatbot_documents(files_found, report_folder, seeker, wrap_text): - """ - Entry point for the nova_chatbot_documents artifact. - - Extracts every HistoryDetailDocument record, resolves the document file from - the device extraction, and produces an HTML report with document metadata - cards and download links, TSV export, and timeline output. - """ - for file_found in files_found: - file_found = str(file_found) - if not file_found.endswith("chat-ai.db"): - continue - - try: - db = sqlite3.connect(file_found) - cursor = db.cursor() - cursor.execute(QUERY) - rows_raw = cursor.fetchall() - db.close() - except Exception as e: - scripts.ilapfuncs.logfunc( - f"[nova_chatbot_documents] Error reading {file_found}: {e}" - ) - continue - - if not rows_raw: - scripts.ilapfuncs.logfunc( - f"[nova_chatbot_documents] No document records found in {file_found}." + rows = [] + for row in rows_raw: + model_int = row[13] + model_name = "Unknown" + if model_int is not None: + name_lookup = CHAT_BOT_MODEL_MAP.get(model_int) + model_name = ( + f"{name_lookup} ({model_int})" + if name_lookup + else f"Unknown Model ({model_int})" ) - continue - - docs_dir = os.path.join(report_folder, "nova_documents") - os.makedirs(docs_dir, exist_ok=True) - - headers = [ - # Document identity - "Doc. ID", - "Msg. ID", - "Conv. ID", - # Conversation context - "Conv. UUID", - "Conv. Title", - "AI Model", - "Conv. Deleted", - # Message context - "Msg. Role", - "Msg. Text", - "Msg. Timestamp (UTC)", - # Document card (HTML) - "Document & Metadata", - # Plain fields for TSV - "File Name", - "MIME Type", - "Size", - "Source Type", - "Firebase Path", # changed from "Internal Path" - ] - html_rows = [] - tsv_rows = [] + doc_type_int = row[4] + doc_type_label = ( + "Local File" + if doc_type_int == 0 + else "Remote File" + if doc_type_int == 1 + else f"Unknown ({doc_type_int})" + ) + + raw_role = row[8] + if raw_role == 0: + submitted_by = "USER" + forensic_note = "Media element actively selected and submitted by the user to the chatbot interface." + elif raw_role == 1: + submitted_by = "AI ASSISTANT" + forensic_note = "Media element generated or provided back by the AI response." + else: + submitted_by = f"UNKNOWN ({raw_role})" + forensic_note = "Unknown structural context for media origin." - for row in rows_raw: + rows.append( ( - doc_id, - msg_id, - doc_url, - doc_name, - doc_type, - doc_size, - mime_type, - conv_id, - msg_type, - msg_text, - msg_created_at, - conv_uuid, - conv_title, - chat_bot_model, - soft_deleted, - ) = row - - # Resolve document from extraction (always returns None) - filename = _resolve_document_file(doc_url, doc_name, seeker, docs_dir) - - common = ( - doc_id, - msg_id, - conv_id, - conv_uuid or "", - conv_title or "", - _resolve_model(chat_bot_model), - _format_soft_deleted(soft_deleted), - _format_role(msg_type), - msg_text or "", - _convert_ms_timestamp(msg_created_at), - ) - - doc_cell = _build_document_cell( - doc_name, mime_type, doc_size, doc_url, doc_type, filename - ) - - html_rows.append( - common - + ( - doc_cell, - doc_name or "", - mime_type or "", - _format_file_size(doc_size), - _resolve_doc_type(doc_type), - doc_url or "", - ) - ) - - tsv_rows.append( - common - + ( - "", # no HTML in TSV - doc_name or "", - mime_type or "", - _format_file_size(doc_size), - _resolve_doc_type(doc_type), - doc_url or "", - ) + row[0], + row[1], + row[7], + row[11] or "", + row[12] or "", + model_name, + "DELETED" if row[14] == 1 else "No", + submitted_by, + row[9] or "", + _convert_ms_timestamp(row[10]), + row[3] or "Unknown", + row[6] or "", + _format_file_size(row[5]), + doc_type_label, + row[2] or "", + forensic_note, ) - - # HTML report - report_name = "Nova AI Chatbot - HistoryDetailDocuments" - report = ArtifactHtmlReport(report_name) - report.start_artifact_report(report_folder, report_name) - report.add_script() - report.write_artifact_data_table( - headers, html_rows, file_found, html_escape=False ) - report.end_artifact_report() - # TSV - scripts.ilapfuncs.tsv(report_folder, headers, tsv_rows, report_name, file_found) + report_name = "HistoryDetailDocuments" + report = ArtifactHtmlReport(report_name) + report.start_artifact_report(report_folder, report_name) + report.add_script() + + # Enforce safe framework-side text escaping to block script injection vectors entirely + report.write_artifact_data_table(headers, rows, file_found, html_escape=True) + report.end_artifact_report() - # Timeline (Msg. Timestamp, index 9) - scripts.ilapfuncs.timeline(report_folder, report_name, tsv_rows, headers) + tsv(report_folder, headers, rows, report_name, file_found) + timeline(report_folder, report_name, rows, headers) + logfunc( + f"[nova_chatbot_documents] Processed {len(rows)} submitted document records." + ) \ No newline at end of file diff --git a/scripts/artifacts/AIChatbotNovaHistoryDetailImage.py b/scripts/artifacts/AIChatbotNovaHistoryDetailImage.py index c322efd5..6991de63 100644 --- a/scripts/artifacts/AIChatbotNovaHistoryDetailImage.py +++ b/scripts/artifacts/AIChatbotNovaHistoryDetailImage.py @@ -1,56 +1,28 @@ __artifacts_v2__ = { "nova_chatbot_images": { - "name": "Nova AI Chatbot - Images (Generated & Submitted)", + "name": "HistoryDetailImages", "description": ( - "Extracts all image records from the AI Chatbot - Nova app " - "(HistoryDetailImage table). Each row represents one image and is enriched " - "with the parent message context from HistoryDetail and the parent " - "conversation context from History. " - "Images are correctly identified by the parent message role: " - "USER messages contain images submitted by the device user (e.g., vision queries); " - "ASSISTANT messages contain images generated by the AI. " - "Both types are stored on Firebase Storage; no local copies are predictably " - "available offline. The report shows the prompt, metadata, and a forensic note " - "explaining the image origin and storage behaviour. Generation state, pipeline, " - "and style ID are included for AI‑generated images where applicable." + "Extracts user-submitted image links and AI-generated image records from the " + "HistoryDetailImage table, enriched with parent message and conversation context." ), "author": "Guilherme Guilherme", - "version": "0.5", - "date": "2026-05-03", + "version": "1.0", + "date": "2026-05-21", "requirements": "none", "category": "AI Chatbot - Nova", - "notes": ( - "Database: com.scaleup.chatai/databases/chat-ai.db. " - "HistoryDetailImage.url stores a Firebase Storage object path. " - "Identification of image origin is based on the parent message's type: " - "0 = USER → user‑submitted image; 1 = ASSISTANT → AI‑generated image. " - "The source column in HistoryDetailImage is ignored because it is often " - "misleading (e.g., vision images are marked as source=0 but are user‑submitted). " - "AI‑generated images are temporarily cached in cache/image_manager_disk_cache/*.0 " - "but the filenames are SHA‑256 hashes of signed URLs containing a token not " - "stored on the device; automatic matching is impossible. User‑submitted images " - "are not cached locally. " - "state: 1=Success, 0=Pending, 2=Failed (only relevant for AI‑generated). " - "pipeline identifies the generation engine (e.g. flux_tpu). " - "styleId references the visual style preset selected by the user. " - "softDeleted is inherited from the parent History record." - ), + "notes": "Database: com.scaleup.chatai/databases/chat-ai.db", "paths": ("*/com.scaleup.chatai/databases/chat-ai.db",), "function": "get_nova_chatbot_images", + "output_types": "standard", + "artifact_icon": "image", } } -import os -import shutil -import sqlite3 import datetime -import html as html_module +from datetime import timezone from scripts.artifact_report import ArtifactHtmlReport -import scripts.ilapfuncs +from scripts.ilapfuncs import logfunc, tsv, timeline, open_sqlite_db_readonly -# --------------------------------------------------------------------------- -# Known mappings for the chatBotModel integer field. -# --------------------------------------------------------------------------- CHAT_BOT_MODEL_MAP = { 0: "ChatGPT 3.5", 1: "GPT-5", @@ -90,306 +62,158 @@ 2: "Failed", } -# --------------------------------------------------------------------------- -# SQL (includes msg_type from HistoryDetail) -# --------------------------------------------------------------------------- QUERY = """ SELECT - i.id AS img_id, - i.historyDetailID AS msg_id, - i.url AS img_url, - i.prompt AS prompt, - i.state AS state, - i.mimeType AS mime_type, - i.styleId AS style_id, - i.source AS source, - i.sourceUrl AS source_url, - i.pipeline AS pipeline, - - hd.historyID AS conv_id, - hd.type AS msg_type, - hd.text AS msg_text, - hd.createdAt AS msg_created_at, - - h.UUID AS conv_uuid, - h.title AS conv_title, - h.chatBotModel AS chat_bot_model, - h.softDeleted AS soft_deleted - + i.id, + i.historyDetailID, + i.url, + i.prompt, + i.state, + i.mimeType, + i.styleId, + i.pipeline, + hd.historyID, + hd.type, + hd.text, + hd.createdAt, + h.UUID, + h.title, + h.chatBotModel, + h.softDeleted FROM HistoryDetailImage i INNER JOIN HistoryDetail hd ON hd.id = i.historyDetailID INNER JOIN History h ON h.id = hd.historyID ORDER BY i.id ASC """ -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _e(text): - return html_module.escape(str(text)) if text else "" - def _convert_ms_timestamp(ms): + """Safely converts Unix millisecond timestamp using modern timezone.utc.""" if ms is None: return "" try: - return datetime.datetime.utcfromtimestamp(ms / 1000).strftime( + return datetime.datetime.fromtimestamp(float(ms) / 1000, timezone.utc).strftime( "%Y-%m-%d %H:%M:%S UTC" ) except (OSError, OverflowError, ValueError): return str(ms) -def _resolve_model(model_int): - if model_int is None: - return "Unknown" - name = CHAT_BOT_MODEL_MAP.get(model_int) - return f"{name} ({model_int})" if name else f"Unknown Model ({model_int})" - - -def _resolve_state(state_int): - if state_int is None: - return "" - label = IMAGE_STATE_MAP.get(state_int) - return f"{label} ({state_int})" if label else f"Unknown ({state_int})" - - -def _format_soft_deleted(value): - return "DELETED" if value == 1 else "No" - - -def _format_role(type_int): - return {0: "USER", 1: "ASSISTANT"}.get(type_int, f"UNKNOWN ({type_int})") - - -def _get_image_origin(msg_type): - """Return 'user-submitted' for msg_type=0, 'AI-generated' for msg_type=1.""" - if msg_type == 0: - return ("user-submitted", "User‑submitted image") - elif msg_type == 1: - return ("ai-generated", "AI‑generated image") - else: - return ("unknown", "Unknown origin") - - -# --------------------------------------------------------------------------- -# Image file resolution (always None) -# --------------------------------------------------------------------------- -def _resolve_image_file(db_url, seeker, images_dir): - return None - - -# --------------------------------------------------------------------------- -# HTML cell builder -# --------------------------------------------------------------------------- - - -def _build_image_cell( - img_url, prompt, state, mime_type, pipeline, style_id, msg_type, filename -): - """ - Build HTML cell using msg_type to determine image origin. - """ - origin_key, origin_label = _get_image_origin(msg_type) - cell = '
' - - if prompt: - cell += f'
Prompt: {_e(prompt)}
' - - if origin_key == "ai-generated": - cell += ( - f'
' - f" 🤖 AI‑generated image
" - f" This image was created by the AI based on the user prompt. " - f" It is stored on Firebase Storage; a temporary local copy may exist " - f" in cache/image_manager_disk_cache/*.0 but the filename " - f" is a hash of a signed URL that includes a token not stored on the device. " - f" Manual inspection of .0 files is recommended.
" - f" Forensic action: Examine .0 files directly as JPEG." - f"
" - ) - elif origin_key == "user-submitted": - cell += ( - f'
' - f" 📤 User‑submitted image
" - f" This image was actively uploaded by the device user (e.g., as part of a vision query). " - f" The file content is stored on Firebase Storage and is not cached locally. " - f" Only the metadata record remains on the device." - f"
" - ) - else: - cell += ( - f'
' - f" ❓ Unknown image origin
" - f" The parent message type is {_e(str(msg_type))} – cannot determine if user‑submitted or AI‑generated." - f"
" - ) - - if pipeline: - cell += f"
Pipeline: {_e(pipeline)}
" - if style_id is not None: - cell += f"
Style ID: {_e(str(style_id))}
" - if mime_type: - cell += f"
MIME Type: {_e(mime_type)}
" - - cell += ( - f'
' - f" Firebase path:
" - f' {_e(img_url)}' - f"
" - ) - cell += "
" - return cell - - -# --------------------------------------------------------------------------- -# Entry point -# --------------------------------------------------------------------------- - - def get_nova_chatbot_images(files_found, report_folder, seeker, wrap_text): - for file_found in files_found: - file_found = str(file_found) - if not file_found.endswith("chat-ai.db"): - continue - - try: - db = sqlite3.connect(file_found) - cursor = db.cursor() - cursor.execute(QUERY) - rows_raw = cursor.fetchall() - db.close() - except Exception as e: - scripts.ilapfuncs.logfunc( - f"[nova_chatbot_images] Error reading {file_found}: {e}" - ) - continue - - if not rows_raw: - scripts.ilapfuncs.logfunc( - f"[nova_chbot_images] No image records found in {file_found}." - ) - continue - - images_dir = os.path.join(report_folder, "nova_images") - os.makedirs(images_dir, exist_ok=True) - - headers = [ - "Image ID", - "Msg. ID", - "Conv. ID", - "Conv. UUID", - "Conv. Title", - "AI Model", - "Conv. Deleted", - "Msg. Role", - "Msg. Text", - "Msg. Timestamp (UTC)", - "Image Preview & Metadata", - "Prompt", - "State", - "Pipeline", - "Style ID", - "MIME Type", - "Firebase Path", - ] + logfunc("Processing data for HistoryDetail Images") - html_rows = [] - tsv_rows = [] + # Filter out secondary transactional database engines + files_found = [ + x for x in files_found if not x.endswith(("-journal", "-wal", "-shm")) + ] + file_found = next((str(x) for x in files_found if "chat-ai.db" in str(x)), None) - for row in rows_raw: - ( - img_id, - msg_id, - img_url, - prompt, - state, - mime_type, - style_id, - source, - source_url, - pipeline, - conv_id, - msg_type, - msg_text, - msg_created_at, - conv_uuid, - conv_title, - chat_bot_model, - soft_deleted, - ) = row + if not file_found: + logfunc("[nova_chatbot_images] Nova database file not found.") + return - filename = _resolve_image_file(img_url, seeker, images_dir) + try: + db = open_sqlite_db_readonly(file_found) + cursor = db.cursor() + cursor.execute(QUERY) + rows_raw = cursor.fetchall() + db.close() + except Exception as e: + logfunc(f"[nova_chatbot_images] Error reading {file_found}: {e}") + return + + if not rows_raw: + logfunc(f"[nova_chatbot_images] No image records found in {file_found}.") + return + + headers = ( + "Image ID", + "Msg. ID", + "Conv. ID", + "Conv. UUID", + "Conv. Title", + "AI Model", + "Conv. Deleted", + "Media Submitted By", + "Msg. Text", + "Msg. Timestamp (UTC)", + "Prompt", + "State", + "Pipeline", + "Style ID", + "MIME Type", + "Firebase Storage Path", + "Forensic Notes", + ) - common = ( - img_id, - msg_id, - conv_id, - conv_uuid or "", - conv_title or "", - _resolve_model(chat_bot_model), - _format_soft_deleted(soft_deleted), - _format_role(msg_type), - msg_text or "", - _convert_ms_timestamp(msg_created_at), + rows = [] + for row in rows_raw: + model_int = row[14] + model_name = "Unknown" + if model_int is not None: + name_lookup = CHAT_BOT_MODEL_MAP.get(model_int) + model_name = ( + f"{name_lookup} ({model_int})" + if name_lookup + else f"Unknown Model ({model_int})" ) - img_cell = _build_image_cell( - img_url, - prompt, - state, - mime_type, - pipeline, - style_id, - msg_type, - filename, + state_int = row[4] + state_label = "" + if state_int is not None: + name_state = IMAGE_STATE_MAP.get(state_int) + state_label = f"{name_state} ({state_int})" if name_state else f"Unknown ({state_int})" + + raw_role = row[9] + if raw_role == 0: + submitted_by = "USER" + forensic_note = ( + "Media element actively selected and submitted by the user to the chatbot interface. " + "The file content resides remotely on Firebase Storage and is not locally cached." ) - - # For TSV we also include the origin (derived from msg_type) - origin_label = ( - "User-submitted" - if msg_type == 0 - else "AI-generated" - if msg_type == 1 - else "Unknown" + elif raw_role == 1: + submitted_by = "AI ASSISTANT" + forensic_note = ( + "Media element generated or provided back by the AI response. " + "Stored on Firebase Storage; temporary local copies might be found in cache/image_manager_disk_cache/." ) + else: + submitted_by = f"UNKNOWN ({raw_role})" + forensic_note = "Unknown structural context for media origin." - html_rows.append( - common - + ( - img_cell, - prompt or "", - _resolve_state(state), - pipeline or "", - style_id if style_id is not None else "", - mime_type or "", - img_url or "", - ) + rows.append( + ( + row[0], + row[1], + row[8], + row[12] or "", + row[13] or "", + model_name, + "DELETED" if row[15] == 1 else "No", + submitted_by, + row[10] or "", + _convert_ms_timestamp(row[11]), + row[3] or "", + state_label, + row[7] or "", + row[6] if row[6] is not None else "", + row[5] or "", + row[2] or "", + forensic_note, ) + ) - tsv_rows.append( - common - + ( - "", # no HTML in TSV - prompt or "", - _resolve_state(state), - pipeline or "", - style_id if style_id is not None else "", - mime_type or "", - img_url or "", - ) - ) + report_name = "HistoryDetailImages" + report = ArtifactHtmlReport(report_name) + report.start_artifact_report(report_folder, report_name) + report.add_script() - report_name = "Nova AI Chatbot - HistoryDetailImage" - report = ArtifactHtmlReport(report_name) - report.start_artifact_report(report_folder, report_name) - report.add_script() - report.write_artifact_data_table( - headers, html_rows, file_found, html_escape=False - ) - report.end_artifact_report() + # Enforce safe framework-side text escaping to block script injection vectors entirely + report.write_artifact_data_table(headers, rows, file_found, html_escape=True) + report.end_artifact_report() - scripts.ilapfuncs.tsv(report_folder, headers, tsv_rows, report_name, file_found) - scripts.ilapfuncs.timeline(report_folder, report_name, tsv_rows, headers) + tsv(report_folder, headers, rows, report_name, file_found) + timeline(report_folder, report_name, rows, headers) + logfunc( + f"[nova_chatbot_images] Processed {len(rows)} submitted image records." + ) diff --git a/scripts/artifacts/AIChatbotNovaHistoryDetailLink.py b/scripts/artifacts/AIChatbotNovaHistoryDetailLink.py index 30a343c9..a88ef782 100644 --- a/scripts/artifacts/AIChatbotNovaHistoryDetailLink.py +++ b/scripts/artifacts/AIChatbotNovaHistoryDetailLink.py @@ -1,6 +1,6 @@ __artifacts_v2__ = { "nova_chatbot_links": { - "name": "Nova AI Chatbot - Shared Links", + "name": "Shared Links", "description": ( "Extracts all link records from the AI Chatbot - Nova app " "(HistoryDetailLink table). Each row represents one URL shared within " @@ -202,7 +202,7 @@ def get_nova_chatbot_links(files_found, report_folder, seeker, wrap_text): scripts.ilapfuncs.logfunc( f"[nova_chatbot_links] HistoryDetailLink table is empty in {file_found}." ) - report_name = "Nova AI Chatbot - HistoryDetailLinks" + report_name = "HistoryDetailLinks" report = ArtifactHtmlReport(report_name) report.start_artifact_report(report_folder, report_name) report.add_script() @@ -280,7 +280,7 @@ def get_nova_chatbot_links(files_found, report_folder, seeker, wrap_text): tsv_rows.append(common + (link_url or "",)) # HTML report - report_name = "Nova AI Chatbot - HistoryDetailLinks" + report_name = "HistoryDetailLinks" report = ArtifactHtmlReport(report_name) report.start_artifact_report(report_folder, report_name) report.add_script() diff --git a/scripts/artifacts/AIChatbotNovaMediastore.py b/scripts/artifacts/AIChatbotNovaMediastore.py index dc0c65b6..451fcd07 100644 --- a/scripts/artifacts/AIChatbotNovaMediastore.py +++ b/scripts/artifacts/AIChatbotNovaMediastore.py @@ -1,381 +1,259 @@ __artifacts_v2__ = { "nova_user_submissions": { - "name": "Nova AI Chatbot - User Media Submissions", + "name": "User Media Submissions", "description": ( - "Identifies ALL media files submitted by the user to Nova AI Chatbot. " - "This includes uploaded documents and photos captured using the in-app camera. " - "The artifact lists recovered filenames, user context, timestamps, MIME types, " - "and resolved physical paths from the extracted filesystem." + "Identifies media files submitted by the user to Nova AI Chatbot, including " + "uploaded documents and photos captured using the in-app camera. The artifact " + "lists recovered filenames, conversation context, timestamps, MIME types, and resolved " + "physical paths from the extracted filesystem." ), "author": "Guilherme Guilherme", - "version": "2.7", - "date": "2026-05-20", + "version": "3.3", + "date": "2026-05-21", "requirements": "none", "category": "AI Chatbot - Nova", - "notes": ( - "Sources: chat-ai.db and Android MediaStore databases. " - "Resolves extracted filesystem paths and falls back to filename search when needed. " - "This module does not embed previews; it focuses on metadata and physical path reporting." - ), + "notes": "Sources: chat-ai.db and Android MediaStore databases.", "paths": ( "*/com.scaleup.chatai/databases/chat-ai.db", "*/com.android.providers.media/databases/external*.db", "*/com.google.android.providers.media.module/databases/external*.db", ), "function": "get_nova_user_submissions", + "output_types": "standard", + "artifact_icon": "folder", } } import os -import csv -import sqlite3 import datetime -import html as html_module +from datetime import timezone from scripts.artifact_report import ArtifactHtmlReport -import scripts.ilapfuncs - - -def _e(text): - return html_module.escape(str(text)) if text else "" - +from scripts.ilapfuncs import logfunc, tsv, open_sqlite_db_readonly, media_to_html -def _convert_ms_timestamp(ms): - if ms is None: - return "" - try: - return datetime.datetime.utcfromtimestamp(ms / 1000).strftime( - "%Y-%m-%d %H:%M:%S UTC" - ) - except Exception: - return str(ms) - -def _convert_sec_timestamp(ts): - if ts is None: +def _parse_path(raw_path): + """Normalizes paths and slices them to always start at /data.""" + if not raw_path: return "" - try: - return datetime.datetime.utcfromtimestamp(int(ts)).strftime( - "%Y-%m-%d %H:%M:%S UTC" - ) - except Exception: - return str(ts) - - -def _format_file_size(size_bytes): - if size_bytes is None: - return "" - try: - size_bytes = int(size_bytes) - if size_bytes < 1024: - return f"{size_bytes} B" - if size_bytes < 1024**2: - return f"{size_bytes / 1024:.1f} KB" - if size_bytes < 1024**3: - return f"{size_bytes / (1024**2):.1f} MB" - return f"{size_bytes / (1024**3):.2f} GB" - except Exception: - return str(size_bytes) - - -def _normalize_media_path(media_path): - if not media_path: - return None - p = media_path.replace("\\", "/") - if p.startswith("/storage/emulated/0/"): - p = p.replace("/storage/emulated/0/", "/data/media/0/", 1) - return p - - -def _resolve_extraction_path(extraction_root, media_path): - if not extraction_root or not media_path: - return None - - normalized = _normalize_media_path(media_path) - candidate = os.path.normpath(os.path.join(extraction_root, normalized.lstrip("/"))) - if os.path.exists(candidate): - return candidate - - fname = os.path.basename(normalized) - filename_candidate = os.path.normpath(os.path.join(extraction_root, fname)) - if os.path.exists(filename_candidate): - return filename_candidate - - data_media_candidate = os.path.normpath( - os.path.join(extraction_root, "data/media/0", fname) - ) - if os.path.exists(data_media_candidate): - return data_media_candidate - - for root, dirs, files in os.walk(extraction_root): - if fname in files: - return os.path.join(root, fname) - - return None + normalized = str(raw_path).replace("\\", "/") + if "/data/" in normalized: + return "/data/" + normalized.split("/data/", 1)[1] + elif "data/data/" in normalized: + return "/data/data/" + normalized.split("data/data/", 1)[1] + return normalized def get_nova_user_submissions(files_found, report_folder, seeker, wrap_text): - nova_db = None - media_db = None - - for file_found in files_found: - file_found = str(file_found) - if "chat-ai.db" in file_found: - nova_db = file_found - elif "external" in file_found and file_found.endswith(".db"): - media_db = file_found + logfunc("Processing data for Nova User Media Submissions") + + files_found = [ + x for x in files_found if not x.endswith(("-journal", "-wal", "-shm")) + ] + nova_db = next((str(x) for x in files_found if "chat-ai.db" in str(x)), None) + media_db = next( + ( + str(x) + for x in files_found + if "external" in str(x) and str(x).endswith(".db") + ), + None, + ) if not nova_db: - scripts.ilapfuncs.logfunc("[nova_user_submissions] Nova database not found.") + logfunc("[nova_user_submissions] Nova database not found.") return - extraction_root = getattr(seeker, "search_dir", "") - scripts.ilapfuncs.logfunc( - f"[nova_user_submissions] Extraction root: {extraction_root}" - ) - + extraction_root = getattr(seeker, "search_dir", "") or "" media_lookup = {} - if media_db and os.path.exists(media_db): + # 1. Map the MediaStore database records to see what is on local storage + if media_db: try: - db = sqlite3.connect(media_db) - cursor = db.cursor() - cursor.execute(""" + db = open_sqlite_db_readonly(media_db) + cur = db.cursor() + cur.execute(""" SELECT _display_name, _data, _size, date_added, mime_type FROM files WHERE _data IS NOT NULL """) - for ( - display_name, - data_path, - size, - date_added, - mime_type, - ) in cursor.fetchall(): - normalized_path = _normalize_media_path(data_path or "") - if not normalized_path: - continue - - if not any( - x in normalized_path.lower() - for x in ["/download/", "/nova/", "/com.scaleup.chatai/"] - ): - continue - - key = (display_name or os.path.basename(normalized_path)).lower() - media_lookup[key] = { - "media_path": normalized_path, - "extraction_path": _resolve_extraction_path( - extraction_root, normalized_path - ), - "size": size, - "timestamp": date_added, - "mime": mime_type or "", - } - + for display_name, data_path, size, date_added, mime_type in cur.fetchall(): + local_path = "" + if data_path: + clean_rel = str(data_path).replace("\\", "/").lstrip("/") + if clean_rel.startswith("storage/emulated/0/"): + clean_rel = clean_rel.replace( + "storage/emulated/0/", "data/media/0/", 1 + ) + + candidate_path = os.path.join(extraction_root, clean_rel) + if os.path.exists(candidate_path): + local_path = candidate_path + + key = (display_name or os.path.basename(str(data_path))).lower() + media_lookup[key] = {"data_path": data_path, "local_path": local_path} db.close() except Exception as e: - scripts.ilapfuncs.logfunc( - f"[nova_user_submissions] Error reading MediaStore: {e}" - ) + logfunc(f"[nova_user_submissions] Error building MediaStore lookup: {e}") all_items = [] - query_docs = """ - SELECT - hdd.name, - hdd.url, - hdd.mimeType, - hdd.size, - hd.text, - hd.createdAt, - h.title - FROM HistoryDetailDocument hdd - INNER JOIN HistoryDetail hd ON hd.id = hdd.historyDetailID - INNER JOIN History h ON h.id = hd.historyID - WHERE hd.type = 0 - ORDER BY hd.createdAt DESC - """ - + # 2. Process chat database attachments and cross-reference with MediaStore try: - db = sqlite3.connect(nova_db) - cursor = db.cursor() - cursor.execute(query_docs) + db = open_sqlite_db_readonly(nova_db) + cur = db.cursor() + cur.execute(""" + SELECT + hdd.name, + hdd.mimeType, + hdd.size, + hd.text, + hd.createdAt, + h.title, + h.UUID + FROM HistoryDetailDocument hdd + INNER JOIN HistoryDetail hd ON hd.id = hdd.historyDetailID + INNER JOIN History h ON h.id = hd.historyID + WHERE hd.type = 0 + ORDER BY hd.createdAt DESC + """) for ( file_name, - firebase_url, mime_type, size_db, message, created_at, conversation, - ) in cursor.fetchall(): - media_match = media_lookup.get((file_name or "").lower()) + conv_uuid, + ) in cur.fetchall(): + mtime_str = "" + if created_at: + try: + mtime_str = datetime.datetime.fromtimestamp( + float(created_at) / 1000, timezone.utc + ).strftime("%Y-%m-%d %H:%M:%S UTC") + except Exception: + mtime_str = str(created_at) + + match_key = (file_name or "").lower() + if match_key in media_lookup: + match = media_lookup[match_key] + if match["local_path"]: + # Correctly links local images into ALEAPP's standard thumb pipeline + media_to_html(file_name, match["local_path"], report_folder) + display_path = _parse_path(match["data_path"]) + else: + display_path = "Cloud-only (Firebase Storage)" + all_items.append( - { - "type": "submitted_document", - "name": file_name or "Unknown", - "firebase_url": firebase_url or "", - "mime": mime_type or "", - "size_db": size_db, - "message": message or "", - "timestamp": created_at, - "conversation": conversation or "Untitled", - "media_path": media_match["media_path"] if media_match else None, - "extraction_path": media_match["extraction_path"] - if media_match - else None, - } + ( + file_name or "Unknown", + "Submitted to AI", + message or "", + conversation or "Untitled", + conv_uuid or "", + mtime_str, + size_db if size_db is not None else "", + mime_type or "", + display_path, + ) ) db.close() except Exception as e: - scripts.ilapfuncs.logfunc( - f"[nova_user_submissions] Error querying documents: {e}" - ) + logfunc(f"[nova_user_submissions] Error querying documents: {e}") - if media_db and os.path.exists(media_db): + # 3. Process standalone camera storage entries matching the application context + if media_db: try: - db = sqlite3.connect(media_db) - cursor = db.cursor() - cursor.execute(""" + db = open_sqlite_db_readonly(media_db) + cur = db.cursor() + cur.execute(""" SELECT _display_name, _data, _size, date_added, mime_type FROM files WHERE bucket_display_name = 'Nova' OR _data LIKE '%/Nova/%' ORDER BY date_added DESC """) - for ( - display_name, - data_path, - size, - date_added, - mime_type, - ) in cursor.fetchall(): - normalized_path = _normalize_media_path(data_path or "") - extraction_path = _resolve_extraction_path( - extraction_root, normalized_path + for display_name, data_path, size, date_added, mime_type in cur.fetchall(): + mtime_str = "" + if date_added: + try: + mtime_str = datetime.datetime.fromtimestamp( + int(date_added), timezone.utc + ).strftime("%Y-%m-%d %H:%M:%S UTC") + except Exception: + mtime_str = str(date_added) + + fname = display_name or ( + os.path.basename(str(data_path)) if data_path else "Unknown" ) + + clean_rel = str(data_path).replace("\\", "/").lstrip("/") + if clean_rel.startswith("storage/emulated/0/"): + clean_rel = clean_rel.replace( + "storage/emulated/0/", "data/media/0/", 1 + ) + + local_path = os.path.join(extraction_root, clean_rel) + if os.path.exists(local_path): + media_to_html(fname, local_path, report_folder) + all_items.append( - { - "type": "camera_photo", - "name": display_name - or os.path.basename(normalized_path or "") - or "Unknown", - "mime": mime_type or "image/jpeg", - "size_db": size, - "message": "", - "timestamp": date_added, - "conversation": "Camera photo (not associated with a message)", - "media_path": normalized_path, - "extraction_path": extraction_path, - } + ( + fname, + "Camera Photo", + "", + "Camera photo (not associated with a message)", + "", # Camera pictures do not have an associated conversation UUID + mtime_str, + size if size is not None else "", + mime_type or "image/jpeg", + _parse_path(data_path), + ) ) db.close() except Exception as e: - scripts.ilapfuncs.logfunc( - f"[nova_user_submissions] Error querying camera photos: {e}" - ) + logfunc(f"[nova_user_submissions] Error querying camera photos: {e}") + + if not all_items: + logfunc("[nova_user_submissions] No media found.") + return + # Deduplicate entries safely using filename and localized storage path attributes deduped = [] seen = set() - for item in all_items: - key = (item["name"].lower(), item.get("media_path") or "") + for row in all_items: + # Deduplicate using filename (index 0) and physical path data (index 8) + key = (row[0].lower(), row[8]) if key in seen: continue seen.add(key) - deduped.append(item) - - deduped.sort(key=lambda x: x.get("timestamp", 0) or 0, reverse=True) + deduped.append(row) - if not deduped: - scripts.ilapfuncs.logfunc("[nova_user_submissions] No media found.") - return + report_name = "User Media Submissions" + report = ArtifactHtmlReport(report_name) + report.start_artifact_report(report_folder, report_name) + report.add_script() headers = ( "File Name", "Type", "User Message / Context", - "Conversation", + "Conversation Title", + "Conv. UUID", "Date (UTC)", - "Size", + "Size (Bytes)", "MIME Type", - "Physical Path", + "Path", ) - rows = [] - tsv_rows = [] - - for item in deduped: - type_label = ( - "📤 Submitted to AI" - if item["type"] == "submitted_document" - else "📷 Camera Photo" - ) - context = item.get("message") or "No message recorded" - if isinstance(context, str) and len(context) > 150: - context = _e(context[:150] + "...") - date_str = ( - _convert_ms_timestamp(item["timestamp"]) - if item["type"] == "submitted_document" - else _convert_sec_timestamp(item["timestamp"]) - ) - size_str = _format_file_size(item.get("size_db")) - - if item.get("extraction_path") and os.path.exists(item["extraction_path"]): - physical_path = item["extraction_path"] - elif item.get("media_path"): - physical_path = item["media_path"] - else: - physical_path = "Cloud-only (Firebase Storage)" - - rows.append( - ( - _e(item["name"]), - type_label, - context, - _e(item["conversation"]), - date_str, - size_str, - _e(item.get("mime") or ""), - _e(physical_path), - ) - ) - - tsv_rows.append( - ( - item["name"], - type_label, - context, - item["conversation"], - date_str, - size_str, - item.get("mime") or "", - physical_path, - ) - ) - - report_name = "Nova AI Chatbot - User Media Submissions" - report = ArtifactHtmlReport(report_name) - report.start_artifact_report(report_folder, report_name) - report.add_script() - report.write_artifact_data_table(headers, rows, nova_db, html_escape=False) + # Compliant HTML injection vulnerability protection handled securely by the framework via DataTables + report.write_artifact_data_table( + headers, + deduped, + nova_db, + table_id="NovaUserSubmissions", + html_escape=True, + ) report.end_artifact_report() - tsv_path = os.path.join(report_folder, f"{report_name}.tsv") - with open(tsv_path, "w", newline="", encoding="utf-8") as tsvfile: - writer = csv.writer(tsvfile, delimiter="\t") - writer.writerow( - [ - "File Name", - "Type", - "User Message", - "Conversation", - "Date (UTC)", - "Size", - "MIME Type", - "Physical Path", - ] - ) - writer.writerows(tsv_rows) - - scripts.ilapfuncs.logfunc( - f"[nova_user_submissions] Found {len(deduped)} total items." - ) + tsv(report_folder, headers, deduped, report_name, nova_db) + logfunc(f"[nova_user_submissions] Found {len(deduped)} total items.") From 8be6e99135949f5307c90715dadf58c09fea19b5 Mon Sep 17 00:00:00 2001 From: Guilherme Guilherme Date: Thu, 21 May 2026 17:20:58 +0100 Subject: [PATCH 08/18] Fix Physical Image Path --- .../artifacts/AIChatbotNovaConversations.py | 708 ++++++------------ scripts/artifacts/AIChatbotNovaMediastore.py | 95 ++- 2 files changed, 310 insertions(+), 493 deletions(-) diff --git a/scripts/artifacts/AIChatbotNovaConversations.py b/scripts/artifacts/AIChatbotNovaConversations.py index 3fdce765..714b9790 100644 --- a/scripts/artifacts/AIChatbotNovaConversations.py +++ b/scripts/artifacts/AIChatbotNovaConversations.py @@ -4,51 +4,39 @@ "description": ( "Reconstructs full conversations from the AI Chatbot - Nova app by joining " "History, HistoryDetail, HistoryDetailImage, HistoryDetailDocument, and " - "HistoryDetailLink tables. Produces one row per message with all attachment " - "metadata surfaced inline. Image origin (user‑submitted vs AI‑generated) is " - "determined by the parent message role. Generated images are not resolvable " - "locally due to Firebase signed URL tokens; the report shows the Firebase path " - "and a forensic note. Documents are displayed with full metadata and a note " - "confirming they were submitted by the user. Soft‑deleted conversations are " - "flagged on every associated message row." + "HistoryDetailLink tables. Cross-references local file attachments with the " + "Android MediaStore database to map real physical file storage paths for both " + "documents and user-submitted images." ), "author": "Guilherme Guilherme", - "version": "0.5", - "date": "2026-05-03", + "version": "1.1", + "date": "2026-05-21", "requirements": "none", "category": "AI Chatbot - Nova", - "notes": ( - "Message timestamps are stored as Unix milliseconds (INTEGER) and are " - "converted to UTC for display and timeline submission. " - "HistoryDetail.type: 0 = USER, 1 = ASSISTANT. " - "Attachment columns are empty when no attachment is linked to a message. " - "A conversation flagged as DELETED means History.softDeleted = 1; the record " - "remains in the database after user deletion and is forensically recoverable. " - "chatBotModel is an integer mapped to known AI model names where possible. " - "Image origin is correctly identified by the parent message role: " - "USER messages contain user‑submitted images (e.g., vision queries); " - "ASSISTANT messages contain AI‑generated images. " - "All images are stored on Firebase Storage; local cache filenames are hashes " - "of signed URLs (tokens not on device), so automatic matching is impossible. " - "Documents are also stored on Firebase; no local copy is kept. " - "TSV export contains plain‑text equivalents for all attachment fields." + "notes": "Sources: chat-ai.db and Android MediaStore databases.", + "paths": ( + "*/com.scaleup.chatai/databases/chat-ai.db", + "*/com.android.providers.media/databases/external*.db", + "*/com.google.android.providers.media.module/databases/external*.db", ), - "paths": ("*/com.scaleup.chatai/databases/chat-ai.db",), "function": "get_nova_chatbot_conversations", + "output_types": "standard", + "artifact_icon": "message-square", } } import os -import shutil -import sqlite3 import datetime -import html as html_module +from datetime import timezone from scripts.artifact_report import ArtifactHtmlReport -import scripts.ilapfuncs +from scripts.ilapfuncs import ( + logfunc, + tsv, + timeline, + open_sqlite_db_readonly, + media_to_html, +) -# --------------------------------------------------------------------------- -# Known mappings for the chatBotModel integer field. -# --------------------------------------------------------------------------- CHAT_BOT_MODEL_MAP = { 0: "ChatGPT 3.5", 1: "GPT-5", @@ -82,309 +70,6 @@ 29: "GPT-4o Mini", } -IMAGE_STATE_MAP = { - 0: "Pending", - 1: "Success", - 2: "Failed", -} - -DOCUMENT_TYPE_MAP = { - 0: "Local File", - 1: "Remote File", -} - -MIME_ICON_MAP = { - "application/pdf": "📄", - "application/msword": "📝", - "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "📝", - "application/vnd.ms-excel": "📊", - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "📊", - "text/plain": "📃", - "text/csv": "📊", - "image/jpeg": "🖼️", - "image/png": "🖼️", - "image/gif": "🖼️", - "image/webp": "🖼️", -} - -# --------------------------------------------------------------------------- -# Scalar helpers -# --------------------------------------------------------------------------- - - -def _convert_ms_timestamp(ms): - if ms is None: - return "" - try: - return datetime.datetime.utcfromtimestamp(ms / 1000).strftime( - "%Y-%m-%d %H:%M:%S UTC" - ) - except (OSError, OverflowError, ValueError): - return str(ms) - - -def _resolve_model(model_int): - if model_int is None: - return "Unknown" - name = CHAT_BOT_MODEL_MAP.get(model_int) - return f"{name} ({model_int})" if name else f"Unknown Model ({model_int})" - - -def _resolve_image_state(state_int): - if state_int is None: - return "" - label = IMAGE_STATE_MAP.get(state_int) - return f"{label} ({state_int})" if label else f"Unknown State ({state_int})" - - -def _resolve_document_type(type_int): - if type_int is None: - return "" - label = DOCUMENT_TYPE_MAP.get(type_int) - return f"{label} ({type_int})" if label else f"Unknown Type ({type_int})" - - -def _format_role(type_int): - return {0: "USER", 1: "ASSISTANT"}.get(type_int, f"UNKNOWN ({type_int})") - - -def _format_soft_deleted(value): - return "DELETED" if value == 1 else "No" - - -def _format_file_size(size_bytes): - if size_bytes is None: - return "" - try: - size_bytes = int(size_bytes) - if size_bytes < 1024: - return f"{size_bytes} B" - elif size_bytes < 1024**2: - return f"{size_bytes / 1024:.1f} KB" - elif size_bytes < 1024**3: - return f"{size_bytes / (1024**2):.1f} MB" - else: - return f"{size_bytes / (1024**3):.2f} GB" - except (ValueError, TypeError): - return str(size_bytes) - - -def _e(text): - return html_module.escape(str(text)) if text else "" - - -# --------------------------------------------------------------------------- -# Image resolution – always None (no local preview) -# --------------------------------------------------------------------------- -def _resolve_image_file(db_url, seeker, images_dir): - return None - - -# --------------------------------------------------------------------------- -# Rich HTML cell builders -# --------------------------------------------------------------------------- - - -def _build_image_html(msg_type, img_urls, img_prompts, img_states, resolved_filenames): - """ - Build HTML cell for images. Uses msg_type to determine origin. - """ - if not img_urls: - return "" - - urls = [u.strip() for u in img_urls.split(",") if u.strip()] - prompts = ( - [p.strip() for p in img_prompts.split(",") if p.strip()] if img_prompts else [] - ) - - parts = [] - for i, url in enumerate(urls): - prompt = prompts[i] if i < len(prompts) else "" - filename = resolved_filenames[i] if i < len(resolved_filenames) else None - - cell = '
' - - # Prompt - if prompt: - cell += f'
Prompt: {_e(prompt)}
' - - # Forensic note based on msg_type - if msg_type == 0: # USER → user‑submitted (vision query) - cell += ( - f'
' - f" 📤 User‑submitted image
" - f" This image was uploaded by the device user (e.g., as part of a vision query). " - f" The file content is stored on Firebase Storage and is not cached locally." - f"
" - ) - else: # ASSISTANT or unknown → AI‑generated - cell += ( - f'
' - f" 🤖 AI‑generated image
" - f" This image was created by the AI based on the user prompt. " - f" It is stored on Firebase Storage; a temporary local copy may exist " - f" in cache/image_manager_disk_cache/*.0 but the filename " - f" is a hash of a signed URL that includes a token not stored on the device. " - f" Manual inspection of .0 files is recommended.
" - f" Forensic action: Examine .0 files directly as JPEG." - f"
" - ) - - # Firebase path (was "Internal path") - cell += ( - f'
' - f" Firebase path:
" - f' {_e(url)}' - f"
" - ) - cell += "
" - - if i < len(urls) - 1: - cell += ( - '
' - ) - parts.append(cell) - - return "".join(parts) - - -def _build_document_html(doc_names, doc_mime_types, doc_sizes, doc_urls, doc_types): - """ - Build HTML cell for documents – no local preview, shows Firebase path and forensic note. - """ - if not doc_names: - return "" - - names = [n.strip() for n in doc_names.split(",") if n.strip()] - mimes = ( - [m.strip() for m in doc_mime_types.split(",") if m.strip()] - if doc_mime_types - else [] - ) - sizes = [s.strip() for s in doc_sizes.split(",") if s.strip()] if doc_sizes else [] - urls = [u.strip() for u in doc_urls.split(",") if u.strip()] if doc_urls else [] - types = [t.strip() for t in doc_types.split(",") if t.strip()] if doc_types else [] - - parts = [] - for i, name in enumerate(names): - mime = mimes[i] if i < len(mimes) else "" - size_raw = sizes[i] if i < len(sizes) else None - url = urls[i] if i < len(urls) else "" - dtype_raw = types[i] if i < len(types) else None - - icon = MIME_ICON_MAP.get(mime, "📎") - size_label = ( - _format_file_size(int(size_raw)) - if size_raw and size_raw.lstrip("-").isdigit() - else "" - ) - dtype_label = ( - _resolve_document_type(int(dtype_raw)) - if dtype_raw and dtype_raw.lstrip("-").isdigit() - else "" - ) - - cell = ( - f'
' - f'
{icon}
' - f'
{_e(name)}
' - ) - if mime: - cell += f"
MIME Type: {_e(mime)}
" - if size_label: - cell += f"
Size: {_e(size_label)}
" - if url: - cell += ( - f"
Firebase path:
" - f' {_e(url)}
' - ) - if dtype_label: - cell += f"
Source Type: {_e(dtype_label)}
" - - # Forensic note – same as standalone document module - cell += ( - f'
' - f" ⚠️ Forensic note: This file was submitted by the" - f" user to the AI assistant as part of this conversation." - f"
" - f"
" - ) - - if i < len(names) - 1: - cell += ( - '
' - ) - parts.append(cell) - - return "".join(parts) - - -# --------------------------------------------------------------------------- -# Plain-text builders for TSV / timeline -# --------------------------------------------------------------------------- - - -def _build_image_tsv( - msg_type, img_urls, img_prompts, img_states, img_mime_types, img_pipelines -): - """Flat plain‑text representation for TSV.""" - if not img_urls: - return "" - origin = "user-submitted" if msg_type == 0 else "ai-generated" - parts = [f"Origin: {origin}", f"URL: {img_urls}"] - if img_prompts: - parts.append(f"Prompt: {img_prompts}") - if img_states: - states = ", ".join( - _resolve_image_state(int(s.strip())) - for s in img_states.split(",") - if s.strip().lstrip("-").isdigit() - ) - if states: - parts.append(f"State: {states}") - if img_mime_types: - parts.append(f"MIME: {img_mime_types}") - if img_pipelines: - parts.append(f"Pipeline: {img_pipelines}") - return " | ".join(parts) - - -def _build_document_tsv(doc_names, doc_mime_types, doc_sizes, doc_urls, doc_types): - """Flat plain‑text representation for TSV.""" - if not doc_names: - return "" - size_label = ( - _format_file_size(doc_sizes) - if doc_sizes and doc_sizes.lstrip("-").isdigit() - else "" - ) - dtype_label = "" - if doc_types: - first_type = doc_types.split(",")[0].strip() - if first_type.lstrip("-").isdigit(): - dtype_label = _resolve_document_type(int(first_type)) - else: - dtype_label = first_type - parts = [f"Name: {doc_names}"] - if doc_mime_types: - parts.append(f"MIME: {doc_mime_types}") - if size_label: - parts.append(f"Size: {size_label}") - if doc_urls: - parts.append(f"Path: {doc_urls}") - if dtype_label: - parts.append(f"Type: {dtype_label}") - parts.append("Note: File submitted by user to AI assistant") - return " | ".join(parts) - - -# --------------------------------------------------------------------------- -# SQL (unchanged) -# --------------------------------------------------------------------------- QUERY = """ SELECT h.id AS conv_id, @@ -405,175 +90,234 @@ def _build_document_tsv(doc_names, doc_mime_types, doc_sizes, doc_urls, doc_type GROUP_CONCAT(DISTINCT hdi.url) AS img_urls, GROUP_CONCAT(DISTINCT hdi.prompt) AS img_prompts, - GROUP_CONCAT(DISTINCT hdi.state) AS img_states, - GROUP_CONCAT(DISTINCT hdi.mimeType) AS img_mime_types, - GROUP_CONCAT(DISTINCT hdi.pipeline) AS img_pipelines, GROUP_CONCAT(DISTINCT hdd.name) AS doc_names, - GROUP_CONCAT(DISTINCT hdd.mimeType) AS doc_mime_types, - GROUP_CONCAT(DISTINCT hdd.size) AS doc_sizes, + GROUP_CONCAT(DISTINCT hdd.mimeType) AS doc_mimes, GROUP_CONCAT(DISTINCT hdd.url) AS doc_urls, - GROUP_CONCAT(DISTINCT hdd.type) AS doc_types, GROUP_CONCAT(DISTINCT hdl.url) AS link_urls FROM History h -INNER JOIN HistoryDetail hd - ON hd.historyID = h.id -LEFT JOIN HistoryDetailImage hdi - ON hdi.historyDetailID = hd.id -LEFT JOIN HistoryDetailDocument hdd - ON hdd.historyDetailID = hd.id -LEFT JOIN HistoryDetailLink hdl - ON hdl.historyDetailID = hd.id +INNER JOIN HistoryDetail hd ON hd.historyID = h.id +LEFT JOIN HistoryDetailImage hdi ON hdi.historyDetailID = hd.id +LEFT JOIN HistoryDetailDocument hdd ON hdd.historyDetailID = hd.id +LEFT JOIN HistoryDetailLink hdl ON hdl.historyDetailID = hd.id GROUP BY hd.id ORDER BY h.id ASC, hd.createdAt ASC """ -# --------------------------------------------------------------------------- -# Entry point -# --------------------------------------------------------------------------- + +def _parse_path(raw_path): + """Normalizes paths and slices them to always start at /data.""" + if not raw_path: + return "" + normalized = str(raw_path).replace("\\", "/") + if "/data/" in normalized: + return "/data/" + normalized.split("/data/", 1)[1] + elif "data/data/" in normalized: + return "/data/data/" + normalized.split("data/data/", 1)[1] + return normalized + + +def _convert_ms_timestamp(ms): + """Safely converts Unix millisecond timestamp using modern timezone.utc.""" + if ms is None: + return "" + try: + return datetime.datetime.fromtimestamp(float(ms) / 1000, timezone.utc).strftime( + "%Y-%m-%d %H:%M:%S UTC" + ) + except (OSError, OverflowError, ValueError): + return str(ms) def get_nova_chatbot_conversations(files_found, report_folder, seeker, wrap_text): - for file_found in files_found: - file_found = str(file_found) - if not file_found.endswith("chat-ai.db"): - continue + logfunc("Processing data for Nova Full Conversations") + + files_found = [ + x for x in files_found if not x.endswith(("-journal", "-wal", "-shm")) + ] + nova_db = next((str(x) for x in files_found if "chat-ai.db" in str(x)), None) + media_db = next( + ( + str(x) + for x in files_found + if "external" in str(x) and str(x).endswith(".db") + ), + None, + ) + + if not nova_db: + logfunc("[nova_chatbot_conversations] Nova database file not found.") + return + extraction_root = getattr(seeker, "search_dir", "") or "" + media_lookup = {} + + # 1. Map the MediaStore database entries to verify files present on local storage + if media_db: try: - db = sqlite3.connect(file_found) - cursor = db.cursor() - cursor.execute(QUERY) - rows_raw = cursor.fetchall() + db = open_sqlite_db_readonly(media_db) + cur = db.cursor() + cur.execute(""" + SELECT _display_name, _data + FROM files + WHERE _data IS NOT NULL + """) + for display_name, data_path in cur.fetchall(): + local_path = "" + if data_path: + clean_rel = str(data_path).replace("\\", "/").lstrip("/") + if clean_rel.startswith("storage/emulated/0/"): + clean_rel = clean_rel.replace( + "storage/emulated/0/", "data/media/0/", 1 + ) + + candidate_path = os.path.join(extraction_root, clean_rel) + if os.path.exists(candidate_path): + local_path = candidate_path + + key = (display_name or os.path.basename(str(data_path))).lower() + media_lookup[key] = {"data_path": data_path, "local_path": local_path} db.close() except Exception as e: - scripts.ilapfuncs.logfunc( - f"[nova_chatbot_conversations] Error reading {file_found}: {e}" + logfunc( + f"[nova_chatbot_conversations] Error building MediaStore lookup: {e}" ) - continue - if not rows_raw: - scripts.ilapfuncs.logfunc( - f"[nova_chatbot_conversations] No records found in {file_found}." - ) - continue - - images_dir = os.path.join(report_folder, "nova_images") - os.makedirs(images_dir, exist_ok=True) - - headers = [ - "Conv. ID", - "Conv. UUID", - "Conv. Title", - "AI Model", - "Conv. Deleted", - "Conv. Sync State", - "Msg. ID", - "Msg. UUID", - "Role", - "Message Text", - "Token Count", - "Reasoning Content", - "Message Timestamp (UTC)", - "Msg. Sync State", - "Image Attachment", - "Document Attachment", - "Link URL(s)", - ] - - html_rows = [] - tsv_rows = [] - - for row in rows_raw: - ( - conv_id, - conv_uuid, - conv_title, - chat_bot_model, - soft_deleted, - conv_sync_state, - msg_id, - msg_uuid, - msg_type, - msg_text, - msg_token, - msg_reasoning, - msg_created_at, - msg_sync_state, - img_urls, - img_prompts, - img_states, - img_mime_types, - img_pipelines, - doc_names, - doc_mime_types, - doc_sizes, - doc_urls, - doc_types, - link_urls, - ) = row - - # Resolve images (always None, but we need a list parallel to URLs) - resolved_filenames = [] - if img_urls: - for raw_url in img_urls.split(","): - raw_url = raw_url.strip() - resolved_filenames.append( - _resolve_image_file(raw_url, seeker, images_dir) - ) - - # Common scalar columns - common = ( - conv_id, - conv_uuid or "", - conv_title or "", - _resolve_model(chat_bot_model), - _format_soft_deleted(soft_deleted), - conv_sync_state if conv_sync_state is not None else "", - msg_id, - msg_uuid or "", - _format_role(msg_type), - msg_text or "", - msg_token if msg_token is not None else "", - msg_reasoning or "", - _convert_ms_timestamp(msg_created_at), - msg_sync_state if msg_sync_state is not None else "", - ) + # 2. Query chat timeline logs + try: + db = open_sqlite_db_readonly(nova_db) + cursor = db.cursor() + cursor.execute(QUERY) + rows_raw = cursor.fetchall() + db.close() + except Exception as e: + logfunc(f"[nova_chatbot_conversations] Error reading {nova_db}: {e}") + return + + if not rows_raw: + logfunc(f"[nova_chatbot_conversations] No records found in {nova_db}.") + return + + headers = ( + "Conv. ID", + "Conv. UUID", + "Conv. Title", + "AI Model", + "Conv. Deleted", + "Msg. ID", + "Msg. UUID", + "Role", + "Message Text", + "Token Count", + "Reasoning Content", + "Message Timestamp (UTC)", + "Image Attachment Prompts", + "Image Physical Path", + "Image Firebase Path", + "Document Attachment Name", + "Document Physical Path", + "Document Firebase Path", + "Link URL(s)", + ) - # HTML cells - img_html = _build_image_html( - msg_type, img_urls, img_prompts, img_states, resolved_filenames + rows = [] + for row in rows_raw: + model_int = row[3] + model_name = "Unknown" + if model_int is not None: + name_lookup = CHAT_BOT_MODEL_MAP.get(model_int) + model_name = ( + f"{name_lookup} ({model_int})" + if name_lookup + else f"Unknown Model ({model_int})" ) - doc_html = _build_document_html( - doc_names, doc_mime_types, doc_sizes, doc_urls, doc_types - ) - html_rows.append(common + (img_html, doc_html, link_urls or "")) - - # TSV cells - img_tsv = _build_image_tsv( - msg_type, - img_urls, - img_prompts, - img_states, - img_mime_types, - img_pipelines, - ) - doc_tsv = _build_document_tsv( - doc_names, doc_mime_types, doc_sizes, doc_urls, doc_types + + raw_role = row[8] + role_str = ( + "USER" + if raw_role == 0 + else "AI ASSISTANT" + if raw_role == 1 + else f"UNKNOWN ({raw_role})" + ) + + # A. Cross-reference documents using mapped MediaStore indices + doc_names_raw = row[16] + doc_phys_path_resolved = "Cloud-only (Firebase Storage)" + + if doc_names_raw: + primary_doc = doc_names_raw.split(",")[0].strip() + match_key = primary_doc.lower() + if match_key in media_lookup: + match = media_lookup[match_key] + if match["local_path"]: + media_to_html(primary_doc, match["local_path"], report_folder) + doc_phys_path_resolved = _parse_path(match["data_path"]) + + # B. Cross-reference images inside conversation rows for local paths if available + img_urls_raw = row[14] + img_phys_path_resolved = "Cloud-only (Firebase Storage)" + + if img_urls_raw: + # We evaluate the first image in the group for table display mapping + primary_img_url = img_urls_raw.split(",")[0].strip() + # Clean remote arguments out if present inside URL structures + img_name = os.path.basename(primary_img_url.split("?")[0]) + img_key = img_name.lower() + + if img_key in media_lookup: + match = media_lookup[img_key] + if match["local_path"]: + media_to_html(img_name, match["local_path"], report_folder) + img_phys_path_resolved = _parse_path(match["data_path"]) + + # Also ensure any secondary images in a comma-separated list pass safely into the gallery pipelines + if "," in img_urls_raw: + for url_part in img_urls_raw.split(",")[1:]: + sec_name = os.path.basename(url_part.strip().split("?")[0]) + sec_key = sec_name.lower() + if sec_key in media_lookup and media_lookup[sec_key]["local_path"]: + media_to_html( + sec_name, media_lookup[sec_key]["local_path"], report_folder + ) + + rows.append( + ( + row[0], + row[1] or "", + row[2] or "", + model_name, + "DELETED" if row[4] == 1 else "No", + row[6], + row[7] or "", + role_str, + row[9] or "", + row[10] if row[10] is not None else "", + row[11] or "", + _convert_ms_timestamp(row[12]), + row[15] or "", + img_phys_path_resolved, + row[14] or "", + row[16] or "", + doc_phys_path_resolved, + row[18] or "", + row[19] or "", ) - tsv_rows.append(common + (img_tsv, doc_tsv, link_urls or "")) - - # HTML report - report_name = "Conversations (Full Detail)" - report = ArtifactHtmlReport(report_name) - report.start_artifact_report(report_folder, report_name) - report.add_script() - report.write_artifact_data_table( - headers, html_rows, file_found, html_escape=False ) - report.end_artifact_report() - # TSV and timeline - scripts.ilapfuncs.tsv(report_folder, headers, tsv_rows, report_name, file_found) - scripts.ilapfuncs.timeline(report_folder, report_name, tsv_rows, headers) + report_name = "Conversations (Full Detail)" + report = ArtifactHtmlReport(report_name) + report.start_artifact_report(report_folder, report_name) + report.add_script() + + # Enforce strict framework-side HTML cell escaping to protect against code injection + report.write_artifact_data_table(headers, rows, nova_db, html_escape=True) + report.end_artifact_report() + + tsv(report_folder, headers, rows, report_name, nova_db) + timeline(report_folder, report_name, rows, headers) + logfunc( + f"[nova_chatbot_conversations] Processed {len(rows)} timeline message items." + ) diff --git a/scripts/artifacts/AIChatbotNovaMediastore.py b/scripts/artifacts/AIChatbotNovaMediastore.py index 451fcd07..2006f28f 100644 --- a/scripts/artifacts/AIChatbotNovaMediastore.py +++ b/scripts/artifacts/AIChatbotNovaMediastore.py @@ -3,12 +3,12 @@ "name": "User Media Submissions", "description": ( "Identifies media files submitted by the user to Nova AI Chatbot, including " - "uploaded documents and photos captured using the in-app camera. The artifact " - "lists recovered filenames, conversation context, timestamps, MIME types, and resolved " - "physical paths from the extracted filesystem." + "uploaded documents, chat-attached images, and photos captured using the in-app camera. " + "The artifact lists recovered filenames, conversation context, timestamps, MIME types, " + "and resolved physical paths from the extracted filesystem." ), "author": "Guilherme Guilherme", - "version": "3.3", + "version": "3.4", "date": "2026-05-21", "requirements": "none", "category": "AI Chatbot - Nova", @@ -97,7 +97,7 @@ def get_nova_user_submissions(files_found, report_folder, seeker, wrap_text): all_items = [] - # 2. Process chat database attachments and cross-reference with MediaStore + # 2. Process chat database documents and cross-reference with MediaStore try: db = open_sqlite_db_readonly(nova_db) cur = db.cursor() @@ -138,7 +138,6 @@ def get_nova_user_submissions(files_found, report_folder, seeker, wrap_text): if match_key in media_lookup: match = media_lookup[match_key] if match["local_path"]: - # Correctly links local images into ALEAPP's standard thumb pipeline media_to_html(file_name, match["local_path"], report_folder) display_path = _parse_path(match["data_path"]) else: @@ -147,7 +146,7 @@ def get_nova_user_submissions(files_found, report_folder, seeker, wrap_text): all_items.append( ( file_name or "Unknown", - "Submitted to AI", + "Submitted Document", message or "", conversation or "Untitled", conv_uuid or "", @@ -161,7 +160,83 @@ def get_nova_user_submissions(files_found, report_folder, seeker, wrap_text): except Exception as e: logfunc(f"[nova_user_submissions] Error querying documents: {e}") - # 3. Process standalone camera storage entries matching the application context + # 3. New: Process user chat-submitted images (HistoryDetailImage) + try: + db = open_sqlite_db_readonly(nova_db) + cur = db.cursor() + cur.execute(""" + SELECT + hdi.url, + hdi.prompt, + hd.text, + hd.createdAt, + h.title, + h.UUID + FROM HistoryDetailImage hdi + INNER JOIN HistoryDetail hd ON hd.id = hdi.historyDetailID + INNER JOIN History h ON h.id = hd.historyID + WHERE hd.type = 0 + ORDER BY hd.createdAt DESC + """) + for ( + img_url, + prompt, + message, + created_at, + conversation, + conv_uuid, + ) in cur.fetchall(): + mtime_str = "" + if created_at: + try: + mtime_str = datetime.datetime.fromtimestamp( + float(created_at) / 1000, timezone.utc + ).strftime("%Y-%m-%d %H:%M:%S UTC") + except Exception: + mtime_str = str(created_at) + + # Extract the raw filename out of the remote url endpoint path + file_name = ( + os.path.basename(img_url.split("?")[0]) + if img_url + else "Unknown_Image.jpg" + ) + + # Form an inline context blending user's text message input with any associated image generation prompts + context_pieces = [] + if message: + context_pieces.append(f"Msg: {message}") + if prompt: + context_pieces.append(f"Prompt: {prompt}") + combined_context = " | ".join(context_pieces) + + match_key = file_name.lower() + if match_key in media_lookup: + match = media_lookup[match_key] + if match["local_path"]: + media_to_html(file_name, match["local_path"], report_folder) + display_path = _parse_path(match["data_path"]) + else: + display_path = "Cloud-only (Firebase Storage)" + + all_items.append( + ( + file_name, + "Submitted Image", + combined_context, + conversation or "Untitled", + conv_uuid or "", + mtime_str, + "", # Size metadata is typically absent or cloud-side for image mappings + "image/jpeg", + display_path, + ) + ) + db.close() + except Exception as e: + logfunc(f"[nova_user_submissions] Error querying submitted images: {e}") + + # 4. Process standalone camera storage entries matching the application context if media_db: try: db = open_sqlite_db_readonly(media_db) @@ -202,7 +277,7 @@ def get_nova_user_submissions(files_found, report_folder, seeker, wrap_text): "Camera Photo", "", "Camera photo (not associated with a message)", - "", # Camera pictures do not have an associated conversation UUID + "", mtime_str, size if size is not None else "", mime_type or "image/jpeg", @@ -221,7 +296,6 @@ def get_nova_user_submissions(files_found, report_folder, seeker, wrap_text): deduped = [] seen = set() for row in all_items: - # Deduplicate using filename (index 0) and physical path data (index 8) key = (row[0].lower(), row[8]) if key in seen: continue @@ -245,7 +319,6 @@ def get_nova_user_submissions(files_found, report_folder, seeker, wrap_text): "Path", ) - # Compliant HTML injection vulnerability protection handled securely by the framework via DataTables report.write_artifact_data_table( headers, deduped, From fee399edd7bc61087e4351bf8e1e4e101efbda30 Mon Sep 17 00:00:00 2001 From: Guilherme Guilherme Date: Fri, 29 May 2026 17:01:18 +0100 Subject: [PATCH 09/18] Add Shared Prefs Report --- scripts/artifacts/AIChatbotNovaSharedPrefs.py | 242 ++++++++++++++++++ 1 file changed, 242 insertions(+) create mode 100644 scripts/artifacts/AIChatbotNovaSharedPrefs.py diff --git a/scripts/artifacts/AIChatbotNovaSharedPrefs.py b/scripts/artifacts/AIChatbotNovaSharedPrefs.py new file mode 100644 index 00000000..d00a642e --- /dev/null +++ b/scripts/artifacts/AIChatbotNovaSharedPrefs.py @@ -0,0 +1,242 @@ +__artifacts_v2__ = { + "nova_shared_prefs": { + "name": "Shared Preferences", + "description": ( + "Extracts account info, decoded Firebase JWT data, usage metrics, and Adapty payment " + "profile data from ChatAI app preference files, optimized using LAVA-compliant timestamp parsing." + ), + "author": "Guilherme Guilherme", + "version": "1.1", + "date": "2026-05-29", + "requirements": "none", + "category": "AI Chatbot - Nova", + "notes": "Decodes Firebase JWT tokens and parses nested Adapty JSON strings.", + "paths": ( + "*/com.scaleup.chatai/shared_prefs/MOMO_PREF_FILE.xml", + "*/com.scaleup.chatai/shared_prefs/AdaptySDKPrefs.xml", + ), + "function": "get_chat_ai_prefs", + "output_types": "standard", + "artifact_icon": "settings", + } +} + + +import json +import base64 +import xml.etree.ElementTree as ET +from scripts.artifact_report import ArtifactHtmlReport +from scripts.ilapfuncs import logfunc, tsv + + +def decode_jwt(token): + try: + # JWT is Header.Payload.Signature + payload_b64 = token.split(".")[1] + # Add padding if necessary + missing_padding = len(payload_b64) % 4 + if missing_padding: + payload_b64 += "=" * (4 - missing_padding) + decoded = base64.b64decode(payload_b64).decode("utf-8") + return json.loads(decoded) + except Exception: + return None + + +def get_chat_ai_prefs(files_found, report_folder, seeker, wrap_text): + logfunc("Processing data for ChatAI Shared Preferences") + + for file_found in files_found: + file_found = str(file_found) + + if file_found.endswith("MOMO_PREF_FILE.xml"): + try: + tree = ET.parse(file_found) + root = tree.getroot() + except Exception as e: + logfunc(f"[ChatAIPrefs] Error parsing XML file {file_found}: {e}") + continue + + jwt_info = {} + usage_metrics = [] + generic_ids = [] + + for elem in root: + name = elem.get("name") + value = elem.get("value") if elem.get("value") else elem.text + + if not name: + continue + + # Decode Firebase JWT + if name == "KEY_USER_FIREBASE_ID_TOKEN" and value: + decoded = decode_jwt(value) + if decoded: + jwt_info = { + "Email": decoded.get("email"), + "Name": decoded.get("name"), + "Firebase UID": decoded.get("user_id"), + "Sign-in Provider": decoded.get("firebase", {}).get( + "sign_in_provider" + ), + } + + # Identity Keys + elif name in [ + "KEY_USER_AUTHENTICATION_ID", + "KEY_USER_INSTALLATIONS_ID", + "KEY_PLATFORM_ID", + "KEY_FCM_TOKEN", + ]: + generic_ids.append((name, value or "")) + + # Global Metrics + elif name in ["KEY_SUCCESSFULL_CHAT_RESPONSE", "KEY_SESSION_COUNT"]: + usage_metrics.append((name, value or "")) + + # Individual Bot Usage (Generalizing the pattern) + elif ( + "KEY_USER_USAGE_RIGHT_COUNT" in name + or "KEY_USER_USAGE_TOTAL_COUNT" in name + ): + usage_metrics.append((name, value or "")) + + # Report 1: MOMO Account & IDs (Sem timestamp nativo associado às chaves) + if jwt_info or generic_ids: + report_name = "Shared Prefs - Account Identifiers" + report = ArtifactHtmlReport(report_name) + report.start_artifact_report(report_folder, report_name) + report.add_script() + + data_list = [] + for k, v in jwt_info.items(): + data_list.append(("", k, v, "Decoded from JWT")) + for k, v in generic_ids: + data_list.append(("", k, v, "XML Raw Value")) + + headers = ("Timestamp", "Key/Field", "Value", "Source Type") + report.write_artifact_data_table( + headers, data_list, file_found, html_escape=True + ) + report.end_artifact_report() + tsv(report_folder, headers, data_list, report_name, file_found) + + # Report 2: MOMO Usage Metrics + if usage_metrics: + report_name = "Shared Prefs - Usage Metrics" + report = ArtifactHtmlReport(report_name) + report.start_artifact_report(report_folder, report_name) + report.add_script() + + data_list = [("", k, v) for k, v in usage_metrics] + headers = ("Timestamp", "Metric Key", "Value") + + report.write_artifact_data_table( + headers, data_list, file_found, html_escape=True + ) + report.end_artifact_report() + tsv(report_folder, headers, data_list, report_name, file_found) + + elif file_found.endswith("AdaptySDKPrefs.xml"): + try: + tree = ET.parse(file_found) + root = tree.getroot() + except Exception as e: + logfunc(f"[ChatAIPrefs] Error parsing XML file {file_found}: {e}") + continue + + adapty_main = [] + adapty_meta = [] + + for elem in root: + name = elem.get("name") + value = elem.get("value") if elem.get("value") else elem.text + + if not name: + continue + + # Parse Installation Meta JSON + if name == "LAST_SENT_INSTALLATION_META" and value: + try: + meta = json.loads(value) + for k, v in meta.items(): + adapty_meta.append(("", k, str(v))) + except Exception: + pass + + # Parse Profile JSON + if name in ["get_purchaser_info_response", "PROFILE"] and value: + try: + p_data = json.loads(value) + if "data" in p_data: + attrs = p_data["data"].get("attributes", {}) + else: + attrs = p_data + + custom = attrs.get("custom_attributes", {}) + ts = attrs.get("timestamp") or p_data.get("timestamp") + + # Conversão para Float Epoch compatível com LAVA (Segundos) + lava_timestamp = "" + if ts is not None: + try: + lava_timestamp = float(ts) / 1000 + except (ValueError, TypeError): + lava_timestamp = ts + + adapty_main.append( + ( + lava_timestamp, + "Is Test User", + str(attrs.get("is_test_user", "")), + ) + ) + adapty_main.append( + ( + lava_timestamp, + "Old App Instance ID", + str(custom.get("oldAppInstanceId", "")), + ) + ) + adapty_main.append( + ( + lava_timestamp, + "Total Revenue (USD)", + str(attrs.get("total_revenue_usd", "")), + ) + ) + adapty_main.append( + ( + lava_timestamp, + "Paywall Type", + str(custom.get("paywallType", "")), + ) + ) + except Exception: + pass + + if adapty_main: + report_name = "Shared Prefs - Adapty Payment Profile" + report = ArtifactHtmlReport(report_name) + report.start_artifact_report(report_folder, report_name) + report.add_script() + + headers = ("Timestamp", "Attribute", "Value") + report.write_artifact_data_table( + headers, adapty_main, file_found, html_escape=True + ) + report.end_artifact_report() + tsv(report_folder, headers, adapty_main, report_name, file_found) + + if adapty_meta: + report_name = "Shared Prefs - Adapty Device Meta" + report = ArtifactHtmlReport(report_name) + report.start_artifact_report(report_folder, report_name) + report.add_script() + + headers = ("Timestamp", "Meta Key", "Value") + report.write_artifact_data_table( + headers, adapty_meta, file_found, html_escape=True + ) + report.end_artifact_report() + tsv(report_folder, headers, adapty_meta, report_name, file_found) From 2332ad9d4c01698a9c1eed4ccaa1eed32e8e3709 Mon Sep 17 00:00:00 2001 From: Guilherme Guilherme Date: Fri, 29 May 2026 23:52:11 +0100 Subject: [PATCH 10/18] Fix History Modules This artifact module extracts metadata from the Nova AI Chatbot database, which exclusively logs remote cloud reference URLs stored as relative paths to Firebase rather than keeping local physical media files or full file paths on the device. Because there are no on-disk assets to index, calling the built-in check_in_media function is bypassed to avoid empty results. Instead, these relative cloud paths are safely kept intact and explicitly typed as standard "text" columns within the headers. This ensures the cloud metadata is accurately preserved in the final forensic reports as pure text strings, completely eliminating HTML injection risks while maintaining strict, native LAVA-compliant formatting. --- scripts/artifacts/AIChatbotNovaHistory.py | 600 ++++++++++++++---- .../artifacts/AIChatbotNovaHistoryDetail.py | 203 ------ .../AIChatbotNovaHistoryDetailDocument.py | 222 ------- .../AIChatbotNovaHistoryDetailImage.py | 219 ------- .../AIChatbotNovaHistoryDetailLink.py | 296 --------- 5 files changed, 477 insertions(+), 1063 deletions(-) delete mode 100644 scripts/artifacts/AIChatbotNovaHistoryDetail.py delete mode 100644 scripts/artifacts/AIChatbotNovaHistoryDetailDocument.py delete mode 100644 scripts/artifacts/AIChatbotNovaHistoryDetailImage.py delete mode 100644 scripts/artifacts/AIChatbotNovaHistoryDetailLink.py diff --git a/scripts/artifacts/AIChatbotNovaHistory.py b/scripts/artifacts/AIChatbotNovaHistory.py index 3ff37131..8ef7e347 100644 --- a/scripts/artifacts/AIChatbotNovaHistory.py +++ b/scripts/artifacts/AIChatbotNovaHistory.py @@ -1,33 +1,85 @@ __artifacts_v2__ = { "nova_chatbot_history": { - "name": "Conversation History", - "description": ( - "Extracts the conversation index from the AI Chatbot - Nova app " - "(com.scaleup.chatai) from the History table." - ), + "name": "History", + "description": "Extracts conversation index from Nova AI Chatbot", "author": "Guilherme Guilherme", - "version": "1.0", - "date": "2026-05-21", + "version": "6.1", + "date": "2026-05-29", "requirements": "none", "category": "AI Chatbot - Nova", - "notes": "Database: com.scaleup.chatai/databases/chat-ai.db", "paths": ("*/com.scaleup.chatai/databases/chat-ai.db",), "function": "get_nova_chatbot_history", - "output_types": "standard", + "output_types": "all", "artifact_icon": "message-square", - } + }, + "nova_chatbot_history_detail": { + "name": "History Detail", + "description": "Extracts individual messages from Nova AI Chatbot", + "author": "Guilherme Guilherme", + "version": "6.1", + "date": "2026-05-29", + "requirements": "none", + "category": "AI Chatbot - Nova", + "paths": ("*/com.scaleup.chatai/databases/chat-ai.db",), + "function": "get_nova_chatbot_history_detail", + "output_types": "all", + "artifact_icon": "message-circle", + }, + "nova_chatbot_documents": { + "name": "History Detail Documents", + "description": "Extracts document records from Nova AI Chatbot (Firebase URLs)", + "author": "Guilherme Guilherme", + "version": "6.1", + "date": "2026-05-29", + "requirements": "none", + "category": "AI Chatbot - Nova", + "paths": ("*/com.scaleup.chatai/databases/chat-ai.db",), + "function": "get_nova_chatbot_documents", + "output_types": "all", + "artifact_icon": "file-text", + }, + "nova_chatbot_images": { + "name": "History Detail Images", + "description": "Extracts image records from Nova AI Chatbot (Firebase URLs)", + "author": "Guilherme Guilherme", + "version": "6.1", + "date": "2026-05-29", + "requirements": "none", + "category": "AI Chatbot - Nova", + "paths": ("*/com.scaleup.chatai/databases/chat-ai.db",), + "function": "get_nova_chatbot_images", + "output_types": "all", + "artifact_icon": "image", + }, + "nova_chatbot_links": { + "name": "History Detail Links", + "description": "Extracts link records from Nova AI Chatbot", + "author": "Guilherme Guilherme", + "version": "6.1", + "date": "2026-05-29", + "requirements": "none", + "category": "AI Chatbot - Nova", + "paths": ("*/com.scaleup.chatai/databases/chat-ai.db",), + "function": "get_nova_chatbot_links", + "output_types": "all", + "artifact_icon": "link", + }, } -import datetime -from datetime import timezone -from scripts.artifact_report import ArtifactHtmlReport -from scripts.ilapfuncs import logfunc, tsv, timeline, open_sqlite_db_readonly +from scripts.ilapfuncs import ( + artifact_processor, + get_file_path, + get_sqlite_db_records, + logfunc, + open_sqlite_db_readonly, +) -CHAT_BOT_MODEL_MAP = { +# Model mappings +MODEL_MAP = { 0: "ChatGPT 3.5", 1: "GPT-5", 2: "GPT-4o", - 3: "Bard / Image Gen.", + 3: "Bard/Image Gen", 4: "Image Generator", 5: "Vision", 6: "Google Vision", @@ -56,133 +108,435 @@ 29: "GPT-4o Mini", } -QUERY = """ -SELECT - h.id, - h.UUID, - h.title, - h.chatBotModel, - h.assistantId, - h.captionHistoryId, - h.starred, - h.softDeleted, - h.syncState, - h.syncRetryCount, - h.createdAt, - h.updatedAt, - h.lastModifiedAt, - COUNT(hd.id), - MAX(hd.createdAt), - MIN(CASE WHEN hd.type = 0 THEN hd.text END) -FROM History h -LEFT JOIN HistoryDetail hd ON hd.historyID = h.id -GROUP BY h.id -ORDER BY h.createdAt ASC -""" - - -def _convert_ms_timestamp(ms): - """Safely converts Unix millisecond timestamp using modern timezone.utc.""" - if ms is None: +ASSISTANT_MAP = { + 1: "Margot Robbie", + 2: "Elon Musk", + 3: "Snoop Dogg", + 4: "Steve Jobs", + 5: "LeBron James", + 6: "Zendaya", + 7: "Steve Harvey", + 8: "Botanist", + 9: "Veterinarian", + 10: "Dietitian", + 11: "Accountant", + 12: "Architect", + 13: "Artist", + 14: "Chef", + 15: "Designer", + 16: "Software Developer", + 17: "Doctor", + 18: "Influencer", + 19: "Journalist", + 20: "Lawyer", + 21: "Math Teacher", + 22: "Personal Trainer", + 23: "Pilot", + 24: "Scientist", + 25: "Writer Assistant", + 26: "Taylor Swift", + 27: "Dermatologist", + 28: "Astrologer", + 29: "Fashion Designer", + 30: "Phoebe Buffay", + 31: "Thomas Shelby", + 32: "Barney Stinson", + 33: "Dwight Schrute", + 34: "Sub-Zero", + 35: "Pikachu", + 36: "Super Mario", + 37: "Hello Kitty", + 38: "Doctor Who", + 39: "Chandler Bing", + 40: "Michael Scott", + 41: "Walter White", + 42: "The Grinch", + 43: "Santa Claus", + 44: "Loki", + 45: "Dr. House", + 46: "Relationship Doctor", + 47: "Kylie Jenner", + 58: "Prophecy", +} + +IMAGE_STATE_MAP = {0: "Pending", 1: "Success", 2: "Failed"} + + +def get_model(model_id): + if model_id is None: + return "Unknown" + name = MODEL_MAP.get(model_id) + return f"{name} ({model_id})" if name else f"Unknown Model ({model_id})" + + +def get_assistant(assistant_id): + if not assistant_id: return "" try: - return datetime.datetime.fromtimestamp(float(ms) / 1000, timezone.utc).strftime( - "%Y-%m-%d %H:%M:%S UTC" + name = ASSISTANT_MAP.get(int(assistant_id)) + return ( + f"{name} ({assistant_id})" if name else f"Unknown Persona ({assistant_id})" ) - except (OSError, OverflowError, ValueError): - return str(ms) + except (TypeError, ValueError): + return str(assistant_id) + + +def format_size(bytes_val): + if not bytes_val: + return "" + try: + b = int(bytes_val) + if b < 1024: + return f"{b} B" + if b < 1048576: + return f"{b / 1024:.1f} KB" + return f"{b / 1048576:.1f} MB" + except (ValueError, TypeError): + return str(bytes_val) + + +def get_role(role_int): + if role_int == 0: + return "USER" + if role_int == 1: + return "ASSISTANT" + return f"UNKNOWN ({role_int})" +@artifact_processor def get_nova_chatbot_history(files_found, report_folder, seeker, wrap_text): - logfunc("Processing data for Conversation History") + db_path = get_file_path(files_found, "chat-ai.db") + if not db_path: + return (), [], "" - # Clean the file list of any SQLite journal artifacts - files_found = [ - x for x in files_found if not x.endswith(("-journal", "-wal", "-shm")) - ] - file_found = next((str(x) for x in files_found if "chat-ai.db" in str(x)), None) + query = """ + SELECT h.id, h.UUID, h.title, h.chatBotModel, h.assistantId, + h.captionHistoryId, h.starred, h.softDeleted, h.syncState, + h.syncRetryCount, h.createdAt, h.updatedAt, h.lastModifiedAt, + COUNT(hd.id), MAX(hd.createdAt), + MIN(CASE WHEN hd.type = 0 THEN hd.text END) + FROM History h + LEFT JOIN HistoryDetail hd ON hd.historyID = h.id + GROUP BY h.id + ORDER BY h.createdAt ASC + """ - if not file_found: - logfunc("[nova_chatbot_history] Nova database file not found.") - return + data_list = [] + for row in get_sqlite_db_records(db_path, query): + data_list.append( + ( + row[0], # id + row[1] or "", # UUID + row[2] or "", # title + get_model(row[3]), # chatBotModel + get_assistant(row[4]), # assistantId + row[5] or "", # captionHistoryId + "Yes" if row[6] else "No", # starred + "Yes" if row[7] else "No", # softDeleted + row[8] or "", # syncState + row[9] or 0, # syncRetryCount + row[10], # createdAt (Raw Epoch) + row[11], # updatedAt (Raw Epoch) + row[12], # lastModifiedAt (Raw Epoch) + row[13] or 0, # message count + row[14], # last message at (Raw Epoch) + row[15] or "", # first user message + ) + ) + + headers = ( + ("Conv ID", "text"), + ("UUID", "text"), + ("Title", "text"), + ("AI Model", "text"), + ("Assistant", "text"), + ("Caption History ID", "text"), + ("Starred", "text"), + ("Soft Deleted", "text"), + ("Sync State", "text"), + ("Sync Retry Count", "text"), + ("Created At", "date time"), + ("Updated At", "date time"), + ("Last Modified At", "date time"), + ("Message Count", "text"), + ("Last Message At", "date time"), + ("First User Message", "text"), + ) + + return headers, data_list, db_path - try: - db = open_sqlite_db_readonly(file_found) - cursor = db.cursor() - cursor.execute(QUERY) - rows_raw = cursor.fetchall() - db.close() - except Exception as e: - logfunc(f"[nova_chatbot_history] Error reading {file_found}: {e}") - return - if not rows_raw: - logfunc(f"[nova_chatbot_history] No records found in {file_found}.") - return +@artifact_processor +def get_nova_chatbot_history_detail(files_found, report_folder, seeker, wrap_text): + db_path = get_file_path(files_found, "chat-ai.db") + if not db_path: + return (), [], "" + + query = """ + SELECT hd.id, hd.UUID, hd.historyID, h.UUID, h.title, h.chatBotModel, + h.assistantId, h.softDeleted, hd.type, hd.text, hd.token, + hd.reasoningContent, hd.createdAt, hd.lastModifiedAt, + hd.syncState, hd.syncRetryCount, + EXISTS(SELECT 1 FROM HistoryDetailImage WHERE historyDetailID = hd.id), + EXISTS(SELECT 1 FROM HistoryDetailDocument WHERE historyDetailID = hd.id), + EXISTS(SELECT 1 FROM HistoryDetailLink WHERE historyDetailID = hd.id) + FROM HistoryDetail hd + INNER JOIN History h ON h.id = hd.historyID + ORDER BY hd.historyID ASC, hd.createdAt ASC + """ + + data_list = [] + for row in get_sqlite_db_records(db_path, query): + data_list.append( + ( + row[0], # id + row[1] or "", # UUID + row[2], # historyID + row[3] or "", # conversation UUID + row[4] or "", # conversation title + get_model(row[5]), # chatBotModel + get_assistant(row[6]), # assistantId + "Yes" if row[7] else "No", # softDeleted + get_role(row[8]), # type + row[9] or "", # text + row[10] or "", # token + row[11] or "", # reasoningContent + row[12], # createdAt (Raw Epoch) + row[13], # lastModifiedAt (Raw Epoch) + row[14] or "", # syncState + row[15] or 0, # syncRetryCount + "Yes" if row[16] else "No", # has image + "Yes" if row[17] else "No", # has document + "Yes" if row[18] else "No", # has link + ) + ) headers = ( - "Conv. ID", - "Conv. UUID", - "Title", - "AI Model", - "Assistant ID", - "Caption History ID", - "Starred", - "Soft Deleted", - "Sync State", - "Sync Retry Count", - "Created At (UTC)", - "Updated At (UTC)", - "Last Modified At (UTC)", - "Message Count", - "Last Message At (UTC)", - "First User Message", + ("Msg ID", "text"), + ("Msg UUID", "text"), + ("Conv ID", "text"), + ("Conv UUID", "text"), + ("Conv Title", "text"), + ("AI Model", "text"), + ("Assistant Persona", "text"), + ("Conv Deleted", "text"), + ("Role", "text"), + ("Message Text", "text"), + ("Token Count", "text"), + ("Reasoning Content", "text"), + ("Message Timestamp", "date time"), + ("Last Modified At", "date time"), + ("Sync State", "text"), + ("Sync Retry Count", "text"), + ("Has Image", "text"), + ("Has Document", "text"), + ("Has Link", "text"), ) - rows = [] - for row in rows_raw: - model_int = row[3] - model_name = "Unknown" - if model_int is not None: - name_lookup = CHAT_BOT_MODEL_MAP.get(model_int) - model_name = ( - f"{name_lookup} ({model_int})" - if name_lookup - else f"Unknown Model ({model_int})" + return headers, data_list, db_path + + +@artifact_processor +def get_nova_chatbot_documents(files_found, report_folder, seeker, wrap_text): + db_path = get_file_path(files_found, "chat-ai.db") + if not db_path: + return (), [], "" + + query = """ + SELECT d.id, d.historyDetailID, d.url, d.name, d.type, d.size, d.mimeType, + hd.historyID, hd.type, hd.text, hd.createdAt, h.UUID, h.title, + h.chatBotModel, h.assistantId, h.softDeleted + FROM HistoryDetailDocument d + INNER JOIN HistoryDetail hd ON hd.id = d.historyDetailID + INNER JOIN History h ON h.id = hd.historyID + ORDER BY d.id ASC + """ + + data_list = [] + for row in get_sqlite_db_records(db_path, query): + doc_type = ( + "Local File" + if row[4] == 0 + else "Remote File" + if row[4] == 1 + else f"Unknown ({row[4]})" + ) + + data_list.append( + ( + row[0], # id + row[1], # historyDetailID + row[7], # historyID + row[11] or "", # conversation UUID + row[12] or "", # conversation title + get_model(row[13]), # chatBotModel + get_assistant(row[14]), # assistantId + "Yes" if row[15] else "No", # softDeleted + get_role(row[8]), # submitted by + row[9] or "", # message text + row[10], # createdAt (Raw Epoch) + row[3] or "Unknown", # file name + row[6] or "", # mimeType + format_size(row[5]), # size + doc_type, # source type + row[2] or "", # Firebase URL string output safely as text ) + ) + + headers = ( + ("Doc ID", "text"), + ("Msg ID", "text"), + ("Conv ID", "text"), + ("Conv UUID", "text"), + ("Conv Title", "text"), + ("AI Model", "text"), + ("Assistant Persona", "text"), + ("Conv Deleted", "text"), + ("Submitted By", "text"), + ("Msg Text", "text"), + ("Msg Timestamp", "date time"), + ("File Name", "text"), + ("MIME Type", "text"), + ("Size", "text"), + ("Source Type", "text"), + ( + "Cloud Storage URL", + "text", + ), # Kept as text to natively display remote paths safely + ) + + return headers, data_list, db_path + - rows.append( +@artifact_processor +def get_nova_chatbot_images(files_found, report_folder, seeker, wrap_text): + db_path = get_file_path(files_found, "chat-ai.db") + if not db_path: + return (), [], "" + + query = """ + SELECT i.id, i.historyDetailID, i.url, i.prompt, i.state, i.mimeType, + i.styleId, i.pipeline, hd.historyID, hd.type, hd.text, + hd.createdAt, h.UUID, h.title, h.chatBotModel, + h.assistantId, h.softDeleted + FROM HistoryDetailImage i + INNER JOIN HistoryDetail hd ON hd.id = i.historyDetailID + INNER JOIN History h ON h.id = hd.historyID + ORDER BY i.id ASC + """ + + data_list = [] + for row in get_sqlite_db_records(db_path, query): + state = ( + IMAGE_STATE_MAP.get(row[4], f"Unknown ({row[4]})") + if row[4] is not None + else "" + ) + + data_list.append( ( - row[0], - row[1] or "", - row[2] or "", - model_name, - row[4] if row[4] is not None else "", - row[5] or "", - "Yes" if row[6] else "No", - "DELETED" if row[7] == 1 else "No", - row[8] if row[8] is not None else "", - row[9] if row[9] is not None else "", - _convert_ms_timestamp(row[10]), - _convert_ms_timestamp(row[11]), - _convert_ms_timestamp(row[12]), - row[13] if row[13] is not None else 0, - _convert_ms_timestamp(row[14]), - row[15] or "", + row[0], # id + row[1], # historyDetailID + row[8], # historyID + row[12] or "", # conversation UUID + row[13] or "", # conversation title + get_model(row[14]), # chatBotModel + get_assistant(row[15]), # assistantId + "Yes" if row[16] else "No", # softDeleted + get_role(row[9]), # submitted by + row[10] or "", # message text + row[11], # createdAt (Raw Epoch) + row[3] or "", # prompt + state, # state + row[7] or "", # pipeline + row[6] or "", # styleId + row[5] or "", # mimeType + row[2] or "", # Firebase URL string output safely as text ) ) - report_name = "History" - report = ArtifactHtmlReport(report_name) - report.start_artifact_report(report_folder, report_name) - report.add_script() + headers = ( + ("Image ID", "text"), + ("Msg ID", "text"), + ("Conv ID", "text"), + ("Conv UUID", "text"), + ("Conv Title", "text"), + ("AI Model", "text"), + ("Assistant Persona", "text"), + ("Conv Deleted", "text"), + ("Submitted By", "text"), + ("Msg Text", "text"), + ("Msg Timestamp", "date time"), + ("Prompt", "text"), + ("State", "text"), + ("Pipeline", "text"), + ("Style ID", "text"), + ("MIME Type", "text"), + ( + "Cloud Storage URL", + "text", + ), # Kept as text to natively display remote paths safely + ) + + return headers, data_list, db_path + + +@artifact_processor +def get_nova_chatbot_links(files_found, report_folder, seeker, wrap_text): + db_path = get_file_path(files_found, "chat-ai.db") + if not db_path: + return (), [], "" + + with open_sqlite_db_readonly(db_path) as db: + cursor = db.cursor() + cursor.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='HistoryDetailLink'" + ) + if not cursor.fetchone(): + logfunc("HistoryDetailLink table not found in Nova database") + return (), [], "" + + query = """ + SELECT l.id, l.historyDetailID, l.url, hd.historyID, hd.type, hd.text, + hd.createdAt, h.UUID, h.title, h.chatBotModel, h.assistantId, h.softDeleted + FROM HistoryDetailLink l + INNER JOIN HistoryDetail hd ON hd.id = l.historyDetailID + INNER JOIN History h ON h.id = hd.historyID + ORDER BY l.id ASC + """ - # html_escape=True hands escaping entirely to the framework backend safely - report.write_artifact_data_table(headers, rows, file_found, html_escape=True) - report.end_artifact_report() + data_list = [] + for row in get_sqlite_db_records(db_path, query): + data_list.append( + ( + row[0], # id + row[1], # historyDetailID + row[3], # historyID + row[7] or "", # conversation UUID + row[8] or "", # conversation title + get_model(row[9]), # chatBotModel + get_assistant(row[10]), # assistantId + "Yes" if row[11] else "No", # softDeleted + get_role(row[4]), # msg role + row[5] or "", # message text + row[6], # createdAt (Raw Epoch) + row[2] or "", # URL string output safely as text + ) + ) - tsv(report_folder, headers, rows, report_name, file_found) - timeline(report_folder, report_name, rows, headers) - logfunc( - f"[nova_chatbot_history] Processed {len(rows)} conversation history records." + headers = ( + ("Link ID", "text"), + ("Msg ID", "text"), + ("Conv ID", "text"), + ("Conv UUID", "text"), + ("Conv Title", "text"), + ("AI Model", "text"), + ("Assistant Persona", "text"), + ("Conv Deleted", "text"), + ("Msg Role", "text"), + ("Msg Text", "text"), + ("Msg Timestamp", "date time"), + ("URL", "text"), # Kept as text to natively display remote paths safely ) + + return headers, data_list, db_path diff --git a/scripts/artifacts/AIChatbotNovaHistoryDetail.py b/scripts/artifacts/AIChatbotNovaHistoryDetail.py deleted file mode 100644 index f146b0a1..00000000 --- a/scripts/artifacts/AIChatbotNovaHistoryDetail.py +++ /dev/null @@ -1,203 +0,0 @@ -__artifacts_v2__ = { - "nova_chatbot_history_detail": { - "name": "HistoryDetail", - "description": ( - "Extracts every individual message from the AI Chatbot - Nova app " - "(com.scaleup.chatai) from the HistoryDetail table, enriched with parent " - "conversation context and attachment existence flags." - ), - "author": "Guilherme Guilherme", - "version": "1.0", - "date": "2026-05-21", - "requirements": "none", - "category": "AI Chatbot - Nova", - "notes": "Database: com.scaleup.chatai/databases/chat-ai.db", - "paths": ("*/com.scaleup.chatai/databases/chat-ai.db",), - "function": "get_nova_chatbot_history_detail", - "output_types": "standard", - "artifact_icon": "message-circle", - } -} - -import datetime -from datetime import timezone -from scripts.artifact_report import ArtifactHtmlReport -from scripts.ilapfuncs import logfunc, tsv, timeline, open_sqlite_db_readonly - -CHAT_BOT_MODEL_MAP = { - 0: "ChatGPT 3.5", - 1: "GPT-5", - 2: "GPT-4o", - 3: "Bard / Image Gen.", - 4: "Image Generator", - 5: "Vision", - 6: "Google Vision", - 7: "Document", - 8: "LLaMA 2", - 9: "Nova", - 10: "Gemini", - 11: "Superbot", - 12: "Logo Generator", - 13: "Tattoo Generator", - 14: "Web Search", - 15: "Claude", - 16: "DeepSeek", - 17: "Signature Generator", - 18: "Mistral", - 19: "Grok", - 20: "DeepSeek R1", - 21: "AI Filter", - 22: "Voice Chat", - 23: "Snap & Solve", - 24: "Study Planner", - 25: "Quiz Maker", - 26: "Essay Helper", - 27: "Gemini 3 Pro", - 28: "GPT-5.1", - 29: "GPT-4o Mini", -} - -QUERY = """ -SELECT - hd.id, - hd.UUID, - hd.historyID, - h.UUID, - h.title, - h.chatBotModel, - h.softDeleted, - hd.type, - hd.text, - hd.token, - hd.reasoningContent, - hd.createdAt, - hd.lastModifiedAt, - hd.syncState, - hd.syncRetryCount, - EXISTS(SELECT 1 FROM HistoryDetailImage i WHERE i.historyDetailID = hd.id), - EXISTS(SELECT 1 FROM HistoryDetailDocument d WHERE d.historyDetailID = hd.id), - EXISTS(SELECT 1 FROM HistoryDetailLink l WHERE l.historyDetailID = hd.id) -FROM HistoryDetail hd -INNER JOIN History h ON h.id = hd.historyID -ORDER BY hd.historyID ASC, hd.createdAt ASC -""" - - -def _convert_ms_timestamp(ms): - """Safely converts Unix millisecond timestamp using modern timezone.utc.""" - if ms is None: - return "" - try: - return datetime.datetime.fromtimestamp(float(ms) / 1000, timezone.utc).strftime( - "%Y-%m-%d %H:%M:%S UTC" - ) - except (OSError, OverflowError, ValueError): - return str(ms) - - -def get_nova_chatbot_history_detail(files_found, report_folder, seeker, wrap_text): - logfunc("Processing data for HistoryDetail") - - # Filter out secondary transactional database engines - files_found = [ - x for x in files_found if not x.endswith(("-journal", "-wal", "-shm")) - ] - file_found = next((str(x) for x in files_found if "chat-ai.db" in str(x)), None) - - if not file_found: - logfunc("[nova_chatbot_history_detail] Nova database file not found.") - return - - try: - db = open_sqlite_db_readonly(file_found) - cursor = db.cursor() - cursor.execute(QUERY) - rows_raw = cursor.fetchall() - db.close() - except Exception as e: - logfunc(f"[nova_chatbot_history_detail] Error reading {file_found}: {e}") - return - - if not rows_raw: - logfunc(f"[nova_chatbot_history_detail] No records found in {file_found}.") - return - - headers = ( - "Msg. ID", - "Msg. UUID", - "Conv. ID", - "Conv. UUID", - "Conv. Title", - "AI Model", - "Conv. Deleted", - "Role", - "Message Text", - "Token Count", - "Reasoning Content", - "Message Timestamp (UTC)", - "Last Modified At (UTC)", - "Sync State", - "Sync Retry Count", - "Has Image", - "Has Document", - "Has Link", - ) - - rows = [] - for row in rows_raw: - model_int = row[5] - model_name = "Unknown" - if model_int is not None: - name_lookup = CHAT_BOT_MODEL_MAP.get(model_int) - model_name = ( - f"{name_lookup} ({model_int})" - if name_lookup - else f"Unknown Model ({model_int})" - ) - - role_int = row[7] - role_label = ( - "USER" - if role_int == 0 - else "ASSISTANT" - if role_int == 1 - else f"UNKNOWN ({role_int})" - ) - - rows.append( - ( - row[0], - row[1] or "", - row[2], - row[3] or "", - row[4] or "", - model_name, - "DELETED" if row[6] == 1 else "No", - role_label, - row[8] or "", - row[9] if row[9] is not None else "", - row[10] or "", - _convert_ms_timestamp(row[11]), - _convert_ms_timestamp(row[12]), - row[13] if row[13] is not None else "", - row[14] if row[14] is not None else "", - "Yes" if row[15] else "No", - "Yes" if row[16] else "No", - "Yes" if row[17] else "No", - ) - ) - - report_name = "HistoryDetail" - report = ArtifactHtmlReport(report_name) - report.start_artifact_report(report_folder, report_name) - report.add_script() - - # Delegates script-side sanitization directly to framework tables safely - report.write_artifact_data_table(headers, rows, file_found, html_escape=True) - report.end_artifact_report() - - tsv(report_folder, headers, rows, report_name, file_found) - timeline(report_folder, report_name, rows, headers) - logfunc( - f"[nova_chatbot_history_detail] Processed {len(rows)} message detail records." - ) diff --git a/scripts/artifacts/AIChatbotNovaHistoryDetailDocument.py b/scripts/artifacts/AIChatbotNovaHistoryDetailDocument.py deleted file mode 100644 index 91d4cf5b..00000000 --- a/scripts/artifacts/AIChatbotNovaHistoryDetailDocument.py +++ /dev/null @@ -1,222 +0,0 @@ -__artifacts_v2__ = { - "nova_chatbot_documents": { - "name": "HistoryDetailDocuments", - "description": ( - "Extracts document records submitted by the user to the AI from the " - "HistoryDetailDocument table, enriched with parent message and conversation context." - ), - "author": "Guilherme Guilherme", - "version": "1.0", - "date": "2026-05-21", - "requirements": "none", - "category": "AI Chatbot - Nova", - "notes": "Database: com.scaleup.chatai/databases/chat-ai.db", - "paths": ("*/com.scaleup.chatai/databases/chat-ai.db",), - "function": "get_nova_chatbot_documents", - "output_types": "standard", - "artifact_icon": "file-text", - } -} - -import datetime -from datetime import timezone -from scripts.artifact_report import ArtifactHtmlReport -from scripts.ilapfuncs import logfunc, tsv, timeline, open_sqlite_db_readonly - -CHAT_BOT_MODEL_MAP = { - 0: "ChatGPT 3.5", - 1: "GPT-5", - 2: "GPT-4o", - 3: "Bard / Image Gen.", - 4: "Image Generator", - 5: "Vision", - 6: "Google Vision", - 7: "Document", - 8: "LLaMA 2", - 9: "Nova", - 10: "Gemini", - 11: "Superbot", - 12: "Logo Generator", - 13: "Tattoo Generator", - 14: "Web Search", - 15: "Claude", - 16: "DeepSeek", - 17: "Signature Generator", - 18: "Mistral", - 19: "Grok", - 20: "DeepSeek R1", - 21: "AI Filter", - 22: "Voice Chat", - 23: "Snap & Solve", - 24: "Study Planner", - 25: "Quiz Maker", - 26: "Essay Helper", - 27: "Gemini 3 Pro", - 28: "GPT-5.1", - 29: "GPT-4o Mini", -} - -QUERY = """ -SELECT - d.id, - d.historyDetailID, - d.url, - d.name, - d.type, - d.size, - d.mimeType, - hd.historyID, - hd.type, - hd.text, - hd.createdAt, - h.UUID, - h.title, - h.chatBotModel, - h.softDeleted -FROM HistoryDetailDocument d -INNER JOIN HistoryDetail hd ON hd.id = d.historyDetailID -INNER JOIN History h ON h.id = hd.historyID -ORDER BY d.id ASC -""" - - -def _convert_ms_timestamp(ms): - """Safely converts Unix millisecond timestamp using modern timezone.utc.""" - if ms is None: - return "" - try: - return datetime.datetime.fromtimestamp(float(ms) / 1000, timezone.utc).strftime( - "%Y-%m-%d %H:%M:%S UTC" - ) - except (OSError, OverflowError, ValueError): - return str(ms) - - -def _format_file_size(size_bytes): - if size_bytes is None: - return "" - try: - size_bytes = int(size_bytes) - if size_bytes < 1024: - return f"{size_bytes} B" - elif size_bytes < 1024**2: - return f"{size_bytes / 1024:.1f} KB" - else: - return f"{size_bytes / (1024**2):.1f} MB" - except (ValueError, TypeError): - return str(size_bytes) - - -def get_nova_chatbot_documents(files_found, report_folder, seeker, wrap_text): - logfunc("Processing data for HistoryDetail Submitted Documents") - - # Filter out secondary transactional database engines - files_found = [ - x for x in files_found if not x.endswith(("-journal", "-wal", "-shm")) - ] - file_found = next((str(x) for x in files_found if "chat-ai.db" in str(x)), None) - - if not file_found: - logfunc("[nova_chatbot_documents] Nova database file not found.") - return - - try: - db = open_sqlite_db_readonly(file_found) - cursor = db.cursor() - cursor.execute(QUERY) - rows_raw = cursor.fetchall() - db.close() - except Exception as e: - logfunc(f"[nova_chatbot_documents] Error reading {file_found}: {e}") - return - - if not rows_raw: - logfunc(f"[nova_chatbot_documents] No document records found in {file_found}.") - return - - headers = ( - "Doc. ID", - "Msg. ID", - "Conv. ID", - "Conv. UUID", - "Conv. Title", - "AI Model", - "Conv. Deleted", - "Media Submitted By", - "Msg. Text", - "Msg. Timestamp (UTC)", - "File Name", - "MIME Type", - "Size", - "Source Type", - "Firebase Storage Path", - "Forensic Notes", - ) - - rows = [] - for row in rows_raw: - model_int = row[13] - model_name = "Unknown" - if model_int is not None: - name_lookup = CHAT_BOT_MODEL_MAP.get(model_int) - model_name = ( - f"{name_lookup} ({model_int})" - if name_lookup - else f"Unknown Model ({model_int})" - ) - - doc_type_int = row[4] - doc_type_label = ( - "Local File" - if doc_type_int == 0 - else "Remote File" - if doc_type_int == 1 - else f"Unknown ({doc_type_int})" - ) - - raw_role = row[8] - if raw_role == 0: - submitted_by = "USER" - forensic_note = "Media element actively selected and submitted by the user to the chatbot interface." - elif raw_role == 1: - submitted_by = "AI ASSISTANT" - forensic_note = "Media element generated or provided back by the AI response." - else: - submitted_by = f"UNKNOWN ({raw_role})" - forensic_note = "Unknown structural context for media origin." - - rows.append( - ( - row[0], - row[1], - row[7], - row[11] or "", - row[12] or "", - model_name, - "DELETED" if row[14] == 1 else "No", - submitted_by, - row[9] or "", - _convert_ms_timestamp(row[10]), - row[3] or "Unknown", - row[6] or "", - _format_file_size(row[5]), - doc_type_label, - row[2] or "", - forensic_note, - ) - ) - - report_name = "HistoryDetailDocuments" - report = ArtifactHtmlReport(report_name) - report.start_artifact_report(report_folder, report_name) - report.add_script() - - # Enforce safe framework-side text escaping to block script injection vectors entirely - report.write_artifact_data_table(headers, rows, file_found, html_escape=True) - report.end_artifact_report() - - tsv(report_folder, headers, rows, report_name, file_found) - timeline(report_folder, report_name, rows, headers) - logfunc( - f"[nova_chatbot_documents] Processed {len(rows)} submitted document records." - ) \ No newline at end of file diff --git a/scripts/artifacts/AIChatbotNovaHistoryDetailImage.py b/scripts/artifacts/AIChatbotNovaHistoryDetailImage.py deleted file mode 100644 index 6991de63..00000000 --- a/scripts/artifacts/AIChatbotNovaHistoryDetailImage.py +++ /dev/null @@ -1,219 +0,0 @@ -__artifacts_v2__ = { - "nova_chatbot_images": { - "name": "HistoryDetailImages", - "description": ( - "Extracts user-submitted image links and AI-generated image records from the " - "HistoryDetailImage table, enriched with parent message and conversation context." - ), - "author": "Guilherme Guilherme", - "version": "1.0", - "date": "2026-05-21", - "requirements": "none", - "category": "AI Chatbot - Nova", - "notes": "Database: com.scaleup.chatai/databases/chat-ai.db", - "paths": ("*/com.scaleup.chatai/databases/chat-ai.db",), - "function": "get_nova_chatbot_images", - "output_types": "standard", - "artifact_icon": "image", - } -} - -import datetime -from datetime import timezone -from scripts.artifact_report import ArtifactHtmlReport -from scripts.ilapfuncs import logfunc, tsv, timeline, open_sqlite_db_readonly - -CHAT_BOT_MODEL_MAP = { - 0: "ChatGPT 3.5", - 1: "GPT-5", - 2: "GPT-4o", - 3: "Bard / Image Gen.", - 4: "Image Generator", - 5: "Vision", - 6: "Google Vision", - 7: "Document", - 8: "LLaMA 2", - 9: "Nova", - 10: "Gemini", - 11: "Superbot", - 12: "Logo Generator", - 13: "Tattoo Generator", - 14: "Web Search", - 15: "Claude", - 16: "DeepSeek", - 17: "Signature Generator", - 18: "Mistral", - 19: "Grok", - 20: "DeepSeek R1", - 21: "AI Filter", - 22: "Voice Chat", - 23: "Snap & Solve", - 24: "Study Planner", - 25: "Quiz Maker", - 26: "Essay Helper", - 27: "Gemini 3 Pro", - 28: "GPT-5.1", - 29: "GPT-4o Mini", -} - -IMAGE_STATE_MAP = { - 0: "Pending", - 1: "Success", - 2: "Failed", -} - -QUERY = """ -SELECT - i.id, - i.historyDetailID, - i.url, - i.prompt, - i.state, - i.mimeType, - i.styleId, - i.pipeline, - hd.historyID, - hd.type, - hd.text, - hd.createdAt, - h.UUID, - h.title, - h.chatBotModel, - h.softDeleted -FROM HistoryDetailImage i -INNER JOIN HistoryDetail hd ON hd.id = i.historyDetailID -INNER JOIN History h ON h.id = hd.historyID -ORDER BY i.id ASC -""" - - -def _convert_ms_timestamp(ms): - """Safely converts Unix millisecond timestamp using modern timezone.utc.""" - if ms is None: - return "" - try: - return datetime.datetime.fromtimestamp(float(ms) / 1000, timezone.utc).strftime( - "%Y-%m-%d %H:%M:%S UTC" - ) - except (OSError, OverflowError, ValueError): - return str(ms) - - -def get_nova_chatbot_images(files_found, report_folder, seeker, wrap_text): - logfunc("Processing data for HistoryDetail Images") - - # Filter out secondary transactional database engines - files_found = [ - x for x in files_found if not x.endswith(("-journal", "-wal", "-shm")) - ] - file_found = next((str(x) for x in files_found if "chat-ai.db" in str(x)), None) - - if not file_found: - logfunc("[nova_chatbot_images] Nova database file not found.") - return - - try: - db = open_sqlite_db_readonly(file_found) - cursor = db.cursor() - cursor.execute(QUERY) - rows_raw = cursor.fetchall() - db.close() - except Exception as e: - logfunc(f"[nova_chatbot_images] Error reading {file_found}: {e}") - return - - if not rows_raw: - logfunc(f"[nova_chatbot_images] No image records found in {file_found}.") - return - - headers = ( - "Image ID", - "Msg. ID", - "Conv. ID", - "Conv. UUID", - "Conv. Title", - "AI Model", - "Conv. Deleted", - "Media Submitted By", - "Msg. Text", - "Msg. Timestamp (UTC)", - "Prompt", - "State", - "Pipeline", - "Style ID", - "MIME Type", - "Firebase Storage Path", - "Forensic Notes", - ) - - rows = [] - for row in rows_raw: - model_int = row[14] - model_name = "Unknown" - if model_int is not None: - name_lookup = CHAT_BOT_MODEL_MAP.get(model_int) - model_name = ( - f"{name_lookup} ({model_int})" - if name_lookup - else f"Unknown Model ({model_int})" - ) - - state_int = row[4] - state_label = "" - if state_int is not None: - name_state = IMAGE_STATE_MAP.get(state_int) - state_label = f"{name_state} ({state_int})" if name_state else f"Unknown ({state_int})" - - raw_role = row[9] - if raw_role == 0: - submitted_by = "USER" - forensic_note = ( - "Media element actively selected and submitted by the user to the chatbot interface. " - "The file content resides remotely on Firebase Storage and is not locally cached." - ) - elif raw_role == 1: - submitted_by = "AI ASSISTANT" - forensic_note = ( - "Media element generated or provided back by the AI response. " - "Stored on Firebase Storage; temporary local copies might be found in cache/image_manager_disk_cache/." - ) - else: - submitted_by = f"UNKNOWN ({raw_role})" - forensic_note = "Unknown structural context for media origin." - - rows.append( - ( - row[0], - row[1], - row[8], - row[12] or "", - row[13] or "", - model_name, - "DELETED" if row[15] == 1 else "No", - submitted_by, - row[10] or "", - _convert_ms_timestamp(row[11]), - row[3] or "", - state_label, - row[7] or "", - row[6] if row[6] is not None else "", - row[5] or "", - row[2] or "", - forensic_note, - ) - ) - - report_name = "HistoryDetailImages" - report = ArtifactHtmlReport(report_name) - report.start_artifact_report(report_folder, report_name) - report.add_script() - - # Enforce safe framework-side text escaping to block script injection vectors entirely - report.write_artifact_data_table(headers, rows, file_found, html_escape=True) - report.end_artifact_report() - - tsv(report_folder, headers, rows, report_name, file_found) - timeline(report_folder, report_name, rows, headers) - logfunc( - f"[nova_chatbot_images] Processed {len(rows)} submitted image records." - ) diff --git a/scripts/artifacts/AIChatbotNovaHistoryDetailLink.py b/scripts/artifacts/AIChatbotNovaHistoryDetailLink.py deleted file mode 100644 index a88ef782..00000000 --- a/scripts/artifacts/AIChatbotNovaHistoryDetailLink.py +++ /dev/null @@ -1,296 +0,0 @@ -__artifacts_v2__ = { - "nova_chatbot_links": { - "name": "Shared Links", - "description": ( - "Extracts all link records from the AI Chatbot - Nova app " - "(HistoryDetailLink table). Each row represents one URL shared within " - "a conversation and is enriched with parent message context from " - "HistoryDetail and parent conversation context from History, including " - "the message text that accompanied the link, the role of the sender " - "(USER or ASSISTANT), the AI model used in the conversation, and the " - "soft-deleted status of the parent conversation. " - "Links are rendered as clickable anchors in the HTML report. " - "The table is currently empty in observed samples but the module is " - "future-proof and will extract records if the table is populated in " - "other device images or application versions." - ), - "author": "Guilherme Guilherme", - "version": "0.2", - "date": "2025-04-27", - "requirements": "none", - "category": "AI Chatbot - Nova", - "notes": ( - "Database: com.scaleup.chatai/databases/chat-ai.db. " - "HistoryDetailLink.url stores the full URL shared in the message. " - "Links may be shared by the USER (e.g. a webpage submitted for AI " - "analysis) or by the ASSISTANT (e.g. a reference link in a response). " - "The role of the message is determined by HistoryDetail.type: " - "0 = USER, 1 = ASSISTANT. " - "softDeleted is inherited from the parent History record; DELETED means " - "the conversation was removed by the user but the link record remains " - "physically in the database and is forensically recoverable. " - "If this report contains no rows the HistoryDetailLink table was empty " - "in the examined database — this is normal for the current app version." - ), - "paths": ("*/com.scaleup.chatai/databases/chat-ai.db",), - "function": "get_nova_chatbot_links", - } -} - -import sqlite3 -import datetime -import html as html_module -from scripts.artifact_report import ArtifactHtmlReport -import scripts.ilapfuncs - -# --------------------------------------------------------------------------- -# Known mappings for the chatBotModel integer field. -# Source: FirestoreHistory.EngineTypes enum ordinals from decompiled APK source -# (com.scaleup.chatai.ui.conversation.FirestoreHistory). -# The integer stored in the database is the ENUM ORDINAL (0-based position), -# NOT the botId from chatbotAgentMap. These are two independent systems. -# Image-generating engines: 3 (legacy Bard ordinal reused), 4, 12, 13, 17. -# NOTE: ordinal 3 ('bard') was reused for image generation in newer app versions; -# presence of HistoryDetailImage records confirms image generation regardless of label. -# NOTE: ordinal 20 ('deepSeekR1') — if reasoningContent is NULL the actual API -# call may have used DeepSeek V3; the field reflects the UI selector, not the API. -# --------------------------------------------------------------------------- -CHAT_BOT_MODEL_MAP = { - 0: "ChatGPT 3.5", # gpt-3.5 - 1: "GPT-5", # gpt-5 - 2: "GPT-4o", # gpt-4o - 3: "Bard / Image Gen.", # bard (legacy; reused for image generation) - 4: "Image Generator", # image-generator - 5: "Vision", # vision - 6: "Google Vision", # googleVision - 7: "Document", # document - 8: "LLaMA 2", # llama2 - 9: "Nova", # nova - 10: "Gemini", # gemini - 11: "Superbot", # superbot - 12: "Logo Generator", # logo-generator - 13: "Tattoo Generator", # tattoo-generator - 14: "Web Search", # webSearch - 15: "Claude", # claude - 16: "DeepSeek", # deepSeek - 17: "Signature Generator", # signature-generator - 18: "Mistral", # mistral - 19: "Grok", # grok - 20: "DeepSeek R1", # deepSeekR1 - 21: "AI Filter", # aiFilter - 22: "Voice Chat", # voiceChat - 23: "Snap & Solve", # snapAndSolve - 24: "Study Planner", # studyPlanner - 25: "Quiz Maker", # quizMaker - 26: "Essay Helper", # essayHelper - 27: "Gemini 3 Pro", # gemini-3-pro - 28: "GPT-5.1", # gpt-5.1 - 29: "GPT-4o Mini", # 4o-mini -} - -# --------------------------------------------------------------------------- -# SQL -# One row per HistoryDetailLink, enriched with parent message and -# conversation context. -# --------------------------------------------------------------------------- -QUERY = """ -SELECT - -- Link record - l.id AS link_id, - l.historyDetailID AS msg_id, - l.url AS link_url, - - -- Parent message context (HistoryDetail) - hd.historyID AS conv_id, - hd.type AS msg_type, - hd.text AS msg_text, - hd.createdAt AS msg_created_at, - - -- Parent conversation context (History) - h.UUID AS conv_uuid, - h.title AS conv_title, - h.chatBotModel AS chat_bot_model, - h.softDeleted AS soft_deleted - -FROM HistoryDetailLink l -INNER JOIN HistoryDetail hd ON hd.id = l.historyDetailID -INNER JOIN History h ON h.id = hd.historyID -ORDER BY l.id ASC -""" - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _e(text): - return html_module.escape(str(text)) if text else "" - - -def _convert_ms_timestamp(ms): - if ms is None: - return "" - try: - return datetime.datetime.utcfromtimestamp(ms / 1000).strftime( - "%Y-%m-%d %H:%M:%S UTC" - ) - except (OSError, OverflowError, ValueError): - return str(ms) - - -def _resolve_model(model_int): - if model_int is None: - return "Unknown" - name = CHAT_BOT_MODEL_MAP.get(model_int) - return f"{name} ({model_int})" if name else f"Unknown Model ({model_int})" - - -def _format_soft_deleted(value): - return "DELETED" if value == 1 else "No" - - -def _format_role(type_int): - return {0: "USER", 1: "ASSISTANT"}.get(type_int, f"UNKNOWN ({type_int})") - - -def _build_link_cell(url): - """Render a URL as a clearly labelled clickable anchor.""" - if not url: - return "" - return ( - f'
' - f' 🔗
' - f' {_e(url)}' - f"
" - ) - - -# --------------------------------------------------------------------------- -# Entry point -# --------------------------------------------------------------------------- - - -def get_nova_chatbot_links(files_found, report_folder, seeker, wrap_text): - """ - Entry point for the nova_chatbot_links artifact. - - Extracts every HistoryDetailLink record enriched with parent message and - conversation context. Outputs HTML report, TSV, and timeline. - Handles an empty HistoryDetailLink table gracefully. - """ - for file_found in files_found: - file_found = str(file_found) - if not file_found.endswith("chat-ai.db"): - continue - - try: - db = sqlite3.connect(file_found) - cursor = db.cursor() - cursor.execute(QUERY) - rows_raw = cursor.fetchall() - db.close() - except Exception as e: - scripts.ilapfuncs.logfunc( - f"[nova_chatbot_links] Error reading {file_found}: {e}" - ) - continue - - # Gracefully handle an empty table — log and produce an empty report - # so the examiner knows the module ran and the table had no records. - if not rows_raw: - scripts.ilapfuncs.logfunc( - f"[nova_chatbot_links] HistoryDetailLink table is empty in {file_found}." - ) - report_name = "HistoryDetailLinks" - report = ArtifactHtmlReport(report_name) - report.start_artifact_report(report_folder, report_name) - report.add_script() - report.write_artifact_data_table( - [ - "Link ID", - "Msg. ID", - "Conv. ID", - "Conv. UUID", - "Conv. Title", - "AI Model", - "Conv. Deleted", - "Msg. Role", - "Msg. Text", - "Msg. Timestamp (UTC)", - "Link URL", - ], - [], - file_found, - html_escape=False, - ) - report.end_artifact_report() - continue - - headers = [ - # Link identity - "Link ID", - "Msg. ID", - "Conv. ID", - # Conversation context - "Conv. UUID", - "Conv. Title", - "AI Model", - "Conv. Deleted", - # Message context - "Msg. Role", - "Msg. Text", - "Msg. Timestamp (UTC)", - # Link (HTML rendered) - "Link URL", - ] - - html_rows = [] - tsv_rows = [] - - for row in rows_raw: - ( - link_id, - msg_id, - link_url, - conv_id, - msg_type, - msg_text, - msg_created_at, - conv_uuid, - conv_title, - chat_bot_model, - soft_deleted, - ) = row - - common = ( - link_id, - msg_id, - conv_id, - conv_uuid or "", - conv_title or "", - _resolve_model(chat_bot_model), - _format_soft_deleted(soft_deleted), - _format_role(msg_type), - msg_text or "", - _convert_ms_timestamp(msg_created_at), - ) - - html_rows.append(common + (_build_link_cell(link_url),)) - tsv_rows.append(common + (link_url or "",)) - - # HTML report - report_name = "HistoryDetailLinks" - report = ArtifactHtmlReport(report_name) - report.start_artifact_report(report_folder, report_name) - report.add_script() - report.write_artifact_data_table( - headers, html_rows, file_found, html_escape=False - ) - report.end_artifact_report() - - # TSV - scripts.ilapfuncs.tsv(report_folder, headers, tsv_rows, report_name, file_found) - - # Timeline (Msg. Timestamp, index 9) - scripts.ilapfuncs.timeline(report_folder, report_name, tsv_rows, headers) From 2e9b64a1cfd6b0db875b7a0de5b7153fd4e89752 Mon Sep 17 00:00:00 2001 From: Guilherme Guilherme Date: Fri, 29 May 2026 23:55:10 +0100 Subject: [PATCH 11/18] Fix names --- scripts/artifacts/AIChatbotNovaHistory.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/artifacts/AIChatbotNovaHistory.py b/scripts/artifacts/AIChatbotNovaHistory.py index 8ef7e347..9d85fbc9 100644 --- a/scripts/artifacts/AIChatbotNovaHistory.py +++ b/scripts/artifacts/AIChatbotNovaHistory.py @@ -13,7 +13,7 @@ "artifact_icon": "message-square", }, "nova_chatbot_history_detail": { - "name": "History Detail", + "name": "HistoryDetail", "description": "Extracts individual messages from Nova AI Chatbot", "author": "Guilherme Guilherme", "version": "6.1", @@ -26,7 +26,7 @@ "artifact_icon": "message-circle", }, "nova_chatbot_documents": { - "name": "History Detail Documents", + "name": "HistoryDetailDocuments", "description": "Extracts document records from Nova AI Chatbot (Firebase URLs)", "author": "Guilherme Guilherme", "version": "6.1", @@ -39,7 +39,7 @@ "artifact_icon": "file-text", }, "nova_chatbot_images": { - "name": "History Detail Images", + "name": "HistoryDetailImages", "description": "Extracts image records from Nova AI Chatbot (Firebase URLs)", "author": "Guilherme Guilherme", "version": "6.1", @@ -52,7 +52,7 @@ "artifact_icon": "image", }, "nova_chatbot_links": { - "name": "History Detail Links", + "name": "HistoryDetailLinks", "description": "Extracts link records from Nova AI Chatbot", "author": "Guilherme Guilherme", "version": "6.1", From c350504a01a9396e2324d72653d2ef7fc5a971a1 Mon Sep 17 00:00:00 2001 From: Guilherme Guilherme Date: Sat, 30 May 2026 00:24:31 +0100 Subject: [PATCH 12/18] Delete AIChatbotNovaCachedImages.py The Image Manager Cache report already extracts cached images from Nova AI. This module was a duplicate and is unnecessary. --- .../artifacts/AIChatbotNovaCachedImages.py | 94 ------------------- 1 file changed, 94 deletions(-) delete mode 100644 scripts/artifacts/AIChatbotNovaCachedImages.py diff --git a/scripts/artifacts/AIChatbotNovaCachedImages.py b/scripts/artifacts/AIChatbotNovaCachedImages.py deleted file mode 100644 index 8ccf705d..00000000 --- a/scripts/artifacts/AIChatbotNovaCachedImages.py +++ /dev/null @@ -1,94 +0,0 @@ -__artifacts_v2__ = { - "nova_cache_images": { - "name": "Cached Images (Glide Disk Cache)", - "description": ( - "Extracts cached image files from the Nova AI Chatbot Glide disk cache " - "(cache/image_manager_disk_cache/*.0). These files are raw JPEG images " - "downloaded from Firebase Storage and cached locally." - ), - "author": "Guilherme Guilherme", - "version": "2.0", - "date": "2026-05-21", - "requirements": "none", - "category": "AI Chatbot - Nova", - "notes": "Glide disk cache location: cache/image_manager_disk_cache/*.0.", - "paths": ( - "*/com.scaleup.chatai/cache/image_manager_disk_cache/*", - "*/data/data/com.scaleup.chatai/cache/image_manager_disk_cache/*", - ), - "function": "get_nova_cache_images", - "output_types": "standard", - "artifact_icon": "image", - } -} - -import os -import datetime -from datetime import timezone -from scripts.artifact_report import ArtifactHtmlReport -from scripts.ilapfuncs import logfunc, tsv, media_to_html - - -def get_nova_cache_images(files_found, report_folder, seeker, wrap_text): - logfunc("Processing data for Nova Cached Images") - - data_list = [] - - for file_found in files_found: - file_found = str(file_found) - if os.path.isdir(file_found): - continue - - fname = os.path.basename(file_found) - if not fname.endswith(".0"): - continue - - try: - stat = os.stat(file_found) - size_bytes = stat.st_size - - # Modern, non-deprecated timezone conversion - mtime = datetime.datetime.fromtimestamp(stat.st_mtime, timezone.utc) - mtime_str = mtime.strftime("%Y-%m-%d %H:%M:%S UTC") - - # Mandatory framework call: copies images to output structure and populates LAVA tracking manifests - media_to_html(fname, file_found, report_folder) - - # Parse path so it consistently normalizes from the extraction /data node onward - normalized_path = file_found.replace("\\", "/") - if "/data/" in normalized_path: - display_path = "/data/" + normalized_path.split("/data/", 1)[1] - elif "data/data/" in normalized_path: - display_path = "/data/data/" + normalized_path.split("data/data/", 1)[1] - else: - display_path = normalized_path - - data_list.append((fname, size_bytes, mtime_str, display_path)) - - except Exception as e: - logfunc(f"[nova_cache_images] Error reading {file_found}: {e}") - - if not data_list: - logfunc("No Nova Cached Images data found.") - return - - report_name = "Cached Images" - report = ArtifactHtmlReport(report_name) - report.start_artifact_report(report_folder, report_name) - report.add_script() - - headers = ( - "Original Cache Filename", - "File Size (Bytes)", - "Last Modified (UTC)", - "Path", - ) - - # HTML injection vulnerabilities are entirely eliminated by delegating escaping to the framework - report.write_artifact_data_table( - headers, data_list, report_folder, table_id="NovaCacheImages", html_escape=True - ) - report.end_artifact_report() - - tsv(report_folder, headers, data_list, report_name) - logfunc(f"[nova_cache_images] Displayed {len(data_list)} cached image entries.") From e53d5cbdaba9cdefe63f5f1b8f48aaec77dda645 Mon Sep 17 00:00:00 2001 From: Guilherme Guilherme Date: Sat, 30 May 2026 00:52:30 +0100 Subject: [PATCH 13/18] Update AIChatbotNovaSharedPrefs.py Extracts account info, device identifiers, usage metrics, and Adapty payment data from ChatAI preference files. --- scripts/artifacts/AIChatbotNovaSharedPrefs.py | 437 +++++++++--------- 1 file changed, 229 insertions(+), 208 deletions(-) diff --git a/scripts/artifacts/AIChatbotNovaSharedPrefs.py b/scripts/artifacts/AIChatbotNovaSharedPrefs.py index d00a642e..3703b8dc 100644 --- a/scripts/artifacts/AIChatbotNovaSharedPrefs.py +++ b/scripts/artifacts/AIChatbotNovaSharedPrefs.py @@ -1,39 +1,51 @@ +# AI Chatbot - Nova (com.scaleup.chatai) +# Artifact module: Shared Preferences - Account & Usage +# +# Author : Guilherme Guilherme +# Version : 1.4 +# Date : 2026-05-30 +# Category: AI Chatbot - Nova + __artifacts_v2__ = { - "nova_shared_prefs": { - "name": "Shared Preferences", - "description": ( - "Extracts account info, decoded Firebase JWT data, usage metrics, and Adapty payment " - "profile data from ChatAI app preference files, optimized using LAVA-compliant timestamp parsing." - ), + "nova_momo_prefs": { + "name": "Shared Preferences - Account & Usage", + "description": "Extracts account info, decoded Firebase JWT data, device identifiers, and comprehensive usage metrics from MOMO_PREF_FILE.xml", "author": "Guilherme Guilherme", - "version": "1.1", - "date": "2026-05-29", + "version": "1.4", + "date": "2026-05-30", "requirements": "none", "category": "AI Chatbot - Nova", - "notes": "Decodes Firebase JWT tokens and parses nested Adapty JSON strings.", - "paths": ( - "*/com.scaleup.chatai/shared_prefs/MOMO_PREF_FILE.xml", - "*/com.scaleup.chatai/shared_prefs/AdaptySDKPrefs.xml", - ), - "function": "get_chat_ai_prefs", - "output_types": "standard", + "paths": ("*/com.scaleup.chatai/shared_prefs/MOMO_PREF_FILE.xml",), + "function": "get_nova_momo_prefs", + "output_types": "all", "artifact_icon": "settings", - } + }, + "nova_adapty_prefs": { + "name": "Shared Preferences - Adapty Payment", + "description": "Extracts payment profile and installation metadata from AdaptySDKPrefs.xml", + "author": "Guilherme Guilherme", + "version": "1.4", + "date": "2026-05-30", + "requirements": "none", + "category": "AI Chatbot - Nova", + "paths": ("*/com.scaleup.chatai/shared_prefs/AdaptySDKPrefs.xml",), + "function": "get_nova_adapty_prefs", + "output_types": "all", + "artifact_icon": "credit-card", + }, } - import json import base64 import xml.etree.ElementTree as ET from scripts.artifact_report import ArtifactHtmlReport -from scripts.ilapfuncs import logfunc, tsv +from scripts.ilapfuncs import artifact_processor, logfunc, tsv def decode_jwt(token): + """Decode JWT payload without signature validation""" try: - # JWT is Header.Payload.Signature payload_b64 = token.split(".")[1] - # Add padding if necessary missing_padding = len(payload_b64) % 4 if missing_padding: payload_b64 += "=" * (4 - missing_padding) @@ -43,200 +55,209 @@ def decode_jwt(token): return None -def get_chat_ai_prefs(files_found, report_folder, seeker, wrap_text): - logfunc("Processing data for ChatAI Shared Preferences") +def format_key_name(key): + """Convert internal key names to user-friendly display names""" + name = key.replace("KEY_", "").replace("_", " ") + return " ".join(word.capitalize() for word in name.split()) - for file_found in files_found: - file_found = str(file_found) - if file_found.endswith("MOMO_PREF_FILE.xml"): - try: - tree = ET.parse(file_found) - root = tree.getroot() - except Exception as e: - logfunc(f"[ChatAIPrefs] Error parsing XML file {file_found}: {e}") - continue - - jwt_info = {} - usage_metrics = [] - generic_ids = [] - - for elem in root: - name = elem.get("name") - value = elem.get("value") if elem.get("value") else elem.text - - if not name: - continue - - # Decode Firebase JWT - if name == "KEY_USER_FIREBASE_ID_TOKEN" and value: - decoded = decode_jwt(value) - if decoded: - jwt_info = { - "Email": decoded.get("email"), - "Name": decoded.get("name"), - "Firebase UID": decoded.get("user_id"), - "Sign-in Provider": decoded.get("firebase", {}).get( - "sign_in_provider" - ), - } - - # Identity Keys - elif name in [ - "KEY_USER_AUTHENTICATION_ID", - "KEY_USER_INSTALLATIONS_ID", - "KEY_PLATFORM_ID", - "KEY_FCM_TOKEN", - ]: - generic_ids.append((name, value or "")) - - # Global Metrics - elif name in ["KEY_SUCCESSFULL_CHAT_RESPONSE", "KEY_SESSION_COUNT"]: - usage_metrics.append((name, value or "")) - - # Individual Bot Usage (Generalizing the pattern) - elif ( - "KEY_USER_USAGE_RIGHT_COUNT" in name - or "KEY_USER_USAGE_TOTAL_COUNT" in name - ): - usage_metrics.append((name, value or "")) - - # Report 1: MOMO Account & IDs (Sem timestamp nativo associado às chaves) - if jwt_info or generic_ids: - report_name = "Shared Prefs - Account Identifiers" - report = ArtifactHtmlReport(report_name) - report.start_artifact_report(report_folder, report_name) - report.add_script() - - data_list = [] - for k, v in jwt_info.items(): - data_list.append(("", k, v, "Decoded from JWT")) - for k, v in generic_ids: - data_list.append(("", k, v, "XML Raw Value")) - - headers = ("Timestamp", "Key/Field", "Value", "Source Type") - report.write_artifact_data_table( - headers, data_list, file_found, html_escape=True +@artifact_processor +def get_nova_momo_prefs(files_found, report_folder, seeker, wrap_text): + """ + Extract data from MOMO_PREF_FILE.xml + Note: No media handling needed as this contains only preference data + """ + if not files_found: + logfunc("[nova_momo_prefs] No MOMO_PREF_FILE.xml found") + return + + file_path = str(files_found[0]) + account_data = [] + identifiers = [] + usage_data = [] + flags = [] + + try: + tree = ET.parse(file_path) + root = tree.getroot() + except Exception as e: + logfunc(f"[nova_momo_prefs] Error parsing XML: {e}") + return + + for elem in root: + name = elem.get("name") + value = elem.get("value") if elem.get("value") is not None else elem.text + + if not name: + continue + + # Decode Firebase JWT for account info + if name == "KEY_USER_FIREBASE_ID_TOKEN": + decoded = decode_jwt(value) + if decoded: + account_data.extend( + [ + ("Firebase Email", decoded.get("email", "")), + ("Firebase Name", decoded.get("name", "")), + ("Firebase UID", decoded.get("user_id", "")), + ( + "Sign-in Provider", + decoded.get("firebase", {}).get("sign_in_provider", ""), + ), + ] ) - report.end_artifact_report() - tsv(report_folder, headers, data_list, report_name, file_found) - # Report 2: MOMO Usage Metrics - if usage_metrics: - report_name = "Shared Prefs - Usage Metrics" - report = ArtifactHtmlReport(report_name) - report.start_artifact_report(report_folder, report_name) - report.add_script() + # Device identifiers + elif name in [ + "KEY_USER_AUTHENTICATION_ID", + "KEY_USER_INSTALLATIONS_ID", + "KEY_PLATFORM_ID", + "KEY_FCM_TOKEN", + ]: + identifiers.append((format_key_name(name), value)) + + # Boolean flags and settings + elif name.startswith("KEY_DID_") or name.startswith("KEY_IS_"): + flags.append((format_key_name(name), "Yes" if value == "true" else "No")) + + # Usage metrics (all other KEY_USER_USAGE_* fields) + elif name.startswith("KEY_USER_USAGE_") or name in [ + "KEY_SUCCESSFULL_CHAT_RESPONSE", + "KEY_SESSION_COUNT", + "KEY_HISTORY_BOX_SHOWN", + ]: + usage_data.append((format_key_name(name), value)) + + # Generate reports + if account_data: + report = ArtifactHtmlReport("Shared Prefs - Account Information") + report.start_artifact_report( + report_folder, "Shared Prefs - Account Information" + ) + report.add_script() + report.write_artifact_data_table( + ("Field", "Value"), account_data, file_path, html_escape=False + ) + report.end_artifact_report() + tsv( + report_folder, + ("Field", "Value"), + account_data, + "Shared Prefs - Account Information", + ) + + if identifiers: + report = ArtifactHtmlReport("Shared Prefs - Device Identifiers") + report.start_artifact_report(report_folder, "Shared Prefs - Device Identifiers") + report.add_script() + report.write_artifact_data_table( + ("Identifier", "Value"), identifiers, file_path, html_escape=False + ) + report.end_artifact_report() + tsv( + report_folder, + ("Identifier", "Value"), + identifiers, + "Shared Prefs - Device Identifiers", + ) + + if usage_data: + report = ArtifactHtmlReport("Shared Prefs - Usage Metrics") + report.start_artifact_report(report_folder, "Shared Prefs - Usage Metrics") + report.add_script() + report.write_artifact_data_table( + ("Metric", "Value"), usage_data, file_path, html_escape=False + ) + report.end_artifact_report() + tsv( + report_folder, + ("Metric", "Value"), + usage_data, + "Shared Prefs - Usage Metrics", + ) + + if flags: + report = ArtifactHtmlReport("Shared Prefs - App Settings") + report.start_artifact_report(report_folder, "Shared Prefs - App Settings") + report.add_script() + report.write_artifact_data_table( + ("Setting", "Value"), flags, file_path, html_escape=False + ) + report.end_artifact_report() + tsv(report_folder, ("Setting", "Value"), flags, "Shared Prefs - App Settings") + + +@artifact_processor +def get_nova_adapty_prefs(files_found, report_folder, seeker, wrap_text): + """Extract data from AdaptySDKPrefs.xml""" + if not files_found: + logfunc("[nova_adapty_prefs] No AdaptySDKPrefs.xml found") + return + + file_path = str(files_found[0]) + data_list = [] + + try: + tree = ET.parse(file_path) + root = tree.getroot() + except Exception as e: + logfunc(f"[nova_adapty_prefs] Error parsing XML: {e}") + return - data_list = [("", k, v) for k, v in usage_metrics] - headers = ("Timestamp", "Metric Key", "Value") + for elem in root: + name = elem.get("name") + value = elem.get("value") if elem.get("value") is not None else elem.text - report.write_artifact_data_table( - headers, data_list, file_found, html_escape=True - ) - report.end_artifact_report() - tsv(report_folder, headers, data_list, report_name, file_found) + if not name or not value: + continue - elif file_found.endswith("AdaptySDKPrefs.xml"): + if name == "LAST_SENT_INSTALLATION_META": try: - tree = ET.parse(file_found) - root = tree.getroot() - except Exception as e: - logfunc(f"[ChatAIPrefs] Error parsing XML file {file_found}: {e}") - continue - - adapty_main = [] - adapty_meta = [] - - for elem in root: - name = elem.get("name") - value = elem.get("value") if elem.get("value") else elem.text - - if not name: - continue - - # Parse Installation Meta JSON - if name == "LAST_SENT_INSTALLATION_META" and value: - try: - meta = json.loads(value) - for k, v in meta.items(): - adapty_meta.append(("", k, str(v))) - except Exception: - pass - - # Parse Profile JSON - if name in ["get_purchaser_info_response", "PROFILE"] and value: - try: - p_data = json.loads(value) - if "data" in p_data: - attrs = p_data["data"].get("attributes", {}) - else: - attrs = p_data - - custom = attrs.get("custom_attributes", {}) - ts = attrs.get("timestamp") or p_data.get("timestamp") - - # Conversão para Float Epoch compatível com LAVA (Segundos) - lava_timestamp = "" - if ts is not None: - try: - lava_timestamp = float(ts) / 1000 - except (ValueError, TypeError): - lava_timestamp = ts - - adapty_main.append( - ( - lava_timestamp, - "Is Test User", - str(attrs.get("is_test_user", "")), - ) - ) - adapty_main.append( - ( - lava_timestamp, - "Old App Instance ID", - str(custom.get("oldAppInstanceId", "")), - ) - ) - adapty_main.append( - ( - lava_timestamp, - "Total Revenue (USD)", - str(attrs.get("total_revenue_usd", "")), - ) - ) - adapty_main.append( - ( - lava_timestamp, - "Paywall Type", - str(custom.get("paywallType", "")), - ) - ) - except Exception: - pass - - if adapty_main: - report_name = "Shared Prefs - Adapty Payment Profile" - report = ArtifactHtmlReport(report_name) - report.start_artifact_report(report_folder, report_name) - report.add_script() - - headers = ("Timestamp", "Attribute", "Value") - report.write_artifact_data_table( - headers, adapty_main, file_found, html_escape=True - ) - report.end_artifact_report() - tsv(report_folder, headers, adapty_main, report_name, file_found) - - if adapty_meta: - report_name = "Shared Prefs - Adapty Device Meta" - report = ArtifactHtmlReport(report_name) - report.start_artifact_report(report_folder, report_name) - report.add_script() - - headers = ("Timestamp", "Meta Key", "Value") - report.write_artifact_data_table( - headers, adapty_meta, file_found, html_escape=True + meta = json.loads(value) + for k, v in meta.items(): + data_list.append((f"Meta: {k}", str(v))) + except Exception: + pass + + if name in ["get_purchaser_info_response", "PROFILE"]: + try: + p_data = json.loads(value) + if "data" in p_data: + attrs = p_data["data"].get("attributes", {}) + else: + attrs = p_data + + custom = attrs.get("custom_attributes", {}) + data_list.extend( + [ + ("Is Test User", str(attrs.get("is_test_user", ""))), + ( + "Old App Instance ID", + str(custom.get("oldAppInstanceId", "")), + ), + ( + "Total Revenue (USD)", + str(attrs.get("total_revenue_usd", "")), + ), + ("Paywall Type", str(custom.get("paywallType", ""))), + ] ) - report.end_artifact_report() - tsv(report_folder, headers, adapty_meta, report_name, file_found) + except Exception: + pass + + if data_list: + report = ArtifactHtmlReport("Shared Prefs - Adapty Payment Data") + report.start_artifact_report( + report_folder, "Shared Prefs - Adapty Payment Data" + ) + report.add_script() + report.write_artifact_data_table( + ("Field", "Value"), data_list, file_path, html_escape=False + ) + report.end_artifact_report() + tsv( + report_folder, + ("Field", "Value"), + data_list, + "Shared Prefs - Adapty Payment Data", + ) From 1a9ee858e9ed81a79d679eb5e64f685ff3d5abf1 Mon Sep 17 00:00:00 2001 From: Guilherme Guilherme Date: Sat, 30 May 2026 09:24:48 +0100 Subject: [PATCH 14/18] Update AIChatbotNovaSharedPrefs.py Remove Artifact HTML Report --- scripts/artifacts/AIChatbotNovaSharedPrefs.py | 225 ++++-------------- 1 file changed, 42 insertions(+), 183 deletions(-) diff --git a/scripts/artifacts/AIChatbotNovaSharedPrefs.py b/scripts/artifacts/AIChatbotNovaSharedPrefs.py index 3703b8dc..55d92bac 100644 --- a/scripts/artifacts/AIChatbotNovaSharedPrefs.py +++ b/scripts/artifacts/AIChatbotNovaSharedPrefs.py @@ -1,49 +1,39 @@ -# AI Chatbot - Nova (com.scaleup.chatai) -# Artifact module: Shared Preferences - Account & Usage -# -# Author : Guilherme Guilherme -# Version : 1.4 -# Date : 2026-05-30 -# Category: AI Chatbot - Nova - __artifacts_v2__ = { - "nova_momo_prefs": { + # Key must match the function name exactly + "get_nova_momo_prefs": { "name": "Shared Preferences - Account & Usage", - "description": "Extracts account info, decoded Firebase JWT data, device identifiers, and comprehensive usage metrics from MOMO_PREF_FILE.xml", + "description": "Extracts account info, decoded Firebase JWT data, device identifiers, and usage metrics.", "author": "Guilherme Guilherme", - "version": "1.4", + "version": "4.0", "date": "2026-05-30", "requirements": "none", "category": "AI Chatbot - Nova", "paths": ("*/com.scaleup.chatai/shared_prefs/MOMO_PREF_FILE.xml",), - "function": "get_nova_momo_prefs", "output_types": "all", "artifact_icon": "settings", }, - "nova_adapty_prefs": { + "get_nova_adapty_prefs": { "name": "Shared Preferences - Adapty Payment", - "description": "Extracts payment profile and installation metadata from AdaptySDKPrefs.xml", + "description": "Extracts payment profile and installation metadata from AdaptySDKPrefs.xml.", "author": "Guilherme Guilherme", - "version": "1.4", + "version": "4.0", "date": "2026-05-30", "requirements": "none", "category": "AI Chatbot - Nova", "paths": ("*/com.scaleup.chatai/shared_prefs/AdaptySDKPrefs.xml",), - "function": "get_nova_adapty_prefs", "output_types": "all", "artifact_icon": "credit-card", }, } + import json import base64 import xml.etree.ElementTree as ET -from scripts.artifact_report import ArtifactHtmlReport -from scripts.ilapfuncs import artifact_processor, logfunc, tsv +from scripts.ilapfuncs import artifact_processor, logfunc def decode_jwt(token): - """Decode JWT payload without signature validation""" try: payload_b64 = token.split(".")[1] missing_padding = len(payload_b64) % 4 @@ -56,208 +46,77 @@ def decode_jwt(token): def format_key_name(key): - """Convert internal key names to user-friendly display names""" name = key.replace("KEY_", "").replace("_", " ") return " ".join(word.capitalize() for word in name.split()) @artifact_processor def get_nova_momo_prefs(files_found, report_folder, seeker, wrap_text): - """ - Extract data from MOMO_PREF_FILE.xml - Note: No media handling needed as this contains only preference data - """ - if not files_found: - logfunc("[nova_momo_prefs] No MOMO_PREF_FILE.xml found") - return - file_path = str(files_found[0]) - account_data = [] - identifiers = [] - usage_data = [] - flags = [] + data_list = [] try: - tree = ET.parse(file_path) - root = tree.getroot() + root = ET.parse(file_path).getroot() except Exception as e: logfunc(f"[nova_momo_prefs] Error parsing XML: {e}") - return + return (), [], "" for elem in root: name = elem.get("name") value = elem.get("value") if elem.get("value") is not None else elem.text - if not name: continue - # Decode Firebase JWT for account info if name == "KEY_USER_FIREBASE_ID_TOKEN": decoded = decode_jwt(value) if decoded: - account_data.extend( - [ - ("Firebase Email", decoded.get("email", "")), - ("Firebase Name", decoded.get("name", "")), - ("Firebase UID", decoded.get("user_id", "")), - ( - "Sign-in Provider", - decoded.get("firebase", {}).get("sign_in_provider", ""), - ), - ] - ) - - # Device identifiers - elif name in [ - "KEY_USER_AUTHENTICATION_ID", - "KEY_USER_INSTALLATIONS_ID", - "KEY_PLATFORM_ID", - "KEY_FCM_TOKEN", - ]: - identifiers.append((format_key_name(name), value)) - - # Boolean flags and settings + for k, v in [ + ("Email", decoded.get("email")), + ("Name", decoded.get("name")), + ("UID", decoded.get("user_id")), + ("Provider", decoded.get("firebase", {}).get("sign_in_provider")), + ]: + data_list.append(("Account", k, v)) elif name.startswith("KEY_DID_") or name.startswith("KEY_IS_"): - flags.append((format_key_name(name), "Yes" if value == "true" else "No")) - - # Usage metrics (all other KEY_USER_USAGE_* fields) - elif name.startswith("KEY_USER_USAGE_") or name in [ - "KEY_SUCCESSFULL_CHAT_RESPONSE", - "KEY_SESSION_COUNT", - "KEY_HISTORY_BOX_SHOWN", - ]: - usage_data.append((format_key_name(name), value)) + data_list.append( + ("Settings", format_key_name(name), "Yes" if value == "true" else "No") + ) + else: + data_list.append(("Data", format_key_name(name), value)) - # Generate reports - if account_data: - report = ArtifactHtmlReport("Shared Prefs - Account Information") - report.start_artifact_report( - report_folder, "Shared Prefs - Account Information" - ) - report.add_script() - report.write_artifact_data_table( - ("Field", "Value"), account_data, file_path, html_escape=False - ) - report.end_artifact_report() - tsv( - report_folder, - ("Field", "Value"), - account_data, - "Shared Prefs - Account Information", - ) - - if identifiers: - report = ArtifactHtmlReport("Shared Prefs - Device Identifiers") - report.start_artifact_report(report_folder, "Shared Prefs - Device Identifiers") - report.add_script() - report.write_artifact_data_table( - ("Identifier", "Value"), identifiers, file_path, html_escape=False - ) - report.end_artifact_report() - tsv( - report_folder, - ("Identifier", "Value"), - identifiers, - "Shared Prefs - Device Identifiers", - ) - - if usage_data: - report = ArtifactHtmlReport("Shared Prefs - Usage Metrics") - report.start_artifact_report(report_folder, "Shared Prefs - Usage Metrics") - report.add_script() - report.write_artifact_data_table( - ("Metric", "Value"), usage_data, file_path, html_escape=False - ) - report.end_artifact_report() - tsv( - report_folder, - ("Metric", "Value"), - usage_data, - "Shared Prefs - Usage Metrics", - ) - - if flags: - report = ArtifactHtmlReport("Shared Prefs - App Settings") - report.start_artifact_report(report_folder, "Shared Prefs - App Settings") - report.add_script() - report.write_artifact_data_table( - ("Setting", "Value"), flags, file_path, html_escape=False - ) - report.end_artifact_report() - tsv(report_folder, ("Setting", "Value"), flags, "Shared Prefs - App Settings") + return ("Category", "Field", "Value"), data_list, file_path @artifact_processor def get_nova_adapty_prefs(files_found, report_folder, seeker, wrap_text): - """Extract data from AdaptySDKPrefs.xml""" - if not files_found: - logfunc("[nova_adapty_prefs] No AdaptySDKPrefs.xml found") - return - file_path = str(files_found[0]) data_list = [] try: - tree = ET.parse(file_path) - root = tree.getroot() + root = ET.parse(file_path).getroot() except Exception as e: logfunc(f"[nova_adapty_prefs] Error parsing XML: {e}") - return + return (), [], "" for elem in root: name = elem.get("name") value = elem.get("value") if elem.get("value") is not None else elem.text - if not name or not value: continue if name == "LAST_SENT_INSTALLATION_META": - try: - meta = json.loads(value) - for k, v in meta.items(): - data_list.append((f"Meta: {k}", str(v))) - except Exception: - pass - - if name in ["get_purchaser_info_response", "PROFILE"]: - try: - p_data = json.loads(value) - if "data" in p_data: - attrs = p_data["data"].get("attributes", {}) - else: - attrs = p_data - - custom = attrs.get("custom_attributes", {}) - data_list.extend( - [ - ("Is Test User", str(attrs.get("is_test_user", ""))), - ( - "Old App Instance ID", - str(custom.get("oldAppInstanceId", "")), - ), - ( - "Total Revenue (USD)", - str(attrs.get("total_revenue_usd", "")), - ), - ("Paywall Type", str(custom.get("paywallType", ""))), - ] - ) - except Exception: - pass - - if data_list: - report = ArtifactHtmlReport("Shared Prefs - Adapty Payment Data") - report.start_artifact_report( - report_folder, "Shared Prefs - Adapty Payment Data" - ) - report.add_script() - report.write_artifact_data_table( - ("Field", "Value"), data_list, file_path, html_escape=False - ) - report.end_artifact_report() - tsv( - report_folder, - ("Field", "Value"), - data_list, - "Shared Prefs - Adapty Payment Data", - ) + for k, v in json.loads(value).items(): + data_list.append(("Installation Meta", k, str(v))) + elif name in ["get_purchaser_info_response", "PROFILE"]: + p_data = json.loads(value) + attrs = p_data.get("data", p_data).get("attributes", p_data) + custom = attrs.get("custom_attributes", {}) + for k, v in [ + ("Is Test User", attrs.get("is_test_user")), + ("Old Instance ID", custom.get("oldAppInstanceId")), + ("Total Revenue", attrs.get("total_revenue_usd")), + ("Paywall", custom.get("paywallType")), + ]: + data_list.append(("Payment Profile", k, str(v))) + + return ("Category", "Field", "Value"), data_list, file_path From 571e5c5fda5952391eb4f4d77aee2cc65c3478d8 Mon Sep 17 00:00:00 2001 From: Guilherme Guilherme Date: Sat, 30 May 2026 13:53:53 +0100 Subject: [PATCH 15/18] Update User Media Submissions Lava Complicance --- scripts/artifacts/AIChatbotNovaMediastore.py | 406 ++++++------------ scripts/artifacts/AIChatbotNovaSharedPrefs.py | 1 - 2 files changed, 127 insertions(+), 280 deletions(-) diff --git a/scripts/artifacts/AIChatbotNovaMediastore.py b/scripts/artifacts/AIChatbotNovaMediastore.py index 2006f28f..671da28a 100644 --- a/scripts/artifacts/AIChatbotNovaMediastore.py +++ b/scripts/artifacts/AIChatbotNovaMediastore.py @@ -1,55 +1,46 @@ __artifacts_v2__ = { "nova_user_submissions": { "name": "User Media Submissions", - "description": ( - "Identifies media files submitted by the user to Nova AI Chatbot, including " - "uploaded documents, chat-attached images, and photos captured using the in-app camera. " - "The artifact lists recovered filenames, conversation context, timestamps, MIME types, " - "and resolved physical paths from the extracted filesystem." - ), + "description": "Extracts Nova AI media. Identifies files via database indexing (MediaStore) and performs a filesystem sweep for orphaned camera captures.", "author": "Guilherme Guilherme", - "version": "3.4", - "date": "2026-05-21", + "version": "3.6", + "date": "2026-05-30", "requirements": "none", "category": "AI Chatbot - Nova", - "notes": "Sources: chat-ai.db and Android MediaStore databases.", + "notes": "Integrates chat-ai.db history with physical filesystem discovery. Note: chat-ai.db contains text data only, not media files.", "paths": ( - "*/com.scaleup.chatai/databases/chat-ai.db", - "*/com.android.providers.media/databases/external*.db", - "*/com.google.android.providers.media.module/databases/external*.db", + "**/com.scaleup.chatai/databases/chat-ai.db", + "**/com.android.providers.media/databases/external*.db", + "**/com.google.android.providers.media.module/databases/external*.db", + "**/data/media/0/Android/media/com.scaleup.chatai/Nova/*", ), "function": "get_nova_user_submissions", - "output_types": "standard", + "output_types": ["standard", "lava"], "artifact_icon": "folder", } } import os -import datetime -from datetime import timezone -from scripts.artifact_report import ArtifactHtmlReport -from scripts.ilapfuncs import logfunc, tsv, open_sqlite_db_readonly, media_to_html - - -def _parse_path(raw_path): - """Normalizes paths and slices them to always start at /data.""" - if not raw_path: - return "" - normalized = str(raw_path).replace("\\", "/") - if "/data/" in normalized: - return "/data/" + normalized.split("/data/", 1)[1] - elif "data/data/" in normalized: - return "/data/data/" + normalized.split("data/data/", 1)[1] - return normalized +from types import SimpleNamespace +from scripts.ilapfuncs import ( + artifact_processor, + logfunc, + open_sqlite_db_readonly, + check_in_media, + get_file_path, +) +@artifact_processor def get_nova_user_submissions(files_found, report_folder, seeker, wrap_text): - logfunc("Processing data for Nova User Media Submissions") + logfunc("Processing Nova User Media (Logic + Physical Sweep)") + + # Use the artifact_info injected by the framework (cleaner than inspect.stack) + artifact_info = SimpleNamespace(**get_nova_user_submissions.artifact_info) + artifact_info.filename = __file__ - files_found = [ - x for x in files_found if not x.endswith(("-journal", "-wal", "-shm")) - ] - nova_db = next((str(x) for x in files_found if "chat-ai.db" in str(x)), None) + # Find databases + nova_db = get_file_path(files_found, "chat-ai.db") media_db = next( ( str(x) @@ -59,274 +50,131 @@ def get_nova_user_submissions(files_found, report_folder, seeker, wrap_text): None, ) - if not nova_db: - logfunc("[nova_user_submissions] Nova database not found.") - return + all_items = [] + processed_paths = set() - extraction_root = getattr(seeker, "search_dir", "") or "" - media_lookup = {} + # Pre-build lookup for files in the extraction to quickly find them by name + # Focus on the Nova folder to avoid collisions + nova_files_lookup = {} + nova_path_part = "Android/media/com.scaleup.chatai/Nova" + for f in files_found: + if nova_path_part in str(f): + nova_files_lookup[os.path.basename(f).lower()] = str(f) - # 1. Map the MediaStore database records to see what is on local storage + # 1. Database Indexed Lookup (MediaStore) + media_lookup = {} if media_db: - try: - db = open_sqlite_db_readonly(media_db) + with open_sqlite_db_readonly(media_db) as db: + cur = db.cursor() + cur.execute("SELECT _display_name, _data FROM files WHERE _data IS NOT NULL") + for name, path in cur.fetchall(): + key = (name or os.path.basename(str(path))).lower() + media_lookup[key] = path + + # 2. Extract from Nova Chat DB + if nova_db: + with open_sqlite_db_readonly(nova_db) as db: cur = db.cursor() - cur.execute(""" - SELECT _display_name, _data, _size, date_added, mime_type - FROM files - WHERE _data IS NOT NULL - """) - for display_name, data_path, size, date_added, mime_type in cur.fetchall(): - local_path = "" - if data_path: - clean_rel = str(data_path).replace("\\", "/").lstrip("/") - if clean_rel.startswith("storage/emulated/0/"): - clean_rel = clean_rel.replace( - "storage/emulated/0/", "data/media/0/", 1 - ) - - candidate_path = os.path.join(extraction_root, clean_rel) - if os.path.exists(candidate_path): - local_path = candidate_path - - key = (display_name or os.path.basename(str(data_path))).lower() - media_lookup[key] = {"data_path": data_path, "local_path": local_path} - db.close() - except Exception as e: - logfunc(f"[nova_user_submissions] Error building MediaStore lookup: {e}") - - all_items = [] - - # 2. Process chat database documents and cross-reference with MediaStore - try: - db = open_sqlite_db_readonly(nova_db) - cur = db.cursor() - cur.execute(""" - SELECT - hdd.name, - hdd.mimeType, - hdd.size, - hd.text, - hd.createdAt, - h.title, - h.UUID - FROM HistoryDetailDocument hdd - INNER JOIN HistoryDetail hd ON hd.id = hdd.historyDetailID - INNER JOIN History h ON h.id = hd.historyID - WHERE hd.type = 0 - ORDER BY hd.createdAt DESC - """) - for ( - file_name, - mime_type, - size_db, - message, - created_at, - conversation, - conv_uuid, - ) in cur.fetchall(): - mtime_str = "" - if created_at: - try: - mtime_str = datetime.datetime.fromtimestamp( - float(created_at) / 1000, timezone.utc - ).strftime("%Y-%m-%d %H:%M:%S UTC") - except Exception: - mtime_str = str(created_at) - - match_key = (file_name or "").lower() - if match_key in media_lookup: - match = media_lookup[match_key] - if match["local_path"]: - media_to_html(file_name, match["local_path"], report_folder) - display_path = _parse_path(match["data_path"]) - else: - display_path = "Cloud-only (Firebase Storage)" - - all_items.append( - ( - file_name or "Unknown", - "Submitted Document", - message or "", - conversation or "Untitled", - conv_uuid or "", - mtime_str, - size_db if size_db is not None else "", - mime_type or "", - display_path, - ) - ) - db.close() - except Exception as e: - logfunc(f"[nova_user_submissions] Error querying documents: {e}") - - # 3. New: Process user chat-submitted images (HistoryDetailImage) - try: - db = open_sqlite_db_readonly(nova_db) - cur = db.cursor() - cur.execute(""" - SELECT - hdi.url, - hdi.prompt, - hd.text, - hd.createdAt, - h.title, - h.UUID - FROM HistoryDetailImage hdi - INNER JOIN HistoryDetail hd ON hd.id = hdi.historyDetailID - INNER JOIN History h ON h.id = hd.historyID - WHERE hd.type = 0 - ORDER BY hd.createdAt DESC - """) - for ( - img_url, - prompt, - message, - created_at, - conversation, - conv_uuid, - ) in cur.fetchall(): - mtime_str = "" - if created_at: - try: - mtime_str = datetime.datetime.fromtimestamp( - float(created_at) / 1000, timezone.utc - ).strftime("%Y-%m-%d %H:%M:%S UTC") - except Exception: - mtime_str = str(created_at) - - # Extract the raw filename out of the remote url endpoint path - file_name = ( - os.path.basename(img_url.split("?")[0]) - if img_url - else "Unknown_Image.jpg" - ) - - # Form an inline context blending user's text message input with any associated image generation prompts - context_pieces = [] - if message: - context_pieces.append(f"Msg: {message}") - if prompt: - context_pieces.append(f"Prompt: {prompt}") - combined_context = " | ".join(context_pieces) - - match_key = file_name.lower() - if match_key in media_lookup: - match = media_lookup[match_key] - if match["local_path"]: - media_to_html(file_name, match["local_path"], report_folder) - display_path = _parse_path(match["data_path"]) - else: - display_path = "Cloud-only (Firebase Storage)" - all_items.append( - ( - file_name, - "Submitted Image", - combined_context, - conversation or "Untitled", - conv_uuid or "", - mtime_str, - "", # Size metadata is typically absent or cloud-side for image mappings - "image/jpeg", - display_path, - ) + # Documents + cur.execute( + "SELECT hdd.name, hdd.mimeType, hd.text, hd.createdAt FROM HistoryDetailDocument hdd INNER JOIN HistoryDetail hd ON hd.id = hdd.historyDetailID" ) - db.close() - except Exception as e: - logfunc(f"[nova_user_submissions] Error querying submitted images: {e}") - - # 4. Process standalone camera storage entries matching the application context - if media_db: - try: - db = open_sqlite_db_readonly(media_db) - cur = db.cursor() - cur.execute(""" - SELECT _display_name, _data, _size, date_added, mime_type - FROM files - WHERE bucket_display_name = 'Nova' OR _data LIKE '%/Nova/%' - ORDER BY date_added DESC - """) - for display_name, data_path, size, date_added, mime_type in cur.fetchall(): - mtime_str = "" - if date_added: - try: - mtime_str = datetime.datetime.fromtimestamp( - int(date_added), timezone.utc - ).strftime("%Y-%m-%d %H:%M:%S UTC") - except Exception: - mtime_str = str(date_added) + for name, mime, msg, ts in cur.fetchall(): + key = (name or "").lower() + dev_path = media_lookup.get(key) + media_ref = "" + ext_path = nova_files_lookup.get(key) + if ext_path: + media_ref = check_in_media( + artifact_info, report_folder, seeker, files_found, ext_path, name + ) + processed_paths.add(ext_path) - fname = display_name or ( - os.path.basename(str(data_path)) if data_path else "Unknown" + all_items.append( + ( + name, + "Document", + msg, + "", + "", + float(ts) / 1000 if ts else None, + "", + mime, + media_ref, + dev_path or "Cloud-only", + ) ) - clean_rel = str(data_path).replace("\\", "/").lstrip("/") - if clean_rel.startswith("storage/emulated/0/"): - clean_rel = clean_rel.replace( - "storage/emulated/0/", "data/media/0/", 1 + # Images + cur.execute( + "SELECT hdi.url, hdi.prompt, hd.text, hd.createdAt FROM HistoryDetailImage hdi INNER JOIN HistoryDetail hd ON hd.id = hdi.historyDetailID" + ) + for url, prompt, msg, ts in cur.fetchall(): + fname = os.path.basename(url.split("?")[0]) + key = fname.lower() + dev_path = media_lookup.get(key) + media_ref = "" + ext_path = nova_files_lookup.get(key) + if ext_path: + media_ref = check_in_media( + artifact_info, + report_folder, + seeker, + files_found, + ext_path, + fname, ) - - local_path = os.path.join(extraction_root, clean_rel) - if os.path.exists(local_path): - media_to_html(fname, local_path, report_folder) + processed_paths.add(ext_path) all_items.append( ( fname, - "Camera Photo", + "Image", + f"Msg: {msg} | Prompt: {prompt}", "", - "Camera photo (not associated with a message)", "", - mtime_str, - size if size is not None else "", - mime_type or "image/jpeg", - _parse_path(data_path), + float(ts) / 1000 if ts else None, + "", + "image/jpeg", + media_ref, + dev_path or "Cloud-only", ) ) - db.close() - except Exception as e: - logfunc(f"[nova_user_submissions] Error querying camera photos: {e}") - - if not all_items: - logfunc("[nova_user_submissions] No media found.") - return - # Deduplicate entries safely using filename and localized storage path attributes - deduped = [] - seen = set() - for row in all_items: - key = (row[0].lower(), row[8]) - if key in seen: - continue - seen.add(key) - deduped.append(row) - - report_name = "User Media Submissions" - report = ArtifactHtmlReport(report_name) - report.start_artifact_report(report_folder, report_name) - report.add_script() + # 3. Physical Sweep (Orphaned Files in /Nova) + for file_path in files_found: + if nova_path_part in str(file_path) and str(file_path) not in processed_paths: + fname = os.path.basename(file_path) + media_ref = check_in_media( + artifact_info, report_folder, seeker, files_found, str(file_path), fname + ) + all_items.append( + ( + fname, + "Orphaned Media", + "Found in /Nova folder (No DB link)", + "", + "", + None, + "", + "image/jpeg", + media_ref, + str(file_path), + ) + ) headers = ( "File Name", "Type", - "User Message / Context", - "Conversation Title", - "Conv. UUID", - "Date (UTC)", - "Size (Bytes)", - "MIME Type", + "Context", + "Conv. Title", + "UUID", + ("Date (UTC)", "datetime"), + "Size", + "MIME", + ("Media", "media"), "Path", ) - report.write_artifact_data_table( - headers, - deduped, - nova_db, - table_id="NovaUserSubmissions", - html_escape=True, - ) - report.end_artifact_report() - - tsv(report_folder, headers, deduped, report_name, nova_db) - logfunc(f"[nova_user_submissions] Found {len(deduped)} total items.") + return headers, all_items, nova_db or "Filesystem" diff --git a/scripts/artifacts/AIChatbotNovaSharedPrefs.py b/scripts/artifacts/AIChatbotNovaSharedPrefs.py index 55d92bac..7826a8c6 100644 --- a/scripts/artifacts/AIChatbotNovaSharedPrefs.py +++ b/scripts/artifacts/AIChatbotNovaSharedPrefs.py @@ -1,5 +1,4 @@ __artifacts_v2__ = { - # Key must match the function name exactly "get_nova_momo_prefs": { "name": "Shared Preferences - Account & Usage", "description": "Extracts account info, decoded Firebase JWT data, device identifiers, and usage metrics.", From be9e3e4c9c41deba11a05c124bc6925aaf825a80 Mon Sep 17 00:00:00 2001 From: Guilherme Guilherme Date: Sat, 30 May 2026 13:58:08 +0100 Subject: [PATCH 16/18] Update AIChatbotNovaConversations.py --- .../artifacts/AIChatbotNovaConversations.py | 226 +++++++----------- 1 file changed, 93 insertions(+), 133 deletions(-) diff --git a/scripts/artifacts/AIChatbotNovaConversations.py b/scripts/artifacts/AIChatbotNovaConversations.py index 714b9790..4089014b 100644 --- a/scripts/artifacts/AIChatbotNovaConversations.py +++ b/scripts/artifacts/AIChatbotNovaConversations.py @@ -9,32 +9,30 @@ "documents and user-submitted images." ), "author": "Guilherme Guilherme", - "version": "1.1", - "date": "2026-05-21", + "version": "1.2", + "date": "2026-05-30", "requirements": "none", "category": "AI Chatbot - Nova", "notes": "Sources: chat-ai.db and Android MediaStore databases.", "paths": ( - "*/com.scaleup.chatai/databases/chat-ai.db", - "*/com.android.providers.media/databases/external*.db", - "*/com.google.android.providers.media.module/databases/external*.db", + "**/com.scaleup.chatai/databases/chat-ai.db", + "**/com.android.providers.media/databases/external*.db", + "**/com.google.android.providers.media.module/databases/external*.db", ), "function": "get_nova_chatbot_conversations", - "output_types": "standard", + "output_types": ["standard", "lava"], "artifact_icon": "message-square", } } import os -import datetime -from datetime import timezone -from scripts.artifact_report import ArtifactHtmlReport +from types import SimpleNamespace from scripts.ilapfuncs import ( + artifact_processor, logfunc, - tsv, - timeline, open_sqlite_db_readonly, - media_to_html, + check_in_media, + get_file_path, ) CHAT_BOT_MODEL_MAP = { @@ -107,37 +105,15 @@ """ -def _parse_path(raw_path): - """Normalizes paths and slices them to always start at /data.""" - if not raw_path: - return "" - normalized = str(raw_path).replace("\\", "/") - if "/data/" in normalized: - return "/data/" + normalized.split("/data/", 1)[1] - elif "data/data/" in normalized: - return "/data/data/" + normalized.split("data/data/", 1)[1] - return normalized - - -def _convert_ms_timestamp(ms): - """Safely converts Unix millisecond timestamp using modern timezone.utc.""" - if ms is None: - return "" - try: - return datetime.datetime.fromtimestamp(float(ms) / 1000, timezone.utc).strftime( - "%Y-%m-%d %H:%M:%S UTC" - ) - except (OSError, OverflowError, ValueError): - return str(ms) - - +@artifact_processor def get_nova_chatbot_conversations(files_found, report_folder, seeker, wrap_text): logfunc("Processing data for Nova Full Conversations") - files_found = [ - x for x in files_found if not x.endswith(("-journal", "-wal", "-shm")) - ] - nova_db = next((str(x) for x in files_found if "chat-ai.db" in str(x)), None) + # Use framework-injected artifact_info + artifact_info = SimpleNamespace(**get_nova_chatbot_conversations.artifact_info) + artifact_info.filename = __file__ + + nova_db = get_file_path(files_found, "chat-ai.db") media_db = next( ( str(x) @@ -149,56 +125,40 @@ def get_nova_chatbot_conversations(files_found, report_folder, seeker, wrap_text if not nova_db: logfunc("[nova_chatbot_conversations] Nova database file not found.") - return + return (), [], "" - extraction_root = getattr(seeker, "search_dir", "") or "" - media_lookup = {} + # Pre-build lookup for local files in the extraction + nova_files_lookup = {} + nova_path_part = "Android/media/com.scaleup.chatai/Nova" + for f in files_found: + if nova_path_part in str(f): + nova_files_lookup[os.path.basename(f).lower()] = str(f) - # 1. Map the MediaStore database entries to verify files present on local storage + media_lookup = {} if media_db: try: - db = open_sqlite_db_readonly(media_db) - cur = db.cursor() - cur.execute(""" - SELECT _display_name, _data - FROM files - WHERE _data IS NOT NULL - """) - for display_name, data_path in cur.fetchall(): - local_path = "" - if data_path: - clean_rel = str(data_path).replace("\\", "/").lstrip("/") - if clean_rel.startswith("storage/emulated/0/"): - clean_rel = clean_rel.replace( - "storage/emulated/0/", "data/media/0/", 1 - ) - - candidate_path = os.path.join(extraction_root, clean_rel) - if os.path.exists(candidate_path): - local_path = candidate_path - - key = (display_name or os.path.basename(str(data_path))).lower() - media_lookup[key] = {"data_path": data_path, "local_path": local_path} - db.close() + with open_sqlite_db_readonly(media_db) as db: + cur = db.cursor() + cur.execute( + "SELECT _display_name, _data FROM files WHERE _data IS NOT NULL" + ) + for display_name, data_path in cur.fetchall(): + key = (display_name or os.path.basename(str(data_path))).lower() + media_lookup[key] = data_path except Exception as e: logfunc( f"[nova_chatbot_conversations] Error building MediaStore lookup: {e}" ) - # 2. Query chat timeline logs + rows_raw = [] try: - db = open_sqlite_db_readonly(nova_db) - cursor = db.cursor() - cursor.execute(QUERY) - rows_raw = cursor.fetchall() - db.close() + with open_sqlite_db_readonly(nova_db) as db: + cursor = db.cursor() + cursor.execute(QUERY) + rows_raw = cursor.fetchall() except Exception as e: logfunc(f"[nova_chatbot_conversations] Error reading {nova_db}: {e}") - return - - if not rows_raw: - logfunc(f"[nova_chatbot_conversations] No records found in {nova_db}.") - return + return (), [], "" headers = ( "Conv. ID", @@ -212,13 +172,15 @@ def get_nova_chatbot_conversations(files_found, report_folder, seeker, wrap_text "Message Text", "Token Count", "Reasoning Content", - "Message Timestamp (UTC)", + ("Message Timestamp (UTC)", "datetime"), "Image Attachment Prompts", - "Image Physical Path", - "Image Firebase Path", - "Document Attachment Name", - "Document Physical Path", - "Document Firebase Path", + ("Image Media", "media"), + "Image Path", + "Image Cloud URL", + "Document Name", + ("Document Media", "media"), + "Document Path", + "Document Cloud URL", "Link URL(s)", ) @@ -243,45 +205,54 @@ def get_nova_chatbot_conversations(files_found, report_folder, seeker, wrap_text else f"UNKNOWN ({raw_role})" ) - # A. Cross-reference documents using mapped MediaStore indices + # A. Documents doc_names_raw = row[16] - doc_phys_path_resolved = "Cloud-only (Firebase Storage)" + doc_media_ref = "" + doc_dev_path = "Cloud-only" if doc_names_raw: primary_doc = doc_names_raw.split(",")[0].strip() - match_key = primary_doc.lower() - if match_key in media_lookup: - match = media_lookup[match_key] - if match["local_path"]: - media_to_html(primary_doc, match["local_path"], report_folder) - doc_phys_path_resolved = _parse_path(match["data_path"]) - - # B. Cross-reference images inside conversation rows for local paths if available + key = primary_doc.lower() + doc_dev_path = media_lookup.get(key, "Cloud-only") + ext_path = nova_files_lookup.get(key) + if ext_path: + doc_media_ref = check_in_media( + artifact_info, + report_folder, + seeker, + files_found, + ext_path, + primary_doc, + ) + + # B. Images img_urls_raw = row[14] - img_phys_path_resolved = "Cloud-only (Firebase Storage)" + img_media_refs = [] + img_dev_path = "Cloud-only" if img_urls_raw: - # We evaluate the first image in the group for table display mapping - primary_img_url = img_urls_raw.split(",")[0].strip() - # Clean remote arguments out if present inside URL structures - img_name = os.path.basename(primary_img_url.split("?")[0]) - img_key = img_name.lower() - - if img_key in media_lookup: - match = media_lookup[img_key] - if match["local_path"]: - media_to_html(img_name, match["local_path"], report_folder) - img_phys_path_resolved = _parse_path(match["data_path"]) - - # Also ensure any secondary images in a comma-separated list pass safely into the gallery pipelines - if "," in img_urls_raw: - for url_part in img_urls_raw.split(",")[1:]: - sec_name = os.path.basename(url_part.strip().split("?")[0]) - sec_key = sec_name.lower() - if sec_key in media_lookup and media_lookup[sec_key]["local_path"]: - media_to_html( - sec_name, media_lookup[sec_key]["local_path"], report_folder - ) + # Handle comma separated images + url_parts = img_urls_raw.split(",") + for i, url_part in enumerate(url_parts): + img_name = os.path.basename(url_part.strip().split("?")[0]) + key = img_name.lower() + + # We map dev path for the first one for the column + if i == 0: + img_dev_path = media_lookup.get(key, "Cloud-only") + + ext_path = nova_files_lookup.get(key) + if ext_path: + ref = check_in_media( + artifact_info, + report_folder, + seeker, + files_found, + ext_path, + img_name, + ) + if ref: + img_media_refs.append(ref) rows.append( ( @@ -296,28 +267,17 @@ def get_nova_chatbot_conversations(files_found, report_folder, seeker, wrap_text row[9] or "", row[10] if row[10] is not None else "", row[11] or "", - _convert_ms_timestamp(row[12]), + float(row[12]) / 1000 if row[12] else None, row[15] or "", - img_phys_path_resolved, + img_media_refs[0] if img_media_refs else "", + img_dev_path, row[14] or "", row[16] or "", - doc_phys_path_resolved, + doc_media_ref, + doc_dev_path, row[18] or "", row[19] or "", ) ) - report_name = "Conversations (Full Detail)" - report = ArtifactHtmlReport(report_name) - report.start_artifact_report(report_folder, report_name) - report.add_script() - - # Enforce strict framework-side HTML cell escaping to protect against code injection - report.write_artifact_data_table(headers, rows, nova_db, html_escape=True) - report.end_artifact_report() - - tsv(report_folder, headers, rows, report_name, nova_db) - timeline(report_folder, report_name, rows, headers) - logfunc( - f"[nova_chatbot_conversations] Processed {len(rows)} timeline message items." - ) + return headers, rows, nova_db From b0bca4a2c7dc776d6843d1aa6118c5a47e36965a Mon Sep 17 00:00:00 2001 From: Guilherme Guilherme Date: Sat, 30 May 2026 14:01:45 +0100 Subject: [PATCH 17/18] Add assistantId Recognition --- .../artifacts/AIChatbotNovaConversations.py | 97 ++++++++++++++++--- 1 file changed, 83 insertions(+), 14 deletions(-) diff --git a/scripts/artifacts/AIChatbotNovaConversations.py b/scripts/artifacts/AIChatbotNovaConversations.py index 4089014b..5ff8b9fd 100644 --- a/scripts/artifacts/AIChatbotNovaConversations.py +++ b/scripts/artifacts/AIChatbotNovaConversations.py @@ -68,12 +68,77 @@ 29: "GPT-4o Mini", } +ASSISTANT_MAP = { + 1: "Margot Robbie", + 2: "Elon Musk", + 3: "Snoop Dogg", + 4: "Steve Jobs", + 5: "LeBron James", + 6: "Zendaya", + 7: "Steve Harvey", + 8: "Botanist", + 9: "Veterinarian", + 10: "Dietitian", + 11: "Accountant", + 12: "Architect", + 13: "Artist", + 14: "Chef", + 15: "Designer", + 16: "Software Developer", + 17: "Doctor", + 18: "Influencer", + 19: "Journalist", + 20: "Lawyer", + 21: "Math Teacher", + 22: "Personal Trainer", + 23: "Pilot", + 24: "Scientist", + 25: "Writer Assistant", + 26: "Taylor Swift", + 27: "Dermatologist", + 28: "Astrologer", + 29: "Fashion Designer", + 30: "Phoebe Buffay", + 31: "Thomas Shelby", + 32: "Barney Stinson", + 33: "Dwight Schrute", + 34: "Sub-Zero", + 35: "Pikachu", + 36: "Super Mario", + 37: "Hello Kitty", + 38: "Doctor Who", + 39: "Chandler Bing", + 40: "Michael Scott", + 41: "Walter White", + 42: "The Grinch", + 43: "Santa Claus", + 44: "Loki", + 45: "Dr. House", + 46: "Relationship Doctor", + 47: "Kylie Jenner", + 58: "Prophecy", +} + + +def get_assistant(assistant_id): + if not assistant_id: + return "" + try: + name = ASSISTANT_MAP.get(int(assistant_id)) + return ( + f"{name} ({assistant_id})" if name else f"Unknown Persona ({assistant_id})" + ) + except (TypeError, ValueError): + return str(assistant_id) + + QUERY = """ SELECT h.id AS conv_id, h.UUID AS conv_uuid, h.title AS conv_title, h.chatBotModel AS chat_bot_model, + h.assistantId AS assistant_id, h.softDeleted AS soft_deleted, h.syncState AS conv_sync_state, @@ -165,6 +230,7 @@ def get_nova_chatbot_conversations(files_found, report_folder, seeker, wrap_text "Conv. UUID", "Conv. Title", "AI Model", + "Assistant Persona", "Conv. Deleted", "Msg. ID", "Msg. UUID", @@ -196,7 +262,9 @@ def get_nova_chatbot_conversations(files_found, report_folder, seeker, wrap_text else f"Unknown Model ({model_int})" ) - raw_role = row[8] + assistant_persona = get_assistant(row[4]) + + raw_role = row[9] role_str = ( "USER" if raw_role == 0 @@ -206,7 +274,7 @@ def get_nova_chatbot_conversations(files_found, report_folder, seeker, wrap_text ) # A. Documents - doc_names_raw = row[16] + doc_names_raw = row[17] doc_media_ref = "" doc_dev_path = "Cloud-only" @@ -226,7 +294,7 @@ def get_nova_chatbot_conversations(files_found, report_folder, seeker, wrap_text ) # B. Images - img_urls_raw = row[14] + img_urls_raw = row[15] img_media_refs = [] img_dev_path = "Cloud-only" @@ -260,23 +328,24 @@ def get_nova_chatbot_conversations(files_found, report_folder, seeker, wrap_text row[1] or "", row[2] or "", model_name, - "DELETED" if row[4] == 1 else "No", - row[6], - row[7] or "", + assistant_persona, + "DELETED" if row[5] == 1 else "No", + row[7], + row[8] or "", role_str, - row[9] or "", - row[10] if row[10] is not None else "", - row[11] or "", - float(row[12]) / 1000 if row[12] else None, - row[15] or "", + row[10] or "", + row[11] if row[11] is not None else "", + row[12] or "", + float(row[13]) / 1000 if row[13] else None, + row[16] or "", img_media_refs[0] if img_media_refs else "", img_dev_path, - row[14] or "", - row[16] or "", + row[15] or "", + row[17] or "", doc_media_ref, doc_dev_path, - row[18] or "", row[19] or "", + row[20] or "", ) ) From eda1a65e7a2e7465250a8a51a98bd2b286c22648 Mon Sep 17 00:00:00 2001 From: Guilherme Guilherme <112128696+guilhermegui08@users.noreply.github.com> Date: Tue, 2 Jun 2026 15:48:11 +0100 Subject: [PATCH 18/18] Fix Modules --- scripts/artifacts/AIChatbotNovaConversations.py | 6 +++--- scripts/artifacts/AIChatbotNovaHistory.py | 10 +++++----- scripts/artifacts/AIChatbotNovaMediastore.py | 2 +- scripts/artifacts/AIChatbotNovaSharedPrefs.py | 10 +++++----- scripts/report_icons.py | 1 - 5 files changed, 14 insertions(+), 15 deletions(-) diff --git a/scripts/artifacts/AIChatbotNovaConversations.py b/scripts/artifacts/AIChatbotNovaConversations.py index 5ff8b9fd..498e5d74 100644 --- a/scripts/artifacts/AIChatbotNovaConversations.py +++ b/scripts/artifacts/AIChatbotNovaConversations.py @@ -171,7 +171,7 @@ def get_assistant(assistant_id): @artifact_processor -def get_nova_chatbot_conversations(files_found, report_folder, seeker, wrap_text): +def get_nova_chatbot_conversations(files_found, report_folder, seeker, _wrap_text): logfunc("Processing data for Nova Full Conversations") # Use framework-injected artifact_info @@ -210,7 +210,7 @@ def get_nova_chatbot_conversations(files_found, report_folder, seeker, wrap_text for display_name, data_path in cur.fetchall(): key = (display_name or os.path.basename(str(data_path))).lower() media_lookup[key] = data_path - except Exception as e: + except Exception as e: # pylint: disable=broad-exception-caught logfunc( f"[nova_chatbot_conversations] Error building MediaStore lookup: {e}" ) @@ -221,7 +221,7 @@ def get_nova_chatbot_conversations(files_found, report_folder, seeker, wrap_text cursor = db.cursor() cursor.execute(QUERY) rows_raw = cursor.fetchall() - except Exception as e: + except Exception as e: # pylint: disable=broad-exception-caught logfunc(f"[nova_chatbot_conversations] Error reading {nova_db}: {e}") return (), [], "" diff --git a/scripts/artifacts/AIChatbotNovaHistory.py b/scripts/artifacts/AIChatbotNovaHistory.py index 9d85fbc9..623c8b53 100644 --- a/scripts/artifacts/AIChatbotNovaHistory.py +++ b/scripts/artifacts/AIChatbotNovaHistory.py @@ -204,7 +204,7 @@ def get_role(role_int): @artifact_processor -def get_nova_chatbot_history(files_found, report_folder, seeker, wrap_text): +def get_nova_chatbot_history(files_found, _report_folder, _seeker, _wrap_text): db_path = get_file_path(files_found, "chat-ai.db") if not db_path: return (), [], "" @@ -267,7 +267,7 @@ def get_nova_chatbot_history(files_found, report_folder, seeker, wrap_text): @artifact_processor -def get_nova_chatbot_history_detail(files_found, report_folder, seeker, wrap_text): +def get_nova_chatbot_history_detail(files_found, _report_folder, _seeker, _wrap_text): db_path = get_file_path(files_found, "chat-ai.db") if not db_path: return (), [], "" @@ -337,7 +337,7 @@ def get_nova_chatbot_history_detail(files_found, report_folder, seeker, wrap_tex @artifact_processor -def get_nova_chatbot_documents(files_found, report_folder, seeker, wrap_text): +def get_nova_chatbot_documents(files_found, _report_folder, _seeker, _wrap_text): db_path = get_file_path(files_found, "chat-ai.db") if not db_path: return (), [], "" @@ -409,7 +409,7 @@ def get_nova_chatbot_documents(files_found, report_folder, seeker, wrap_text): @artifact_processor -def get_nova_chatbot_images(files_found, report_folder, seeker, wrap_text): +def get_nova_chatbot_images(files_found, _report_folder, _seeker, _wrap_text): db_path = get_file_path(files_found, "chat-ai.db") if not db_path: return (), [], "" @@ -482,7 +482,7 @@ def get_nova_chatbot_images(files_found, report_folder, seeker, wrap_text): @artifact_processor -def get_nova_chatbot_links(files_found, report_folder, seeker, wrap_text): +def get_nova_chatbot_links(files_found, _report_folder, _seeker, _wrap_text): db_path = get_file_path(files_found, "chat-ai.db") if not db_path: return (), [], "" diff --git a/scripts/artifacts/AIChatbotNovaMediastore.py b/scripts/artifacts/AIChatbotNovaMediastore.py index 671da28a..5d4217ac 100644 --- a/scripts/artifacts/AIChatbotNovaMediastore.py +++ b/scripts/artifacts/AIChatbotNovaMediastore.py @@ -32,7 +32,7 @@ @artifact_processor -def get_nova_user_submissions(files_found, report_folder, seeker, wrap_text): +def get_nova_user_submissions(files_found, report_folder, seeker, _wrap_text): logfunc("Processing Nova User Media (Logic + Physical Sweep)") # Use the artifact_info injected by the framework (cleaner than inspect.stack) diff --git a/scripts/artifacts/AIChatbotNovaSharedPrefs.py b/scripts/artifacts/AIChatbotNovaSharedPrefs.py index 7826a8c6..5f582867 100644 --- a/scripts/artifacts/AIChatbotNovaSharedPrefs.py +++ b/scripts/artifacts/AIChatbotNovaSharedPrefs.py @@ -40,7 +40,7 @@ def decode_jwt(token): payload_b64 += "=" * (4 - missing_padding) decoded = base64.b64decode(payload_b64).decode("utf-8") return json.loads(decoded) - except Exception: + except Exception: # pylint: disable=broad-exception-caught return None @@ -50,13 +50,13 @@ def format_key_name(key): @artifact_processor -def get_nova_momo_prefs(files_found, report_folder, seeker, wrap_text): +def get_nova_momo_prefs(files_found, _report_folder, _seeker, _wrap_text): file_path = str(files_found[0]) data_list = [] try: root = ET.parse(file_path).getroot() - except Exception as e: + except Exception as e: # pylint: disable=broad-exception-caught logfunc(f"[nova_momo_prefs] Error parsing XML: {e}") return (), [], "" @@ -87,13 +87,13 @@ def get_nova_momo_prefs(files_found, report_folder, seeker, wrap_text): @artifact_processor -def get_nova_adapty_prefs(files_found, report_folder, seeker, wrap_text): +def get_nova_adapty_prefs(files_found, _report_folder, _seeker, _wrap_text): file_path = str(files_found[0]) data_list = [] try: root = ET.parse(file_path).getroot() - except Exception as e: + except Exception as e: # pylint: disable=broad-exception-caught logfunc(f"[nova_adapty_prefs] Error parsing XML: {e}") return (), [], "" diff --git a/scripts/report_icons.py b/scripts/report_icons.py index 762c4414..857fed29 100644 --- a/scripts/report_icons.py +++ b/scripts/report_icons.py @@ -38,7 +38,6 @@ 'default': 'user' }, 'AGGREGATE DICTIONARY': 'book', - 'AI CHATBOT - NOVA': 'message-circle', 'AIRDROP DISCOVERABLE': 'search', 'AIRDROP EMAILS': 'send', 'AIRDROP NUMBERS': 'smartphone',