Fix streaming notifications and fediverse API handling
This commit is contained in:
@@ -0,0 +1 @@
|
||||
/ttyverse.wiki/
|
||||
@@ -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.
|
||||
+1
-1
Submodule extensions updated: 2b8bf031fa...8c4dcd1f87
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user