diff --git a/scripts/artifacts/AIChatbotNovaConversations.py b/scripts/artifacts/AIChatbotNovaConversations.py new file mode 100644 index 00000000..5f2aa7d6 --- /dev/null +++ b/scripts/artifacts/AIChatbotNovaConversations.py @@ -0,0 +1,349 @@ +__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", + "creation_date": "2026-05-30", + "last_update_date": "2026-08-10", + "requirements": "none", + "category": "AI Chatbot - Nova", + "notes": ("Sources: chat-ai.db and the Android MediaStore databases. The AI Model and Assistant Persona names are mapped from the numeric codes as observed in the app by the author; the mapping is not vendor-documented, so the stored code is shown beside every name and an unmapped code is reported as stored. A path shown as Not in MediaStore means no MediaStore row matched the file name, not that the file never existed locally. Attachment URLs are concatenated by SQLite and split on commas, so a URL containing a comma would split wrong; only the first image attachment is rendered as media. Developed against the author's own installation; no registered corpus image carries this app."), + "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 datetime +import os +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) + + +def _epoch_to_utc(value): + """chat-ai.db epochs are milliseconds; values below 1e11 are read as + seconds (the magnitudes cannot overlap for plausible dates).""" + try: + value = float(value) + except (TypeError, ValueError): + return '' + if value <= 0: + return '' + if value > 1e11: + value /= 1000 + try: + return datetime.datetime.fromtimestamp(value, datetime.timezone.utc) + except (ValueError, OverflowError, OSError): + return '' + + +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): + 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 = "Not in MediaStore" + + if doc_names_raw: + primary_doc = doc_names_raw.split(",")[0].strip() + key = primary_doc.lower() + doc_dev_path = media_lookup.get(key, "Not in MediaStore") + ext_path = nova_files_lookup.get(key) + if ext_path: + doc_media_ref = check_in_media(ext_path, name=primary_doc) or '' + + # B. Images + img_urls_raw = row[15] + img_media_refs = [] + img_dev_path = "Not in MediaStore" + + 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, "Not in MediaStore") + + ext_path = nova_files_lookup.get(key) + if ext_path: + ref = check_in_media(ext_path, name=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 "", + _epoch_to_utc(row[13]), + 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..295aa09d --- /dev/null +++ b/scripts/artifacts/AIChatbotNovaHistory.py @@ -0,0 +1,565 @@ +__artifacts_v2__ = { + "nova_chatbot_history": { + "name": "History", + "description": "Extracts conversation index from Nova AI Chatbot", + "author": "Guilherme Guilherme", + "creation_date": "2026-05-29", + "last_update_date": "2026-08-10", + "requirements": "none", + "category": "AI Chatbot - Nova", + "notes": ("The AI Model and Assistant Persona names are mapped from the numeric codes as observed in the app by the author; the mapping is not vendor-documented, so the stored code is shown beside every name and an unmapped code is reported as stored. Role reads type 0 as USER and 1 as ASSISTANT on the same basis. Developed against the author's own installation; no registered corpus image carries this app, so the parser is exercised by a synthetic round-trip until test data exists."), + "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", + "creation_date": "2026-05-29", + "last_update_date": "2026-08-10", + "requirements": "none", + "category": "AI Chatbot - Nova", + "notes": ("The AI Model and Assistant Persona names are mapped from the numeric codes as observed in the app by the author; the mapping is not vendor-documented, so the stored code is shown beside every name and an unmapped code is reported as stored. Role reads type 0 as USER and 1 as ASSISTANT on the same basis. Developed against the author's own installation; no registered corpus image carries this app, so the parser is exercised by a synthetic round-trip until test data exists."), + "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", + "creation_date": "2026-05-29", + "last_update_date": "2026-08-10", + "requirements": "none", + "category": "AI Chatbot - Nova", + "notes": ("The AI Model and Assistant Persona names are mapped from the numeric codes as observed in the app by the author; the mapping is not vendor-documented, so the stored code is shown beside every name and an unmapped code is reported as stored. Role reads type 0 as USER and 1 as ASSISTANT on the same basis. Developed against the author's own installation; no registered corpus image carries this app, so the parser is exercised by a synthetic round-trip until test data exists."), + "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", + "creation_date": "2026-05-29", + "last_update_date": "2026-08-10", + "requirements": "none", + "category": "AI Chatbot - Nova", + "notes": ("The AI Model and Assistant Persona names are mapped from the numeric codes as observed in the app by the author; the mapping is not vendor-documented, so the stored code is shown beside every name and an unmapped code is reported as stored. Role reads type 0 as USER and 1 as ASSISTANT on the same basis. Developed against the author's own installation; no registered corpus image carries this app, so the parser is exercised by a synthetic round-trip until test data exists."), + "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", + "creation_date": "2026-05-29", + "last_update_date": "2026-08-10", + "requirements": "none", + "category": "AI Chatbot - Nova", + "notes": ("The AI Model and Assistant Persona names are mapped from the numeric codes as observed in the app by the author; the mapping is not vendor-documented, so the stored code is shown beside every name and an unmapped code is reported as stored. Role reads type 0 as USER and 1 as ASSISTANT on the same basis. Developed against the author's own installation; no registered corpus image carries this app, so the parser is exercised by a synthetic round-trip until test data exists."), + "paths": ("*/com.scaleup.chatai/databases/chat-ai.db",), + "function": "get_nova_chatbot_links", + "output_types": "all", + "artifact_icon": "link", + }, +} + +import datetime + +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 _epoch_to_utc(value): + """Convert a stored epoch to an aware UTC datetime. + + chat-ai.db stores epochs in milliseconds; the two magnitudes do not + overlap for any plausible date (milliseconds are ~1e12, seconds ~1e9), + so a value below 1e11 is read as seconds. Returns '' for empty or + unparseable values. + """ + try: + value = float(value) + except (TypeError, ValueError): + return '' + if value <= 0: + return '' + if value > 1e11: + value /= 1000 + try: + return datetime.datetime.fromtimestamp(value, datetime.timezone.utc) + except (ValueError, OverflowError, OSError): + return '' + + + +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 + _epoch_to_utc(row[10]), # createdAt + _epoch_to_utc(row[11]), # updatedAt + _epoch_to_utc(row[12]), # lastModifiedAt + row[13] or 0, # message count + _epoch_to_utc(row[14]), # last message at + row[15] or "", # first user message + ) + ) + + headers = ( + "Conv ID", + "UUID", + "Title", + "AI Model", + "Assistant", + "Caption History ID", + "Starred", + "Soft Deleted", + "Sync State", + "Sync Retry Count", + ("Created At", "datetime"), + ("Updated At", "datetime"), + ("Last Modified At", "datetime"), + "Message Count", + ("Last Message At", "datetime"), + "First User Message", + ) + + 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 + _epoch_to_utc(row[12]), # createdAt + _epoch_to_utc(row[13]), # lastModifiedAt + 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", + "Msg UUID", + "Conv ID", + "Conv UUID", + "Conv Title", + "AI Model", + "Assistant Persona", + "Conv Deleted", + "Role", + "Message Text", + "Token Count", + "Reasoning Content", + ("Message Timestamp", "datetime"), + ("Last Modified At", "datetime"), + "Sync State", + "Sync Retry Count", + "Has Image", + "Has Document", + "Has Link", + ) + + 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 + _epoch_to_utc(row[10]), # createdAt + 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", + "Msg ID", + "Conv ID", + "Conv UUID", + "Conv Title", + "AI Model", + "Assistant Persona", + "Conv Deleted", + "Submitted By", + "Msg Text", + ("Msg Timestamp", "datetime"), + "File Name", + "MIME Type", + "Size", + "Source Type", + "Cloud Storage URL", # plain text; a URL column must never render as a live link + ) + + 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 + _epoch_to_utc(row[11]), # createdAt + 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", + "Msg ID", + "Conv ID", + "Conv UUID", + "Conv Title", + "AI Model", + "Assistant Persona", + "Conv Deleted", + "Submitted By", + "Msg Text", + ("Msg Timestamp", "datetime"), + "Prompt", + "State", + "Pipeline", + "Style ID", + "MIME Type", + "Cloud Storage URL", # plain text; a URL column must never render as a live link + ) + + 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 + _epoch_to_utc(row[6]), # createdAt + row[2] or "", # URL string output safely as text + ) + ) + + headers = ( + "Link ID", + "Msg ID", + "Conv ID", + "Conv UUID", + "Conv Title", + "AI Model", + "Assistant Persona", + "Conv Deleted", + "Msg Role", + "Msg Text", + ("Msg Timestamp", "datetime"), + "URL", # 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..5118a274 --- /dev/null +++ b/scripts/artifacts/AIChatbotNovaMediastore.py @@ -0,0 +1,187 @@ +__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", + "creation_date": "2026-05-30", + "last_update_date": "2026-08-10", + "requirements": "none", + "category": "AI Chatbot - Nova", + "notes": ("Integrates chat-ai.db history with filesystem discovery; chat-ai.db holds text records only, not the media bytes. A path shown as Not in MediaStore means no MediaStore row matched the file name. The MIME column is the value the database records where present and blank otherwise; the file bytes are not sniffed. Developed against the author's own installation; no registered corpus image carries this app."), + "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 datetime +import os +import sqlite3 +from scripts.ilapfuncs import ( + artifact_processor, + logfunc, + open_sqlite_db_readonly, + check_in_media, + get_file_path, +) + + +def _epoch_to_utc(value): + """chat-ai.db epochs are milliseconds; values below 1e11 are read as + seconds (the magnitudes cannot overlap for plausible dates).""" + try: + value = float(value) + except (TypeError, ValueError): + return '' + if value <= 0: + return '' + if value > 1e11: + value /= 1000 + try: + return datetime.datetime.fromtimestamp(value, datetime.timezone.utc) + except (ValueError, OverflowError, OSError): + return '' + + +@artifact_processor +def get_nova_user_submissions(files_found, _report_folder, _seeker, _wrap_text): + # 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: + # A MediaStore database matches on every image whether or not Nova is + # installed, and some builds carry no files table; a failed lookup + # build must not kill the artifact. + 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 name, path in cur.fetchall(): + key = (name or os.path.basename(str(path))).lower() + media_lookup[key] = path + except sqlite3.Error as exc: + logfunc(f"Nova user submissions - MediaStore lookup unavailable ({exc})") + + # 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(ext_path, name=name) or '' + processed_paths.add(ext_path) + + all_items.append( + ( + name, + "Document", + msg, + "", + "", + _epoch_to_utc(ts), + "", + mime or "", + media_ref, + dev_path or "Not in MediaStore", + ) + ) + + # 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(ext_path, name=fname) or '' + processed_paths.add(ext_path) + + all_items.append( + ( + fname, + "Image", + f"Msg: {msg} | Prompt: {prompt}", + "", + "", + _epoch_to_utc(ts), + "", + "", + media_ref, + dev_path or "Not in MediaStore", + ) + ) + + # 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(str(file_path), name=fname) or '' + all_items.append( + ( + fname, + "Orphaned Media", + "Found in /Nova folder (No DB link)", + "", + "", + None, + "", + "", + 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..2513d364 --- /dev/null +++ b/scripts/artifacts/AIChatbotNovaSharedPrefs.py @@ -0,0 +1,123 @@ +__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", + "creation_date": "2026-05-30", + "last_update_date": "2026-08-10", + "requirements": "none", + "category": "AI Chatbot - Nova", + "notes": ("Values are reported as stored in the preference files; JWT payloads are base64-decoded for display without signature verification. Developed against the author's own installation; no registered corpus image carries this app."), + "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", + "creation_date": "2026-05-30", + "last_update_date": "2026-08-10", + "requirements": "none", + "category": "AI Chatbot - Nova", + "notes": ("Values are reported as stored in the preference files; JWT payloads are base64-decoded for display without signature verification. Developed against the author's own installation; no registered corpus image carries this app."), + "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