disclaimer I'm a statistics major and this is going in my portfolio so please don't actually treat this as anything more than like a fun project

INSTRUCTIONS:

Step 1 open terminal in your macbook (lookup up terminal using the searchbar) and paste each chunk of code separately

sqlite3 ~/Library/Messages/chat.db 

Replace the 1234567890 with the persons actual phone number before you paste this ⬇️


.headers on
.mode csv
.output ourmessages.csv

SELECT
    datetime(message.date / 1000000000 + 978307200, 'unixepoch', 'localtime') AS timestamp,
    CASE
        WHEN message.is_from_me = 1 THEN 'Me'
        ELSE handle.id
    END AS sender,
    message.text
FROM message
JOIN chat_message_join 
    ON message.ROWID = chat_message_join.message_id
JOIN chat_handle_join 
    ON chat_message_join.chat_id = chat_handle_join.chat_id
JOIN handle 
    ON chat_handle_join.handle_id = handle.ROWID
WHERE handle.id LIKE '%9723576595%'
  AND message.text IS NOT NULL
ORDER BY message.date;

Step 2 open RStudio (you can download here) and paste the following code chunks. Im using a .rmd file (file > new file > R Markdown)

# -----packages and exploration
library(tidyverse)
install.packages("tidytext")
library(tidytext)
library(stringr)
install.packages("text2bvec")
library(text2vec)

chat_data <- read_csv("ourmessages.csv") %>%
  mutate(
    # labeling me and them
    sender = if_else(!is.na(sender) & str_trim(sender) == "Me", "Me", "Them"),
    text = str_trim(text)
  )

head(chat_data, 50)
nrow(chat_data)
str(chat_data)

chat_data$sender <- factor(chat_data$sender, levels = c("Me", "Them"))
chat_data$timestamp <- as.Date(chat_data$timestamp)
# -----cleaning dataset and create pairs
chat_pairs <- chat_data %>%
  # clean whitespace and force sender to trimmed case
  mutate(
    sender = str_trim(sender),
    text = str_trim(text)
  ) %>%
  filter(!is.na(text) & nchar(text) > 0) %>% 
  
  # pairing each of my messages with his immediate response
  mutate(
    next_sender = lead(sender),
    next_text = lead(text)
  ) %>%

  filter(tolower(sender) == "me" & tolower(next_sender) != "me") %>%
  select(my_prompt = text, their_response = next_text) %>%
  filter(!is.na(their_response)) %>%
  distinct() %>%
  mutate(pair_id = row_number())

# sanity checking to make sure npairs > 0...
nrow(chat_pairs)

# -----step2 unnest words from past prompts
prompt_tokens <- chat_pairs %>%
  unnest_tokens(word, my_prompt) %>%
  count(pair_id, word) %>%
  bind_tf_idf(word, pair_id, n)

# -----step3 prediction function
predict_response <- function(input_msg, pairs_df, tokens_df) {
  # tokenize the incoming firsthand user prompt
  input_df <- tibble(my_prompt = input_msg) %>%
    unnest_tokens(word, my_prompt) %>%
    count(word)

  matches <- tokens_df %>%
    inner_join(input_df, by = "word", suffix = c("_hist", "_input")) %>%
    # calculate similarity score based on TF-IDF weight
    group_by(pair_id) %>%
    summarise(score = sum(tf_idf * n_input), .groups = "drop") %>%
    arrange(desc(score))
  
  # handle cases with zero word overlap (fallback to random frequent response)
  if (nrow(matches) == 0) {
    message("no direct word matches found, returning a common response")
    return(sample(pairs_df$their_response, 1))
  }
  
  top_pair_id <- matches$pair_id[1]
  
  matched_pair <- pairs_df %>% 
    filter(pair_id == top_pair_id)
  
  return(matched_pair$their_response)
}
# shiny interface
library(shiny)
library(tidyverse)
library(tidytext)

# minimalist barebones shiny interface

ui <- fluidPage(
  tags$head(
    tags$style(HTML("
      body {
        background-color: #ffffff !important;
        font-family: monospace;
        padding: 50px;
        color: #000000;
      }
      .prompt-container {
        display: flex;
        align-items: center;
        font-size: 20px;
      }
      .prompt-symbol {
        margin-right: 12px;
        font-weight: bold;
        user-select: none;
      }
      .shiny-input-container {
        margin-bottom: 0 !important;
        width: 100% !important;
      }
      .form-control, .form-control:focus {
        border: none !important;
        box-shadow: none !important;
        outline: none !important;
        background-color: transparent !important;
        font-family: monospace;
        font-size: 20px;
        color: #000000;
        padding: 0 !important;
        height: auto !important;
      }
      .response-output {
        margin-top: 20px;
        font-size: 20px;
        font-family: monospace;
        color: #000000;
        white-space: pre-wrap;
      }
    "))
  ),
  
  # HTML form traps the Enter key press
  tags$form(
    id = "prompt_form",
    action = "javascript:void(0);", 
    onsubmit = "Shiny.setInputValue('submit_input', document.getElementById('user_input').value, {priority: 'event'});",
    
    div(class = "prompt-container",
      span(">", class = "prompt-symbol"),
      tags$input(
        id = "user_input", 
        type = "text", 
        class = "form-control", 
        placeholder = "",
        autocomplete = "off"
      )
    )
  ),
  
  div(class = "response-output",
    textOutput("prediction")
  )
)

# 3. SERVER LOGIC

server <- function(input, output, session) {
  
  # only triggers when 'submit_input' is fired (via Enter key)
  predicted_val <- eventReactive(input$submit_input, {
    req(input$submit_input)
    predict_response(input$submit_input, chat_pairs, prompt_tokens)
  })
  
  output$prediction <- renderText({
    predicted_val()
  })
}

shinyApp(ui = ui, server = server)