Fix streaming notifications and fediverse API handling

This commit is contained in:
Storm Dragon
2026-06-19 01:50:40 -04:00
parent fb897bd897
commit ae69534cd5
5 changed files with 373 additions and 151 deletions
+1
View File
@@ -0,0 +1 @@
/ttyverse.wiki/
+30
View File
@@ -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.
+15
View File
@@ -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 <path> [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 <query> +<count>` order vs current `+<count> <query>` 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.
+326 -150
View File
@@ -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 = &notification_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 = &notification_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 = &notification_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);