diff --git a/scripts/artifacts/AIChatbotNovaConversations.py b/scripts/artifacts/AIChatbotNovaConversations.py new file mode 100644 index 00000000..498e5d74 --- /dev/null +++ b/scripts/artifacts/AIChatbotNovaConversations.py @@ -0,0 +1,352 @@ +__artifacts_v2__ = { + "nova_chatbot_conversations": { + "name": "Conversations (Full Detail)", + "description": ( + "Reconstructs full conversations from the AI Chatbot - Nova app by joining " + "History, HistoryDetail, HistoryDetailImage, HistoryDetailDocument, and " + "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": "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", + ), + "function": "get_nova_chatbot_conversations", + "output_types": ["standard", "lava"], + "artifact_icon": "message-square", + } +} + +import os +from types import SimpleNamespace +from scripts.ilapfuncs import ( + artifact_processor, + logfunc, + open_sqlite_db_readonly, + check_in_media, + get_file_path, +) + +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", +} + +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, + + 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 hdd.name) AS doc_names, + GROUP_CONCAT(DISTINCT hdd.mimeType) AS doc_mimes, + GROUP_CONCAT(DISTINCT hdd.url) AS doc_urls, + + 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 +""" + + +@artifact_processor +def get_nova_chatbot_conversations(files_found, report_folder, seeker, _wrap_text): + logfunc("Processing data for Nova Full Conversations") + + # 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) + 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 (), [], "" + + # 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) + + media_lookup = {} + if media_db: + try: + 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: # pylint: disable=broad-exception-caught + logfunc( + f"[nova_chatbot_conversations] Error building MediaStore lookup: {e}" + ) + + rows_raw = [] + try: + with open_sqlite_db_readonly(nova_db) as db: + cursor = db.cursor() + cursor.execute(QUERY) + rows_raw = cursor.fetchall() + except Exception as e: # pylint: disable=broad-exception-caught + logfunc(f"[nova_chatbot_conversations] Error reading {nova_db}: {e}") + return (), [], "" + + headers = ( + "Conv. ID", + "Conv. UUID", + "Conv. Title", + "AI Model", + "Assistant Persona", + "Conv. Deleted", + "Msg. ID", + "Msg. UUID", + "Role", + "Message Text", + "Token Count", + "Reasoning Content", + ("Message Timestamp (UTC)", "datetime"), + "Image Attachment Prompts", + ("Image Media", "media"), + "Image Path", + "Image Cloud URL", + "Document Name", + ("Document Media", "media"), + "Document Path", + "Document Cloud URL", + "Link URL(s)", + ) + + 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})" + ) + + assistant_persona = get_assistant(row[4]) + + raw_role = row[9] + role_str = ( + "USER" + if raw_role == 0 + else "AI ASSISTANT" + if raw_role == 1 + else f"UNKNOWN ({raw_role})" + ) + + # A. Documents + doc_names_raw = row[17] + doc_media_ref = "" + doc_dev_path = "Cloud-only" + + if doc_names_raw: + primary_doc = doc_names_raw.split(",")[0].strip() + 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[15] + img_media_refs = [] + img_dev_path = "Cloud-only" + + if img_urls_raw: + # 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( + ( + row[0], + row[1] or "", + row[2] or "", + model_name, + assistant_persona, + "DELETED" if row[5] == 1 else "No", + row[7], + row[8] or "", + role_str, + 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[15] or "", + row[17] or "", + doc_media_ref, + doc_dev_path, + row[19] or "", + row[20] or "", + ) + ) + + return headers, rows, nova_db diff --git a/scripts/artifacts/AIChatbotNovaHistory.py b/scripts/artifacts/AIChatbotNovaHistory.py new file mode 100644 index 00000000..623c8b53 --- /dev/null +++ b/scripts/artifacts/AIChatbotNovaHistory.py @@ -0,0 +1,542 @@ +__artifacts_v2__ = { + "nova_chatbot_history": { + "name": "History", + "description": "Extracts conversation index 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", + "output_types": "all", + "artifact_icon": "message-square", + }, + "nova_chatbot_history_detail": { + "name": "HistoryDetail", + "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": "HistoryDetailDocuments", + "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": "HistoryDetailImages", + "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": "HistoryDetailLinks", + "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", + }, +} + +from scripts.ilapfuncs import ( + artifact_processor, + get_file_path, + get_sqlite_db_records, + logfunc, + open_sqlite_db_readonly, +) + +# Model mappings +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", +} + +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: + 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) + + +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): + db_path = get_file_path(files_found, "chat-ai.db") + if not db_path: + return (), [], "" + + 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 + """ + + 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 + + +@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 = ( + ("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"), + ) + + 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 + + +@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], # 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 + ) + ) + + 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 + """ + + 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 + ) + ) + + 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/AIChatbotNovaMediastore.py b/scripts/artifacts/AIChatbotNovaMediastore.py new file mode 100644 index 00000000..5d4217ac --- /dev/null +++ b/scripts/artifacts/AIChatbotNovaMediastore.py @@ -0,0 +1,180 @@ +__artifacts_v2__ = { + "nova_user_submissions": { + "name": "User Media Submissions", + "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.6", + "date": "2026-05-30", + "requirements": "none", + "category": "AI Chatbot - Nova", + "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", + "**/data/media/0/Android/media/com.scaleup.chatai/Nova/*", + ), + "function": "get_nova_user_submissions", + "output_types": ["standard", "lava"], + "artifact_icon": "folder", + } +} + +import os +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 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__ + + # Find databases + nova_db = get_file_path(files_found, "chat-ai.db") + media_db = next( + ( + str(x) + for x in files_found + if "external" in str(x) and str(x).endswith(".db") + ), + None, + ) + + all_items = [] + processed_paths = set() + + # 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. Database Indexed Lookup (MediaStore) + media_lookup = {} + if 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() + + # Documents + cur.execute( + "SELECT hdd.name, hdd.mimeType, hd.text, hd.createdAt FROM HistoryDetailDocument hdd INNER JOIN HistoryDetail hd ON hd.id = hdd.historyDetailID" + ) + 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) + + all_items.append( + ( + name, + "Document", + msg, + "", + "", + float(ts) / 1000 if ts else None, + "", + mime, + media_ref, + dev_path or "Cloud-only", + ) + ) + + # 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, + ) + processed_paths.add(ext_path) + + all_items.append( + ( + fname, + "Image", + f"Msg: {msg} | Prompt: {prompt}", + "", + "", + float(ts) / 1000 if ts else None, + "", + "image/jpeg", + media_ref, + dev_path or "Cloud-only", + ) + ) + + # 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", + "Context", + "Conv. Title", + "UUID", + ("Date (UTC)", "datetime"), + "Size", + "MIME", + ("Media", "media"), + "Path", + ) + + return headers, all_items, nova_db or "Filesystem" diff --git a/scripts/artifacts/AIChatbotNovaSharedPrefs.py b/scripts/artifacts/AIChatbotNovaSharedPrefs.py new file mode 100644 index 00000000..5f582867 --- /dev/null +++ b/scripts/artifacts/AIChatbotNovaSharedPrefs.py @@ -0,0 +1,121 @@ +__artifacts_v2__ = { + "get_nova_momo_prefs": { + "name": "Shared Preferences - Account & Usage", + "description": "Extracts account info, decoded Firebase JWT data, device identifiers, and usage metrics.", + "author": "Guilherme Guilherme", + "version": "4.0", + "date": "2026-05-30", + "requirements": "none", + "category": "AI Chatbot - Nova", + "paths": ("*/com.scaleup.chatai/shared_prefs/MOMO_PREF_FILE.xml",), + "output_types": "all", + "artifact_icon": "settings", + }, + "get_nova_adapty_prefs": { + "name": "Shared Preferences - Adapty Payment", + "description": "Extracts payment profile and installation metadata from AdaptySDKPrefs.xml.", + "author": "Guilherme Guilherme", + "version": "4.0", + "date": "2026-05-30", + "requirements": "none", + "category": "AI Chatbot - Nova", + "paths": ("*/com.scaleup.chatai/shared_prefs/AdaptySDKPrefs.xml",), + "output_types": "all", + "artifact_icon": "credit-card", + }, +} + + +import json +import base64 +import xml.etree.ElementTree as ET +from scripts.ilapfuncs import artifact_processor, logfunc + + +def decode_jwt(token): + try: + payload_b64 = token.split(".")[1] + 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: # pylint: disable=broad-exception-caught + return None + + +def format_key_name(key): + 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): + file_path = str(files_found[0]) + data_list = [] + + try: + root = ET.parse(file_path).getroot() + except Exception as e: # pylint: disable=broad-exception-caught + 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 + + if name == "KEY_USER_FIREBASE_ID_TOKEN": + decoded = decode_jwt(value) + if decoded: + 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_"): + data_list.append( + ("Settings", format_key_name(name), "Yes" if value == "true" else "No") + ) + else: + data_list.append(("Data", format_key_name(name), value)) + + return ("Category", "Field", "Value"), data_list, file_path + + +@artifact_processor +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: # pylint: disable=broad-exception-caught + logfunc(f"[nova_adapty_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 or not value: + continue + + if name == "LAST_SENT_INSTALLATION_META": + 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