diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fef4e74 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/ttyverse.wiki/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4c4813b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,30 @@ +# TTYverse Agent Guide + +## Project Layout +- `ttyverse.pl` is the main (large) Perl script and source of truth. +- `ttytter.pl` is the original upstream script for reference. +- `extensions/` contains optional extensions. +- `ttyverse.wiki/` contains user and developer documentation. + +## Run +- `./ttyverse.pl` +- `perl ttyverse.pl` +- `./ttyverse.pl -version` + +## Testing +- No automated test suite is present. +- Minimum sanity check after edits: `perl -c ttyverse.pl`. +- Manual checks are expected (login, timelines, posting, notifications, media, polls). + +## Coding Standards +- Follow the existing code style in `ttyverse.pl`. +- New code should use camelCase variables, snake_case functions/methods, and PascalCase class names. +- For bash scripts, all variables must be camelCase (except system env vars). Run `shellcheck` on any edited bash script and fix issues. +- Log lines should be `message [timestamp]` (message first). + +## Accessibility +- Do not use Speech-Dispatcher or `spd-say` in GUI contexts. +- Prefer clear, complete labels and avoid keyboard traps. + +## System Interactions +- If a task requires `sudo`, ask the user to run it. diff --git a/extensions b/extensions index 2b8bf03..8c4dcd1 160000 --- a/extensions +++ b/extensions @@ -1 +1 @@ -Subproject commit 2b8bf031faf3801444c2fa305f7432ec844b07c8 +Subproject commit 8c4dcd1f8711822054910889ecb6935ef883ae59 diff --git a/todo.txt b/todo.txt new file mode 100644 index 0000000..3811400 --- /dev/null +++ b/todo.txt @@ -0,0 +1,15 @@ +TODO: Align code behavior with wiki documentation + +1) Implement `/post` or update wiki to match actual posting behavior (plain text without leading '/'). +2) Decide on config filenames and token storage: + - Code uses `~/.config/ttyverse/ttyverserc` and a keyfile. + - Wiki references `ttyverse_rc` and `oauth_credentials`. +3) Decide whether `/timeline` with no args should default to home and whether `/again` should follow a current timeline state; update code or docs accordingly. +4) Support `/media [message]` inline message, or update docs to reflect prompt-based message entry. +5) Add `/vote` range parsing (e.g., `1-3`) or remove range examples from docs. +6) Add `/unboost` and `/reblog` alias if desired, or remove from docs. +7) Decide on `/search +` order vs current `+ ` parsing; align docs/code. +8) Implement or remove docs for `/version`, `/about`, `/rate`, `/restart`, `/debug` (only `/versioncheck` exists now). +9) Review search scope language in wiki (instance-limited search) and adjust if needed. +10) Add `.m4a` fallback MIME mapping if `file` is unavailable, or trim media format list in docs. +11) Update posting visibility section: `/visibility` exists; docs currently say per-post control is future work. diff --git a/ttyverse.pl b/ttyverse.pl index c1e72ca..60e201c 100755 --- a/ttyverse.pl +++ b/ttyverse.pl @@ -257,7 +257,7 @@ EOF modifyliurl adduliurl delliurl getliurl getlisurl getfliurl creliurl delliurl deluliurl crefliurl delfliurl getuliurl getufliurl dmsenturl reblogurl boostsbyurl bookmarkurl unbookmarkurl dmidurl - statusliurl followliurl leaveliurl followersurl + statusliurl followliurl leaveliurl followersurl contexturl mediastatusurl oauthurl oauthauthurl oauthaccurl oauthbase wtrendurl atrendurl lookupidurl ); %opts_secret = map { $_ => 1} qw( @@ -283,7 +283,7 @@ EOF modifyliurl adduliurl delliurl getliurl getlisurl getfliurl creliurl delliurl deluliurl crefliurl delfliurl atrendurl getuliurl getufliurl dmsenturl reblogurl boostsbyurl bookmarkurl unbookmarkurl wtrendurl - statusliurl followliurl leaveliurl dmidurl nostreamreplies + statusliurl followliurl leaveliurl dmidurl contexturl mediastatusurl nostreamreplies frupdurl filterusers filterats filterrts filterflags filteratonly nofilter ); %opts_others = map { $_ => 1 } qw( @@ -386,6 +386,7 @@ EOF $rurl = "${apibase}/timelines/home"; $uurl = "${apibase}/accounts/%I/statuses"; $idurl = "${apibase}/statuses/%I"; + $contexturl = "${apibase}/statuses/%I/context"; $delurl = "${apibase}/statuses/%I"; $reblogurl = "${apibase}/statuses/%I/reblog"; $bookmarkurl = "${apibase}/statuses/%I/bookmark"; @@ -403,6 +404,8 @@ EOF $dmurl = "${apibase}/conversations"; $directurl = "${apibase}/conversations"; $listurl = "${apibase}/lists/%I/accounts"; + $statusliurl = "${apibase}/timelines/list/%I"; + $mediastatusurl = "${apibase}/media/%I"; $murl = "${apibase}/timelines/home"; $streamurl = "${oauthbase}/api/v1/streaming"; $publicurl = "${apibase}/timelines/public"; @@ -1204,6 +1207,9 @@ $fetch_id = $last_id || 0; # and we have to do it after loading modules because it might be in one. @notifytypes = (); +# Establish the default notification filter before it is compiled below. +$notifies ||= 'boost,favourite,favorite'; + # Initialize flag to suppress notifications during initial timeline load $initial_load_in_progress = 1; if (length($notifytype) && $notifytype ne '0' && @@ -1327,6 +1333,7 @@ $update ||= "${apibase}/statuses"; $rurl ||= "${apibase}/timelines/home"; # mentions are in home timeline $uurl ||= "${apibase}/accounts/%I/statuses"; $idurl ||= "${apibase}/statuses/%I"; +$contexturl ||= "${apibase}/statuses/%I/context"; $delurl ||= "${apibase}/statuses/%I"; $reblogurl ||= "${apibase}/statuses/%I/reblog"; @@ -1373,13 +1380,15 @@ $modifyliurl ||= "${apibase}/lists/%I"; # PUT update list $getliurl ||= "${apibase}/lists/%I/accounts"; # GET list members $adduliurl ||= "${apibase}/lists/%I/accounts"; # POST add members $deluliurl ||= "${apibase}/lists/%I/accounts"; # DELETE remove members -$statusliurl ||= "${apibase}/lists/%I"; # GET list timeline +$statusliurl ||= "${apibase}/timelines/list/%I"; # GET list timeline # Note: Fediverse doesn't have list subscriptions/followers like Twitter $streamurl ||= "${http_proto}://${fediverseserver}/api/v1/streaming"; $dostream ||= 0; $eventbuf ||= 0; +$mediastatusurl ||= "${apibase}/media/%I"; + $queryurl ||= "${apibase}/../v2/search"; # no more $trendurl in 2.1. $wtrendurl ||= "${apibase}/trends"; @@ -1416,7 +1425,6 @@ $dmmarkread = 1 if (!defined $dmmarkread); # Default to enabled $notificationpause = 2 if (!defined $notificationpause); # NOT ||= ... zero is a VALID value! $notificationpause = 0 if ($anonymous); $notificationpause = 0 if ($pause eq '0'); -$notifies ||= 'boost,favourite,favorite'; # Default notification types for sound alerts (both spellings) $ansi = ($noansi) ? 0 : (($ansi || $ENV{'TERM'} eq 'ansi' || $ENV{'TERM'} eq 'xterm-color') ? 1 : 0); @@ -3610,25 +3618,81 @@ EOF print $stdout "-- no such post (yet?): $code\n"; return 0; } - my $limit = 9; - my $id = $post->{'reblog'}->{'id_str'} || - $post->{'in_reply_to_status_id_str'}; + my $thread_ref = [ $post ]; - while ($id && $limit) { - print $stdout "-- thread: fetching $id\n" - if ($verbose); - my $next = &grabjson("${idurl}?id=${id}", 0, 0, 0, - undef, 1); - $id = 0; - $limit--; - if (defined($next) && ref($next) eq 'HASH') { - push(@{ $thread_ref }, - &fix_geo_api_data($next)); - $id = $next->{'reblog'}->{'id_str'} - || $next->{'in_reply_to_status_id_str'} - || 0; + my $thread_built = 0; + my $post_id = $post->{'id_str'} || $post->{'id'}; + + # Prefer the context endpoint when available + if (length($contexturl) && $post_id) { + my $context_url = $contexturl; + $context_url =~ s/%I/$post_id/g; + my $context_ref = &grabjson($context_url, 0, 0, 0, undef, 1); + if ($context_ref && ref($context_ref) eq 'HASH') { + my @thread = (); + my %seen = (); + + if ($context_ref->{'ancestors'} && ref($context_ref->{'ancestors'}) eq 'ARRAY') { + for my $ancestor (@{$context_ref->{'ancestors'}}) { + next unless (ref($ancestor) eq 'HASH'); + $ancestor = &map_single_status($ancestor, 0); + $ancestor = &normalizejson($ancestor); + my $ancestor_id = $ancestor->{'id_str'} || $ancestor->{'id'}; + next if ($ancestor_id && $seen{$ancestor_id}++); + push(@thread, $ancestor); + } + } + + if (ref($post) eq 'HASH') { + my $current_id = $post->{'id_str'} || $post->{'id'}; + if (!$current_id || !$seen{$current_id}++) { + push(@thread, $post); + } + } + + if ($context_ref->{'descendants'} && ref($context_ref->{'descendants'}) eq 'ARRAY') { + for my $descendant (@{$context_ref->{'descendants'}}) { + next unless (ref($descendant) eq 'HASH'); + $descendant = &map_single_status($descendant, 0); + $descendant = &normalizejson($descendant); + my $descendant_id = $descendant->{'id_str'} || $descendant->{'id'}; + next if ($descendant_id && $seen{$descendant_id}++); + push(@thread, $descendant); + } + } + + if (scalar(@thread)) { + $thread_ref = \@thread; + $thread_built = 1; + } } } + + # Fallback: walk reply chain manually + if (!$thread_built) { + my $limit = 9; + my $id = $post->{'reblog'}->{'id_str'} || + $post->{'in_reply_to_status_id_str'}; + $thread_ref = [ $post ]; + while ($id && $limit) { + print $stdout "-- thread: fetching $id\n" + if ($verbose); + my $next_url = $idurl; + $next_url =~ s/%I/$id/g; + my $next = &grabjson($next_url, 0, 0, 0, + undef, 1); + $id = 0; + $limit--; + if (defined($next) && ref($next) eq 'HASH') { + $next = &normalizejson(&map_single_status($next, 0)); + push(@{ $thread_ref }, $next); + $id = $next->{'reblog'}->{'id_str'} + || $next->{'in_reply_to_status_id_str'} + || 0; + } + } + } + &tdisplay($thread_ref, 'thread', 0, 1); # use the mini-menu return 0; } @@ -4592,34 +4656,58 @@ EOF } else { $comm = $_; } - + # Get list ID for fediverse API operations my $list_id = &lookup_list_id($lname); if (!$list_id && $comm ne 'create') { print $stdout "*** list '$lname' not found; use /lists to see available lists\n"; return 0; } - + my $return; my $state = "modified list $lname"; if ($comm eq 'create') { - my $desc; - ($args, $desc) = split(/\s+/, $args, 2) - if ($args =~ /\s+/); - if ($args ne 'public' && $args ne 'private') { - print $stdout - "-- must specify public or private\n"; - return 0; + my $desc = ''; + my $policy = ''; + if (length($args)) { + my $first = $args; + my $rest = ''; + ($first, $rest) = split(/\s+/, $args, 2) + if ($args =~ /\s+/); + my $first_lc = lc($first); + if ($first_lc =~ /^(followed|list|none|public|private)$/) { + $policy = $first_lc; + $desc = $rest; + } else { + $desc = $args; + } } - $state = "created new list $lname (mode $args)"; + + my $replies_policy = ''; + if ($policy eq 'public' || $policy eq 'private') { + print $stdout "-- NOTE: list visibility isn't supported by most fediverse servers; creating list without visibility\n"; + } elsif (length($policy)) { + $replies_policy = $policy; + } + + $state = "created new list $lname"; + $state .= " (replies $replies_policy)" if (length($replies_policy)); $desc = "description=".&url_oauth_sub($desc)."&" if (length($desc)); - $return = &postjson($creliurl, - "${desc}mode=$args&name=$lname"); - } elsif ($comm eq 'private' || $comm eq 'public') { + + my $post_data = "${desc}title=" . &url_oauth_sub($lname); + $post_data .= "&replies_policy=$replies_policy" if (length($replies_policy)); + + $return = &postjson($creliurl, $post_data); + } elsif ($comm eq 'private' || $comm eq 'public' || $comm eq 'followed' || $comm eq 'list' || $comm eq 'none') { + my $replies_policy = $comm; + if ($comm eq 'private' || $comm eq 'public') { + $replies_policy = ($comm eq 'private') ? 'list' : 'followed'; + print $stdout "-- NOTE: mapping '$comm' to replies_policy=$replies_policy\n"; + } my $url = $modifyliurl; $url =~ s/%I/$list_id/; - $return = &postjson($url, "replies_policy=$comm"); + $return = &postjson($url, "replies_policy=$replies_policy"); } elsif ($comm eq 'desc' || $comm eq 'description') { if (!length($args)) { print $stdout "-- $comm needs an argument\n"; @@ -6022,7 +6110,8 @@ sub start_streaming { if ($buf eq '') { if ($event_data) { # Package the event data for processing - $duf = "{ \"packet\" : \"data\", \"pid\" : \"$streampid\", \"curlpid\" : \"$curlpid\", \"payload\" : $event_data }"; + next HELLONURSE unless ($event_type =~ /^[A-Za-z0-9_.:-]+$/); + $duf = "{ \"packet\" : \"data\", \"pid\" : \"$streampid\", \"curlpid\" : \"$curlpid\", \"event\" : \"$event_type\", \"payload\" : $event_data }"; printf STDOUT ("%08x%s", length($duf), $duf); $packets_read++; } @@ -6228,15 +6317,23 @@ sub handle_fediverse_stream_event { sub handle_mastodon_stream_event { my $w = shift; my $event_type = $w->{'event'}; - my $payload_json = $w->{'payload'}; - - # Parse the JSON payload (Mastodon sends JSON as string) - my $payload = &map_mastodon_fields(&parsejson($payload_json)); + my $payloadData = $w->{'payload'}; + my $payload; + + # The streaming buffer normally decodes the complete packet before it gets + # here. Retain support for callers that provide the SSE data as JSON text. + if (ref($payloadData)) { + $payload = &map_mastodon_fields($payloadData); + } elsif ($event_type ne 'delete') { + $payload = &map_mastodon_fields(&parsejson($payloadData)); + } sleep 5 while ($suspend_output > 0); # Handle different Mastodon event types if ($event_type eq 'update' && !$notimeline) { + return unless (ref($payload) eq 'HASH'); + $payload = &normalizejson($payload); # New status/post my $sid = $payload->{'id_str'} || $payload->{'id'}; @@ -6262,18 +6359,21 @@ sub handle_mastodon_stream_event { } elsif ($event_type eq 'delete') { # Status deleted - payload is just the ID - print $streamout "-- post $payload_json deleted\n" if ($verbose); + print $streamout "-- post $payloadData deleted\n" if ($verbose); } elsif ($event_type eq 'notification') { # New notification (follow, mention, boost, favourite) + return unless (ref($payload) eq 'HASH'); + $payload = &prepare_notification($payload); + my $soundClass = ¬ification_sound_class($payload->{'type'}); &send_removereadline if ($termrl); - &$eventhandle($payload); + &$handle($payload, $soundClass); $wrapseq = 1; &send_repaint if ($termrl); } elsif ($event_type eq 'conversation') { # Direct message update - if ($dmpause) { + if ($dmpause && ref($payload) eq 'HASH') { &dmrefresh(0, 0, [ $payload ]); } } @@ -6730,16 +6830,11 @@ sub notifications_tdisplay { # Process each notification with proper type mapping for my $notification (@{ $my_json_ref }) { - my $type = $notification->{'type'} || 'default'; - # Map Mastodon notification types to sound categories - my $sound_class = $type; - $sound_class = 'mention' if ($type eq 'mention'); - $sound_class = 'boost' if ($type eq 'reblog'); - $sound_class = 'favourite' if ($type eq 'favourite' || $type eq 'favorite'); - $sound_class = 'follow' if ($type eq 'follow'); + $notification = &prepare_notification($notification); + my $soundClass = ¬ification_sound_class($notification->{'type'}); # Process as individual post with proper class - &$handle($notification, $sound_class); + &$handle($notification, $soundClass); } unless ($timestamp) { @@ -7161,6 +7256,35 @@ EOF } # refresh for notifications +sub notification_sound_class { + my $type = shift || 'default'; + return 'boost' if ($type eq 'reblog'); + return 'favourite' if ($type eq 'favourite' || $type eq 'favorite'); + return $type; +} + +sub prepare_notification { + my $notification = shift; + return $notification unless (ref($notification) eq 'HASH'); + + if (ref($notification->{'account'}) eq 'HASH') { + $notification->{'user'} = &map_user_object($notification->{'account'}); + } + if (ref($notification->{'status'}) eq 'HASH') { + my $status = &map_single_status($notification->{'status'}, 0); + $status = &normalizejson($status); + $notification->{'status'} = $status; + $notification->{'text'} = $status->{'text'} + unless (length($notification->{'text'})); + $notification->{'content'} = $status->{'content'} + unless (length($notification->{'content'})); + $notification->{'created_at'} ||= $status->{'created_at'}; + } + $notification->{'id_str'} ||= $notification->{'id'}; + $notification->{'text'} ||= ''; + return $notification; +} + sub notificationrefresh { my $interactive = shift; @@ -7242,7 +7366,7 @@ sub notificationrefresh { print $stdout "-- DEBUG: Starting notification display loop: $disp_max notifications\n" if ($verbose); for(my $i = $disp_max; $i > 0; $i--) { my $g = ($i-1); - my $notif = $my_json_ref->[$g]; + my $notif = &prepare_notification($my_json_ref->[$g]); print $stdout "-- DEBUG: Processing notification #$i (index $g)\n" if ($verbose); # Skip if missing data @@ -7271,14 +7395,10 @@ sub notificationrefresh { # Process notification with proper type mapping for sounds my $type = $notif->{'type'} || 'default'; - my $sound_class = $type; - $sound_class = 'mention' if ($type eq 'mention'); - $sound_class = 'boost' if ($type eq 'reblog'); - $sound_class = 'favourite' if ($type eq 'favourite' || $type eq 'favorite'); - $sound_class = 'follow' if ($type eq 'follow'); + my $soundClass = ¬ification_sound_class($type); # Always debug notification types to help troubleshoot - print $stdout "-- DEBUG: Received notification type='$type', mapped to sound_class='$sound_class'\n" if ($verbose || $type eq 'favourite'); + print $stdout "-- DEBUG: Received notification type='$type', mapped to sound_class='$soundClass'\n" if ($verbose || $type eq 'favourite'); # Use existing notification display handler if ($notification_display_only) { @@ -7287,8 +7407,8 @@ sub notificationrefresh { &$handle($notif, ''); # Empty class = no sound } else { # Call handle directly with the proper sound class - print $stdout "-- DEBUG: Calling handle with sound class '$sound_class'\n" if ($verbose); - &$handle($notif, $sound_class); + print $stdout "-- DEBUG: Calling handle with sound class '$soundClass'\n" if ($verbose); + &$handle($notif, $soundClass); } $printed++; } @@ -8585,6 +8705,18 @@ sub lookup_account_id { } } + # Fallback: direct account lookup (more reliable for remote accounts) + if (length($lookupidurl)) { + my $acct_query = &url_oauth_sub($username); + my $lookup_url = "${lookupidurl}?acct=${acct_query}&resolve=true"; + print $stdout "-- DEBUG: Lookup URL: $lookup_url\n" if ($verbose); + my $lookup_result = &grabjson($lookup_url, 0, 0, 0, undef, 1); + if ($lookup_result && ref($lookup_result) eq 'HASH' && $lookup_result->{'id'}) { + print $stdout "-- DEBUG: Lookup found account ID: " . $lookup_result->{'id'} . " for $username\n" if ($verbose); + return $lookup_result->{'id'}; + } + } + print $stdout "-- DEBUG: Could not find account ID for username: $username\n" if ($verbose); return undef; } @@ -9304,75 +9436,104 @@ sub detect_mime_type { 'wav' => 'audio/wav', 'flac' => 'audio/flac' ); - + return $ext_to_mime{$extension} || undef; } sub upload_media_and_post { my ($file_path, $mime_type, $alt_text, $post_message) = @_; - + print $stdout "-- Uploading media file...\n"; - + # Step 1: Upload media to get media ID - my $media_id = &upload_media_file($file_path, $mime_type, $alt_text); - unless ($media_id) { + my $media_info = &upload_media_file($file_path, $mime_type, $alt_text); + unless ($media_info && ref($media_info) eq 'HASH' && $media_info->{'id'}) { print $stdout "-- ERROR: Media upload failed\n"; return 0; } - + + my $media_id = $media_info->{'id'}; print $stdout "-- Media uploaded successfully (ID: $media_id)\n"; - + + # Step 1b: Wait for async processing if needed (videos/gifs/audio) + my $processing = $media_info->{'processing'} || ''; + my $media_url = $media_info->{'url'} || ''; + my $media_type = $media_info->{'type'} || ''; + my $needs_processing = 0; + if (length($processing) && $processing ne 'done' && $processing ne 'complete') { + $needs_processing = 1; + } elsif (!length($media_url) && $media_type =~ /^(video|gifv|audio)$/i) { + $needs_processing = 1; + } elsif (!length($media_url) && length($processing)) { + $needs_processing = 1; + } + + if ($needs_processing) { + print $stdout "-- Media processing in progress; waiting for server...\n"; + my $processed_info = &wait_for_media_processing($media_id); + if ($processed_info && ref($processed_info) eq 'HASH') { + $media_info = $processed_info; + print $stdout "-- Media processing complete\n"; + } else { + print $stdout "-- WARNING: Media processing still in progress; posting anyway\n"; + } + } + # Step 2: Create post with media attachment return &create_post_with_media($media_id, $post_message); } +sub run_multipart_request { + my ($method, $requestUrl, $formFields) = @_; + return ('', 1) unless (ref($formFields) eq 'ARRAY'); + + my @curlCommand = ($baseagent, @wend); + push(@curlCommand, '-H', "Authorization: Bearer $tokenkey") + if (length($tokenkey)); + push(@curlCommand, '-X', $method); + foreach my $formField (@{ $formFields }) { + push(@curlCommand, '-F', $formField); + } + push(@curlCommand, $requestUrl); + + print $stdout "-- DEBUG: Running multipart $method request to $requestUrl\n" + if ($verbose); + my $curlPid = open(my $curlFh, '-|', @curlCommand); + unless ($curlPid) { + print $stdout "-- ERROR: Could not start curl: $!\n"; + return ('', 1); + } + local $/; + my $response = <$curlFh>; + $response = '' unless (defined($response)); + close($curlFh); + my $exitCode = $? >> 8; + return ($response, $exitCode); +} + sub upload_media_file { my ($file_path, $mime_type, $alt_text) = @_; - - # Mastodon media upload endpoint - my $media_url = "${apibase}/media"; - - # Build curl command for multipart file upload - my $curl_cmd = "$baseagent"; - - # Add standard auth and options (from @wend) - foreach my $arg (@wend) { - $curl_cmd .= " '$arg'"; - } - - # Add OAuth Bearer token authentication - my $bearer_header = &signrequest($media_url, ''); - if ($bearer_header) { - $curl_cmd .= " $bearer_header"; - } - - # Add multipart form data - $curl_cmd .= " -X POST"; - $curl_cmd .= " -F 'file=\@$file_path;type=$mime_type'"; - - # Add alt-text if provided (for images) - if (length($alt_text)) { - my $escaped_alt = $alt_text; - $escaped_alt =~ s/'/'\\''/g; # Escape single quotes for shell - $curl_cmd .= " -F 'description=$escaped_alt'"; - } - - $curl_cmd .= " '$media_url'"; - - print $stdout "-- DEBUG: Upload command: $curl_cmd\n" if ($superverbose); + + # Use the asynchronous v2 upload endpoint. Processing status is queried + # through the v1 media endpoint below, as required by the Mastodon API. + my $media_url = $apibase; + $media_url = "${oauthbase}/api/v2/media" + unless ($media_url =~ s#/api/v1/?$#/api/v2/media#); + + my @formFields = ("file=\@$file_path;type=$mime_type"); + push(@formFields, "description=$alt_text") if (length($alt_text)); print $stdout "-- DEBUG: Uploading to $media_url\n" if ($verbose); - - # Execute the upload - my $response = `$curl_cmd 2>/dev/null`; - my $exit_code = $? >> 8; - + + my ($response, $exit_code) = + &run_multipart_request('POST', $media_url, \@formFields); + if ($exit_code != 0) { print $stdout "-- ERROR: Upload failed (curl exit code: $exit_code)\n"; return undef; } - + print $stdout "-- DEBUG: Upload response: $response\n" if ($superverbose); - + # Parse JSON response to get media ID my $media_data = &parsejson($response); unless ($media_data && ref($media_data) eq 'HASH') { @@ -9380,34 +9541,73 @@ sub upload_media_file { print $stdout "-- Response: $response\n" if ($verbose); return undef; } - + my $media_id = $media_data->{'id'}; unless ($media_id) { print $stdout "-- ERROR: No media ID in response\n"; print $stdout "-- Response: $response\n" if ($verbose); return undef; } - - return $media_id; + + return $media_data; +} + +sub wait_for_media_processing { + my ($media_id) = @_; + return undef unless (length($media_id)); + return undef unless (length($mediastatusurl)); + + my $status_url = $mediastatusurl; + $status_url =~ s/%I/$media_id/g; + + my $max_wait = 60; + my $poll_interval = 2; + my $elapsed = 0; + + while ($elapsed <= $max_wait) { + sleep($poll_interval) if ($elapsed); + + my $media_ref = &grabjson($status_url, 0, 0, 0, undef, 1); + unless ($media_ref && ref($media_ref) eq 'HASH') { + $elapsed += $poll_interval; + next; + } + + my $processing = $media_ref->{'processing'} || ''; + if (length($processing) && $processing =~ /^(in_progress|queued)$/i) { + print $stdout "-- DEBUG: Media still processing (${elapsed}s)\n" if ($verbose); + $elapsed += $poll_interval; + next; + } + unless (length($media_ref->{'url'})) { + print $stdout "-- DEBUG: Media URL not ready (${elapsed}s)\n" if ($verbose); + $elapsed += $poll_interval; + next; + } + + return $media_ref; + } + + return undef; } sub create_post_with_media { my ($media_id, $post_message) = @_; - + # Build post data my $post_data = "media_ids[]=$media_id"; if (length($post_message)) { $post_data .= "&status=" . &url_oauth_sub($post_message); } - + # Add current visibility setting $post_data .= "&visibility=$post_visibility" if defined($post_visibility); - + print $stdout "-- Creating post with media attachment...\n"; - + # Use existing postjson function for creating the post my $result = &postjson($update, $post_data); - + if ($result) { print $stdout "-- Post created successfully!\n"; return 1; @@ -11761,62 +11961,38 @@ sub update_server_profile { 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'}); + my @formFields = (); + push(@formFields, 'display_name=' . $changes->{'display_name'}) + if defined($changes->{'display_name'}); + push(@formFields, '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'} . "'"; + push(@formFields, "fields_attributes[${website_field_index}][name]=Website"); + push(@formFields, "fields_attributes[${website_field_index}][value]=" . $changes->{'website'}); } # Custom fields (Mastodon multipart format) if ($changes->{'custom_fields'}) { - my $field_index = 0; + my $field_index = defined($changes->{'website'}) ? 1 : 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'"; + push(@formFields, "fields_attributes[${field_index}][name]=$name"); + push(@formFields, "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 = $?; + + my ($return, $exit_code) = + &run_multipart_request('PATCH', $update_url, \@formFields); print $stdout "-- DEBUG: HTTP exit code: $exit_code\n" if ($verbose); print $stdout "-- DEBUG: API response length: " . length($return) . " bytes\n" if ($verbose);