Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
349 changes: 349 additions & 0 deletions scripts/artifacts/AIChatbotNovaConversations.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading