From 1df027c20f8ed851279d051e901036107bdbf2b1 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Wed, 30 Jul 2025 19:27:55 -0400 Subject: [PATCH] Profiles for users now saved when successfully creating a key, or if it doesn't exist already. Synced with profile on sever if available or use sane defaults if not. You can download the updated profile if changed on server /profiledown, or upload it if you make changes, /profileup, or let ttyverse smart sync changes, /syncprofile. --- ttyverse.pl | 582 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 582 insertions(+) diff --git a/ttyverse.pl b/ttyverse.pl index 829b654..01e094f 100755 --- a/ttyverse.pl +++ b/ttyverse.pl @@ -203,6 +203,7 @@ BEGIN { # user= # Username override # leader= # Command leader character + # === EXTENSION SETTINGS === # Extensions can be loaded with the -exts option # Extension preferences use the extpref_ prefix: @@ -220,6 +221,7 @@ EOF exit 0; } + $space_pad = " " x 1024; $background_is_ready = 0; @@ -2032,6 +2034,9 @@ if (length($credentials)) { # Update server configuration (character limits, etc.) &update_server_config; + + # Check and sync profile if enabled + &check_and_sync_profile($keyfile); } #### BOT/DAEMON MODE STARTUP #### @@ -4406,6 +4411,19 @@ EOF my $choices = $2; return &vote_on_poll($post_code, $choices); } + # profile management commands + if (m#^/syncprofile\s*$#i) { + &cmd_syncprofile; + return 0; + } + if (m#^/profileup\s*$#i) { + &cmd_profileup; + return 0; + } + if (m#^/profiledown\s*$#i) { + &cmd_profiledown; + return 0; + } # reply to all mentioned users if (m#^/(replyall|ra|replytoall)\s+([a-z]\d+)\s+(.+)$#i) { @@ -11266,4 +11284,568 @@ sub save_dm_seen_status { } } +######################################################################### +# Profile Management System Functions +######################################################################### + +# Simple URL encoding for form data +sub url_encode { + my $str = shift; + $str =~ s/([^A-Za-z0-9\-_\.~])/sprintf("%%%02X", ord($1))/ge; + return $str; +} + +# Get the profile file path for a given keyfile +sub get_profile_file_path { + my $keyfile_path = shift; + return "${keyfile_path}.profile"; +} + +# Check if profile file exists for given keyfile +sub profile_file_exists { + my $keyfile_path = shift; + my $profile_path = get_profile_file_path($keyfile_path); + return -f $profile_path; +} + +# Parse profile file into structured hash +sub parse_profile_file { + my $profile_path = shift; + my %profile = (); + my @custom_fields = (); + + unless (open(PROFILE, '<', $profile_path)) { + print $stdout "-- Error: Cannot read profile file $profile_path: $!\n"; + return undef; + } + + print $stdout "-- DEBUG: Parsing profile file with multi-line support\n" if ($verbose); + + my $current_field = ''; + my $current_value = ''; + + while (my $line = ) { + chomp($line); + + # Skip comments and empty lines + next if ($line =~ /^\s*#/ || $line =~ /^\s*$/); + + # Handle continuation lines (indented OR unindented for bio field) + if ($line =~ /^\s+(.*)$/ && $current_field) { + # Standard indented continuation + $current_value .= "\n$1"; + next; + } elsif ($current_field eq 'bio' && $line !~ /^\s*[^=]+\s*=/) { + # Bio field continuation (non-indented lines that aren't new field definitions) + $current_value .= "\n$line"; + next; + } + + # Finish previous field if we have one + if ($current_field) { + $current_value =~ s/^\s+|\s+$//g; # trim whitespace + if ($current_field eq 'custom_field') { + push @custom_fields, $current_value; + } else { + $profile{$current_field} = $current_value; + } + } + + # Parse new field + if ($line =~ /^\s*([^=]+?)\s*=\s*(.*)$/) { + $current_field = $1; + $current_value = $2; + } else { + $current_field = ''; + $current_value = ''; + } + } + + # Don't forget the last field + if ($current_field) { + $current_value =~ s/^\s+|\s+$//g; + if ($current_field eq 'custom_field') { + push @custom_fields, $current_value; + } else { + $profile{$current_field} = $current_value; + } + } + + close(PROFILE); + + $profile{'custom_fields'} = \@custom_fields; + return \%profile; +} + +# Generate profile template file +sub generate_profile_template { + my $keyfile_path = shift; + my $server_profile = shift || {}; + my $server_info = shift || {}; + + my $profile_path = get_profile_file_path($keyfile_path); + + unless (open(TEMPLATE, '>', $profile_path)) { + print $stdout "-- Error: Cannot create profile template $profile_path: $!\n"; + return 0; + } + + my $timestamp = scalar(localtime()); + my $server_name = $fediverseserver || 'your fediverse server'; + my $username = $server_profile->{'username'} || $server_profile->{'acct'} || 'your username'; + + # Clean HTML from bio for template + my $clean_bio = $server_profile->{'note'} || 'Your bio/description here'; + $clean_bio =~ s//\n/gi; # Convert
tags to newlines + $clean_bio =~ s/<\/p>\s*

/\n\n/gi; # Convert paragraph breaks to double newlines + $clean_bio =~ s/<[^>]*>//g; # Remove remaining HTML tags + $clean_bio =~ s/<//g; + $clean_bio =~ s/&/&/g; + $clean_bio =~ s/^\s+|\s+$//g; # Trim whitespace + + print TEMPLATE <{'display_name'} || 'Your Display Name']} +bio = $clean_bio +website = @{[$server_profile->{'url'} || 'https://example.com']} +location = @{[$server_profile->{'location'} || 'Your Location']} + +# === Custom Fields === +# Your server supports custom profile fields +# Format: custom_field = Field Name = Field Value +# Copy/paste this line for more custom fields +EOF + + # Add existing custom fields as examples + if ($server_profile->{'fields'} && ref($server_profile->{'fields'}) eq 'ARRAY') { + foreach my $field (@{$server_profile->{'fields'}}) { + my $name = $field->{'name'} || ''; + my $value = $field->{'value'} || ''; + # Remove HTML tags from value for template + $value =~ s/<[^>]*>//g; + print TEMPLATE "custom_field = $name = $value\n"; + } + } + + # Always add commented examples for guidance + print TEMPLATE "# \n"; + print TEMPLATE "# Examples (uncomment and edit to use):\n"; + print TEMPLATE "# custom_field = GitHub = yourusername\n"; + print TEMPLATE "# custom_field = Matrix = \@you:matrix.org\n"; + print TEMPLATE "# custom_field = Blog = https://myblog.com\n"; + print TEMPLATE "# custom_field = Website = https://yoursite.com\n"; + + close(TEMPLATE); + chmod(0600, $profile_path); # Keep private + + return 1; +} + +# Validate profile configuration +sub validate_profile_config { + my $profile = shift; + my @errors = (); + + # Check display_name length (varies by server, but 30 is common) + if ($profile->{'display_name'} && length($profile->{'display_name'}) > 100) { + push @errors, "Display name too long (max ~100 characters)"; + } + + # Check bio length (varies by server, but 500 is common for Mastodon) + if ($profile->{'bio'} && length($profile->{'bio'}) > 1000) { + push @errors, "Bio too long (max ~500-1000 characters depending on server)"; + } + + # Validate website URL format + if ($profile->{'website'} && $profile->{'website'} !~ /^https?:\/\//) { + push @errors, "Website must be a valid HTTP/HTTPS URL"; + } + + # Validate custom fields format + if ($profile->{'custom_fields'}) { + foreach my $field (@{$profile->{'custom_fields'}}) { + unless ($field =~ /^([^=]+)\s*=\s*(.+)$/) { + push @errors, "Invalid custom field format: '$field' (should be 'Name = Value')"; + } + } + } + + return \@errors; +} + +# Fetch current user profile from server +sub fetch_user_profile { + print $stdout "-- Fetching current profile from server...\n" if ($verbose); + + my $profile_data = &grabjson($credurl, 0, 0, 0, undef, 1); + if (!$profile_data) { + print $stdout "-- Error: Failed to fetch user profile from server\n"; + return undef; + } + + print $stdout "-- Profile fetched successfully\n" if ($verbose); + return $profile_data; +} + +# Update server profile with changes +sub update_server_profile { + my $changes = shift; + + unless (keys %$changes) { + print $stdout "-- No profile changes to update\n" if ($verbose); + return 1; + } + + print $stdout "-- Updating profile on server...\n" if ($verbose); + + # Build direct curl command (bypass TTYverse backticks due to form data issues) + my $update_url = "${apibase}/accounts/update_credentials"; + my $curl_cmd = "$baseagent"; + + # Add standard TTYverse curl options + foreach my $arg (@wend) { + $curl_cmd .= " '$arg'"; + } + + # Add OAuth Bearer token authentication + my $bearer_header = &signrequest($update_url, ''); + if ($bearer_header) { + $curl_cmd .= " $bearer_header"; + } + + # Add PATCH method + $curl_cmd .= " -X PATCH"; + + # Add multipart form data fields + $curl_cmd .= " -F 'display_name=" . $changes->{'display_name'} . "'" if defined($changes->{'display_name'}); + $curl_cmd .= " -F 'note=" . $changes->{'bio'} . "'" if defined($changes->{'bio'}); + + # Website is handled as a custom field, not direct 'url' parameter + if (defined($changes->{'website'})) { + # Find existing Website field or add as new field + my $website_field_index = 0; + $curl_cmd .= " -F 'fields_attributes[${website_field_index}][name]=Website'"; + $curl_cmd .= " -F 'fields_attributes[${website_field_index}][value]=" . $changes->{'website'} . "'"; + } + + # Custom fields (Mastodon multipart format) + if ($changes->{'custom_fields'}) { + my $field_index = 0; + foreach my $field_data (@{$changes->{'custom_fields'}}) { + if ($field_data =~ /^([^=]+)\s*=\s*(.+)$/) { + my ($name, $value) = ($1, $2); + $name =~ s/^\s+|\s+$//g; + $value =~ s/^\s+|\s+$//g; + # Escape single quotes in field values + $name =~ s/'/'\\''/g; + $value =~ s/'/'\\''/g; + $curl_cmd .= " -F 'fields_attributes[${field_index}][name]=$name'"; + $curl_cmd .= " -F 'fields_attributes[${field_index}][value]=$value'"; + $field_index++; + } + } + } + + # Add the URL + $curl_cmd .= " '$update_url'"; + + print $stdout "-- DEBUG: Profile update command: $curl_cmd\n" if ($superverbose); + + # Execute the request directly + my $return = `$curl_cmd 2>&1`; + my $exit_code = $?; + + print $stdout "-- DEBUG: HTTP exit code: $exit_code\n" if ($verbose); + print $stdout "-- DEBUG: API response length: " . length($return) . " bytes\n" if ($verbose); + print $stdout "-- DEBUG: API response: $return\n" if ($superverbose); + + # Check if update was successful - look for valid profile JSON response + if ($return =~ /"id":\s*"[^"]+"/s && $return =~ /"username":\s*"[^"]+"/s) { + print $stdout "-- Profile updated successfully\n"; + return 1; + } elsif ($return =~ /"error":\s*"([^"]+)"/s) { + print $stdout "-- Profile update failed: $1\n"; + return 0; + } else { + print $stdout "-- Profile update failed\n"; + print $stdout "-- Response: $return\n" if ($verbose); + return 0; + } +} + +# Compare local profile config with server profile +sub compare_profiles { + my $local_config = shift; + my $server_profile = shift; + my %changes = (); + + # Compare basic fields + my %field_map = ( + 'display_name' => 'display_name', + 'bio' => 'note', + 'website' => 'url' + ); + + foreach my $local_field (keys %field_map) { + my $server_field = $field_map{$local_field}; + my $local_value = $local_config->{$local_field} || ''; + my $server_value = $server_profile->{$server_field} || ''; + + # Clean server value (remove HTML tags from bio) + if ($server_field eq 'note') { + $server_value =~ s/<[^>]*>//g; + $server_value =~ s/<//g; + $server_value =~ s/&/&/g; + } + + if ($local_value ne $server_value) { + $changes{$local_field} = $local_value; + print $stdout "-- Profile change detected in $local_field: '$server_value' -> '$local_value'\n" if ($verbose); + } + } + + # Compare custom fields + my @local_fields = @{$local_config->{'custom_fields'} || []}; + my @server_fields = @{$server_profile->{'fields'} || []}; + + # Convert server fields to same format as local fields + my @server_field_strings = (); + foreach my $server_field (@server_fields) { + my $name = $server_field->{'name'} || ''; + my $value = $server_field->{'value'} || ''; + # Remove HTML tags + $value =~ s/<[^>]*>//g; + $value =~ s/<//g; + $value =~ s/&/&/g; + push @server_field_strings, "$name = $value"; + } + + # Check if custom fields have changed + my $local_fields_str = join('||', sort @local_fields); + my $server_fields_str = join('||', sort @server_field_strings); + + if ($local_fields_str ne $server_fields_str) { + $changes{'custom_fields'} = \@local_fields; + print $stdout "-- Profile change detected in custom fields\n" if ($verbose); + } + + return \%changes; +} + +# Interactive profile sync command - smart bi-directional sync +sub cmd_syncprofile { + print $stdout "-- Manual profile sync requested\n"; + + my $profile_file = get_profile_file_path($keyfile); + unless (profile_file_exists($keyfile)) { + print $stdout "-- Error: No profile file found. Create one first by restarting TTYverse.\n"; + return; + } + + my $profile_config = parse_profile_file($profile_file); + unless ($profile_config) { + print $stdout "-- Error: Could not parse profile file\n"; + return; + } + + if ($profile_config->{'use_profile'} ne 'true') { + print $stdout "-- Profile sync disabled. Set use_profile=true in $profile_file\n"; + return; + } + + # Validate and sync + my $validation_errors = validate_profile_config($profile_config); + if (@$validation_errors) { + print $stdout "-- Profile validation errors:\n"; + foreach my $error (@$validation_errors) { + print $stdout " $error\n"; + } + return; + } + + my $server_profile = fetch_user_profile(); + unless ($server_profile) { + print $stdout "-- Could not fetch server profile\n"; + return; + } + + my $changes = compare_profiles($profile_config, $server_profile); + + if (keys %$changes) { + print $stdout "-- Syncing profile changes: " . join(", ", keys %$changes) . "\n"; + my $success = update_server_profile($changes); + if ($success) { + print $stdout "-- Profile synced successfully\n"; + } else { + print $stdout "-- Profile sync failed\n"; + } + } else { + print $stdout "-- Profile already up to date\n"; + } +} + +# Force upload local profile to server +sub cmd_profileup { + print $stdout "-- Forcing profile upload (local → server)\n"; + + my $profile_file = get_profile_file_path($keyfile); + unless (profile_file_exists($keyfile)) { + print $stdout "-- Error: No profile file found\n"; + return; + } + + my $profile_config = parse_profile_file($profile_file); + unless ($profile_config) { + print $stdout "-- Error: Could not parse profile file\n"; + return; + } + + if ($profile_config->{'use_profile'} ne 'true') { + print $stdout "-- Profile sync disabled. Set use_profile=true in $profile_file\n"; + return; + } + + # Validate local config + my $validation_errors = validate_profile_config($profile_config); + if (@$validation_errors) { + print $stdout "-- Profile validation errors:\n"; + foreach my $error (@$validation_errors) { + print $stdout " $error\n"; + } + return; + } + + # Force upload all local fields + my %force_changes = (); + $force_changes{'display_name'} = $profile_config->{'display_name'} if $profile_config->{'display_name'}; + $force_changes{'bio'} = $profile_config->{'bio'} if $profile_config->{'bio'}; + $force_changes{'website'} = $profile_config->{'website'} if $profile_config->{'website'}; + $force_changes{'custom_fields'} = $profile_config->{'custom_fields'} if $profile_config->{'custom_fields'}; + + if (keys %force_changes) { + print $stdout "-- Uploading all profile fields to server\n"; + my $success = update_server_profile(\%force_changes); + if ($success) { + print $stdout "-- Profile uploaded successfully\n"; + } else { + print $stdout "-- Profile upload failed\n"; + } + } else { + print $stdout "-- No profile data to upload\n"; + } +} + +# Force download server profile to local file +sub cmd_profiledown { + print $stdout "-- Forcing profile download (server → local)\n"; + + my $profile_file = get_profile_file_path($keyfile); + + print $stdout "-- Fetching current server profile\n"; + my $server_profile = fetch_user_profile(); + unless ($server_profile) { + print $stdout "-- Could not fetch server profile\n"; + return; + } + + print $stdout "-- Regenerating local profile file with server data\n"; + if (generate_profile_template($keyfile, $server_profile)) { + print $stdout "-- Profile downloaded and saved to: $profile_file\n"; + print $stdout "-- Note: use_profile is set to false. Edit file and enable to use.\n"; + } else { + print $stdout "-- Failed to download profile\n"; + } +} + +# Main profile sync function called during startup +sub check_and_sync_profile { + my $keyfile_path = shift; + + return unless length($keyfile_path); + + + my $profile_file = get_profile_file_path($keyfile_path); + + # Check if profile file exists + unless (profile_file_exists($keyfile_path)) { + # Missing profile file - create template + print $stdout "-- Profile template not found, creating: $profile_file\n"; + print $stdout "-- DEBUG: Fetching current server profile to populate template\n" if ($verbose); + my $server_profile = fetch_user_profile(); + if (generate_profile_template($keyfile_path, $server_profile)) { + print $stdout "-- Profile template created: $profile_file\n"; + print $stdout "-- Edit file and set use_profile=true to enable profile management\n"; + print $stdout "-- DEBUG: Template populated with server data: display_name='" . + ($server_profile->{'display_name'} || 'none') . "', custom_fields=" . + scalar(@{$server_profile->{'fields'} || []}) . "\n" if ($verbose); + } else { + print $stdout "-- ERROR: Failed to create profile template\n"; + } + return; + } + + # Parse existing profile file + print $stdout "-- DEBUG: Parsing profile file: $profile_file\n" if ($verbose); + my $profile_config = parse_profile_file($profile_file); + unless ($profile_config) { + print $stdout "-- Error: Could not parse profile file $profile_file\n"; + return; + } + + print $stdout "-- DEBUG: Profile config loaded - use_profile='" . + ($profile_config->{'use_profile'} || 'unset') . "', fields=" . + scalar(keys %$profile_config) . "\n" if ($verbose); + + # Check if profile management is enabled + if ($profile_config->{'use_profile'} ne 'true') { + print $stdout "-- Profile file found but disabled (use_profile=false)\n" if ($verbose); + return; + } + + # Validate profile configuration + my $validation_errors = validate_profile_config($profile_config); + if (@$validation_errors) { + print $stdout "-- Profile validation errors:\n"; + foreach my $error (@$validation_errors) { + print $stdout " $error\n"; + } + return; + } + + # Fetch current server profile and compare + print $stdout "-- DEBUG: Fetching current server profile for comparison\n" if ($verbose); + my $server_profile = fetch_user_profile(); + unless ($server_profile) { + print $stdout "-- Could not fetch server profile for comparison\n"; + return; + } + + print $stdout "-- DEBUG: Comparing local config with server profile\n" if ($verbose); + my $changes = compare_profiles($profile_config, $server_profile); + + if (keys %$changes) { + print $stdout "-- Updating profile: " . join(", ", keys %$changes) . "\n"; + print $stdout "-- DEBUG: Changes detected: " . scalar(keys %$changes) . " fields\n" if ($verbose); + my $success = update_server_profile($changes); + if ($success) { + print $stdout "-- Profile updated successfully\n"; + } else { + print $stdout "-- Profile update failed - check network and authentication\n"; + } + } else { + print $stdout "-- Profile up to date\n" if ($verbose); + } +} + }