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.
|
||||||
+291
-115
@@ -257,7 +257,7 @@ EOF
|
|||||||
modifyliurl adduliurl delliurl getliurl getlisurl getfliurl
|
modifyliurl adduliurl delliurl getliurl getlisurl getfliurl
|
||||||
creliurl delliurl deluliurl crefliurl delfliurl
|
creliurl delliurl deluliurl crefliurl delfliurl
|
||||||
getuliurl getufliurl dmsenturl reblogurl boostsbyurl bookmarkurl unbookmarkurl dmidurl
|
getuliurl getufliurl dmsenturl reblogurl boostsbyurl bookmarkurl unbookmarkurl dmidurl
|
||||||
statusliurl followliurl leaveliurl followersurl
|
statusliurl followliurl leaveliurl followersurl contexturl mediastatusurl
|
||||||
oauthurl oauthauthurl oauthaccurl oauthbase wtrendurl
|
oauthurl oauthauthurl oauthaccurl oauthbase wtrendurl
|
||||||
atrendurl lookupidurl
|
atrendurl lookupidurl
|
||||||
); %opts_secret = map { $_ => 1} qw(
|
); %opts_secret = map { $_ => 1} qw(
|
||||||
@@ -283,7 +283,7 @@ EOF
|
|||||||
modifyliurl adduliurl delliurl getliurl getlisurl getfliurl
|
modifyliurl adduliurl delliurl getliurl getlisurl getfliurl
|
||||||
creliurl delliurl deluliurl crefliurl delfliurl atrendurl
|
creliurl delliurl deluliurl crefliurl delfliurl atrendurl
|
||||||
getuliurl getufliurl dmsenturl reblogurl boostsbyurl bookmarkurl unbookmarkurl wtrendurl
|
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
|
frupdurl filterusers filterats filterrts filterflags
|
||||||
filteratonly nofilter
|
filteratonly nofilter
|
||||||
); %opts_others = map { $_ => 1 } qw(
|
); %opts_others = map { $_ => 1 } qw(
|
||||||
@@ -386,6 +386,7 @@ EOF
|
|||||||
$rurl = "${apibase}/timelines/home";
|
$rurl = "${apibase}/timelines/home";
|
||||||
$uurl = "${apibase}/accounts/%I/statuses";
|
$uurl = "${apibase}/accounts/%I/statuses";
|
||||||
$idurl = "${apibase}/statuses/%I";
|
$idurl = "${apibase}/statuses/%I";
|
||||||
|
$contexturl = "${apibase}/statuses/%I/context";
|
||||||
$delurl = "${apibase}/statuses/%I";
|
$delurl = "${apibase}/statuses/%I";
|
||||||
$reblogurl = "${apibase}/statuses/%I/reblog";
|
$reblogurl = "${apibase}/statuses/%I/reblog";
|
||||||
$bookmarkurl = "${apibase}/statuses/%I/bookmark";
|
$bookmarkurl = "${apibase}/statuses/%I/bookmark";
|
||||||
@@ -403,6 +404,8 @@ EOF
|
|||||||
$dmurl = "${apibase}/conversations";
|
$dmurl = "${apibase}/conversations";
|
||||||
$directurl = "${apibase}/conversations";
|
$directurl = "${apibase}/conversations";
|
||||||
$listurl = "${apibase}/lists/%I/accounts";
|
$listurl = "${apibase}/lists/%I/accounts";
|
||||||
|
$statusliurl = "${apibase}/timelines/list/%I";
|
||||||
|
$mediastatusurl = "${apibase}/media/%I";
|
||||||
$murl = "${apibase}/timelines/home";
|
$murl = "${apibase}/timelines/home";
|
||||||
$streamurl = "${oauthbase}/api/v1/streaming";
|
$streamurl = "${oauthbase}/api/v1/streaming";
|
||||||
$publicurl = "${apibase}/timelines/public";
|
$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.
|
# and we have to do it after loading modules because it might be in one.
|
||||||
@notifytypes = ();
|
@notifytypes = ();
|
||||||
|
|
||||||
|
# Establish the default notification filter before it is compiled below.
|
||||||
|
$notifies ||= 'boost,favourite,favorite';
|
||||||
|
|
||||||
# Initialize flag to suppress notifications during initial timeline load
|
# Initialize flag to suppress notifications during initial timeline load
|
||||||
$initial_load_in_progress = 1;
|
$initial_load_in_progress = 1;
|
||||||
if (length($notifytype) && $notifytype ne '0' &&
|
if (length($notifytype) && $notifytype ne '0' &&
|
||||||
@@ -1327,6 +1333,7 @@ $update ||= "${apibase}/statuses";
|
|||||||
$rurl ||= "${apibase}/timelines/home"; # mentions are in home timeline
|
$rurl ||= "${apibase}/timelines/home"; # mentions are in home timeline
|
||||||
$uurl ||= "${apibase}/accounts/%I/statuses";
|
$uurl ||= "${apibase}/accounts/%I/statuses";
|
||||||
$idurl ||= "${apibase}/statuses/%I";
|
$idurl ||= "${apibase}/statuses/%I";
|
||||||
|
$contexturl ||= "${apibase}/statuses/%I/context";
|
||||||
$delurl ||= "${apibase}/statuses/%I";
|
$delurl ||= "${apibase}/statuses/%I";
|
||||||
|
|
||||||
$reblogurl ||= "${apibase}/statuses/%I/reblog";
|
$reblogurl ||= "${apibase}/statuses/%I/reblog";
|
||||||
@@ -1373,13 +1380,15 @@ $modifyliurl ||= "${apibase}/lists/%I"; # PUT update list
|
|||||||
$getliurl ||= "${apibase}/lists/%I/accounts"; # GET list members
|
$getliurl ||= "${apibase}/lists/%I/accounts"; # GET list members
|
||||||
$adduliurl ||= "${apibase}/lists/%I/accounts"; # POST add members
|
$adduliurl ||= "${apibase}/lists/%I/accounts"; # POST add members
|
||||||
$deluliurl ||= "${apibase}/lists/%I/accounts"; # DELETE remove 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
|
# Note: Fediverse doesn't have list subscriptions/followers like Twitter
|
||||||
|
|
||||||
$streamurl ||= "${http_proto}://${fediverseserver}/api/v1/streaming";
|
$streamurl ||= "${http_proto}://${fediverseserver}/api/v1/streaming";
|
||||||
$dostream ||= 0;
|
$dostream ||= 0;
|
||||||
$eventbuf ||= 0;
|
$eventbuf ||= 0;
|
||||||
|
|
||||||
|
$mediastatusurl ||= "${apibase}/media/%I";
|
||||||
|
|
||||||
$queryurl ||= "${apibase}/../v2/search";
|
$queryurl ||= "${apibase}/../v2/search";
|
||||||
# no more $trendurl in 2.1.
|
# no more $trendurl in 2.1.
|
||||||
$wtrendurl ||= "${apibase}/trends";
|
$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 = 2 if (!defined $notificationpause); # NOT ||= ... zero is a VALID value!
|
||||||
$notificationpause = 0 if ($anonymous);
|
$notificationpause = 0 if ($anonymous);
|
||||||
$notificationpause = 0 if ($pause eq '0');
|
$notificationpause = 0 if ($pause eq '0');
|
||||||
$notifies ||= 'boost,favourite,favorite'; # Default notification types for sound alerts (both spellings)
|
|
||||||
$ansi = ($noansi) ? 0 :
|
$ansi = ($noansi) ? 0 :
|
||||||
(($ansi || $ENV{'TERM'} eq 'ansi' || $ENV{'TERM'} eq 'xterm-color')
|
(($ansi || $ENV{'TERM'} eq 'ansi' || $ENV{'TERM'} eq 'xterm-color')
|
||||||
? 1 : 0);
|
? 1 : 0);
|
||||||
@@ -3610,25 +3618,81 @@ EOF
|
|||||||
print $stdout "-- no such post (yet?): $code\n";
|
print $stdout "-- no such post (yet?): $code\n";
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
my $thread_ref = [ $post ];
|
||||||
|
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 $limit = 9;
|
||||||
my $id = $post->{'reblog'}->{'id_str'} ||
|
my $id = $post->{'reblog'}->{'id_str'} ||
|
||||||
$post->{'in_reply_to_status_id_str'};
|
$post->{'in_reply_to_status_id_str'};
|
||||||
my $thread_ref = [ $post ];
|
$thread_ref = [ $post ];
|
||||||
while ($id && $limit) {
|
while ($id && $limit) {
|
||||||
print $stdout "-- thread: fetching $id\n"
|
print $stdout "-- thread: fetching $id\n"
|
||||||
if ($verbose);
|
if ($verbose);
|
||||||
my $next = &grabjson("${idurl}?id=${id}", 0, 0, 0,
|
my $next_url = $idurl;
|
||||||
|
$next_url =~ s/%I/$id/g;
|
||||||
|
my $next = &grabjson($next_url, 0, 0, 0,
|
||||||
undef, 1);
|
undef, 1);
|
||||||
$id = 0;
|
$id = 0;
|
||||||
$limit--;
|
$limit--;
|
||||||
if (defined($next) && ref($next) eq 'HASH') {
|
if (defined($next) && ref($next) eq 'HASH') {
|
||||||
push(@{ $thread_ref },
|
$next = &normalizejson(&map_single_status($next, 0));
|
||||||
&fix_geo_api_data($next));
|
push(@{ $thread_ref }, $next);
|
||||||
$id = $next->{'reblog'}->{'id_str'}
|
$id = $next->{'reblog'}->{'id_str'}
|
||||||
|| $next->{'in_reply_to_status_id_str'}
|
|| $next->{'in_reply_to_status_id_str'}
|
||||||
|| 0;
|
|| 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&tdisplay($thread_ref, 'thread', 0, 1); # use the mini-menu
|
&tdisplay($thread_ref, 'thread', 0, 1); # use the mini-menu
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -4603,23 +4667,47 @@ EOF
|
|||||||
my $return;
|
my $return;
|
||||||
my $state = "modified list $lname";
|
my $state = "modified list $lname";
|
||||||
if ($comm eq 'create') {
|
if ($comm eq 'create') {
|
||||||
my $desc;
|
my $desc = '';
|
||||||
($args, $desc) = split(/\s+/, $args, 2)
|
my $policy = '';
|
||||||
|
if (length($args)) {
|
||||||
|
my $first = $args;
|
||||||
|
my $rest = '';
|
||||||
|
($first, $rest) = split(/\s+/, $args, 2)
|
||||||
if ($args =~ /\s+/);
|
if ($args =~ /\s+/);
|
||||||
if ($args ne 'public' && $args ne 'private') {
|
my $first_lc = lc($first);
|
||||||
print $stdout
|
if ($first_lc =~ /^(followed|list|none|public|private)$/) {
|
||||||
"-- must specify public or private\n";
|
$policy = $first_lc;
|
||||||
return 0;
|
$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)."&"
|
$desc = "description=".&url_oauth_sub($desc)."&"
|
||||||
if (length($desc));
|
if (length($desc));
|
||||||
$return = &postjson($creliurl,
|
|
||||||
"${desc}mode=$args&name=$lname");
|
my $post_data = "${desc}title=" . &url_oauth_sub($lname);
|
||||||
} elsif ($comm eq 'private' || $comm eq 'public') {
|
$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;
|
my $url = $modifyliurl;
|
||||||
$url =~ s/%I/$list_id/;
|
$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') {
|
} elsif ($comm eq 'desc' || $comm eq 'description') {
|
||||||
if (!length($args)) {
|
if (!length($args)) {
|
||||||
print $stdout "-- $comm needs an argument\n";
|
print $stdout "-- $comm needs an argument\n";
|
||||||
@@ -6022,7 +6110,8 @@ sub start_streaming {
|
|||||||
if ($buf eq '') {
|
if ($buf eq '') {
|
||||||
if ($event_data) {
|
if ($event_data) {
|
||||||
# Package the event data for processing
|
# 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);
|
printf STDOUT ("%08x%s", length($duf), $duf);
|
||||||
$packets_read++;
|
$packets_read++;
|
||||||
}
|
}
|
||||||
@@ -6228,15 +6317,23 @@ sub handle_fediverse_stream_event {
|
|||||||
sub handle_mastodon_stream_event {
|
sub handle_mastodon_stream_event {
|
||||||
my $w = shift;
|
my $w = shift;
|
||||||
my $event_type = $w->{'event'};
|
my $event_type = $w->{'event'};
|
||||||
my $payload_json = $w->{'payload'};
|
my $payloadData = $w->{'payload'};
|
||||||
|
my $payload;
|
||||||
|
|
||||||
# Parse the JSON payload (Mastodon sends JSON as string)
|
# The streaming buffer normally decodes the complete packet before it gets
|
||||||
my $payload = &map_mastodon_fields(&parsejson($payload_json));
|
# 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);
|
sleep 5 while ($suspend_output > 0);
|
||||||
|
|
||||||
# Handle different Mastodon event types
|
# Handle different Mastodon event types
|
||||||
if ($event_type eq 'update' && !$notimeline) {
|
if ($event_type eq 'update' && !$notimeline) {
|
||||||
|
return unless (ref($payload) eq 'HASH');
|
||||||
|
$payload = &normalizejson($payload);
|
||||||
# New status/post
|
# New status/post
|
||||||
my $sid = $payload->{'id_str'} || $payload->{'id'};
|
my $sid = $payload->{'id_str'} || $payload->{'id'};
|
||||||
|
|
||||||
@@ -6262,18 +6359,21 @@ sub handle_mastodon_stream_event {
|
|||||||
}
|
}
|
||||||
elsif ($event_type eq 'delete') {
|
elsif ($event_type eq 'delete') {
|
||||||
# Status deleted - payload is just the ID
|
# 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') {
|
elsif ($event_type eq 'notification') {
|
||||||
# New notification (follow, mention, boost, favourite)
|
# 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);
|
&send_removereadline if ($termrl);
|
||||||
&$eventhandle($payload);
|
&$handle($payload, $soundClass);
|
||||||
$wrapseq = 1;
|
$wrapseq = 1;
|
||||||
&send_repaint if ($termrl);
|
&send_repaint if ($termrl);
|
||||||
}
|
}
|
||||||
elsif ($event_type eq 'conversation') {
|
elsif ($event_type eq 'conversation') {
|
||||||
# Direct message update
|
# Direct message update
|
||||||
if ($dmpause) {
|
if ($dmpause && ref($payload) eq 'HASH') {
|
||||||
&dmrefresh(0, 0, [ $payload ]);
|
&dmrefresh(0, 0, [ $payload ]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6730,16 +6830,11 @@ sub notifications_tdisplay {
|
|||||||
|
|
||||||
# Process each notification with proper type mapping
|
# Process each notification with proper type mapping
|
||||||
for my $notification (@{ $my_json_ref }) {
|
for my $notification (@{ $my_json_ref }) {
|
||||||
my $type = $notification->{'type'} || 'default';
|
$notification = &prepare_notification($notification);
|
||||||
# Map Mastodon notification types to sound categories
|
my $soundClass = ¬ification_sound_class($notification->{'type'});
|
||||||
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');
|
|
||||||
|
|
||||||
# Process as individual post with proper class
|
# Process as individual post with proper class
|
||||||
&$handle($notification, $sound_class);
|
&$handle($notification, $soundClass);
|
||||||
}
|
}
|
||||||
|
|
||||||
unless ($timestamp) {
|
unless ($timestamp) {
|
||||||
@@ -7161,6 +7256,35 @@ EOF
|
|||||||
}
|
}
|
||||||
|
|
||||||
# refresh for notifications
|
# 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 {
|
sub notificationrefresh {
|
||||||
my $interactive = shift;
|
my $interactive = shift;
|
||||||
|
|
||||||
@@ -7242,7 +7366,7 @@ sub notificationrefresh {
|
|||||||
print $stdout "-- DEBUG: Starting notification display loop: $disp_max notifications\n" if ($verbose);
|
print $stdout "-- DEBUG: Starting notification display loop: $disp_max notifications\n" if ($verbose);
|
||||||
for(my $i = $disp_max; $i > 0; $i--) {
|
for(my $i = $disp_max; $i > 0; $i--) {
|
||||||
my $g = ($i-1);
|
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);
|
print $stdout "-- DEBUG: Processing notification #$i (index $g)\n" if ($verbose);
|
||||||
|
|
||||||
# Skip if missing data
|
# Skip if missing data
|
||||||
@@ -7271,14 +7395,10 @@ sub notificationrefresh {
|
|||||||
|
|
||||||
# Process notification with proper type mapping for sounds
|
# Process notification with proper type mapping for sounds
|
||||||
my $type = $notif->{'type'} || 'default';
|
my $type = $notif->{'type'} || 'default';
|
||||||
my $sound_class = $type;
|
my $soundClass = ¬ification_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');
|
|
||||||
|
|
||||||
# Always debug notification types to help troubleshoot
|
# 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
|
# Use existing notification display handler
|
||||||
if ($notification_display_only) {
|
if ($notification_display_only) {
|
||||||
@@ -7287,8 +7407,8 @@ sub notificationrefresh {
|
|||||||
&$handle($notif, ''); # Empty class = no sound
|
&$handle($notif, ''); # Empty class = no sound
|
||||||
} else {
|
} else {
|
||||||
# Call handle directly with the proper sound class
|
# Call handle directly with the proper sound class
|
||||||
print $stdout "-- DEBUG: Calling handle with sound class '$sound_class'\n" if ($verbose);
|
print $stdout "-- DEBUG: Calling handle with sound class '$soundClass'\n" if ($verbose);
|
||||||
&$handle($notif, $sound_class);
|
&$handle($notif, $soundClass);
|
||||||
}
|
}
|
||||||
$printed++;
|
$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);
|
print $stdout "-- DEBUG: Could not find account ID for username: $username\n" if ($verbose);
|
||||||
return undef;
|
return undef;
|
||||||
}
|
}
|
||||||
@@ -9314,57 +9446,86 @@ sub upload_media_and_post {
|
|||||||
print $stdout "-- Uploading media file...\n";
|
print $stdout "-- Uploading media file...\n";
|
||||||
|
|
||||||
# Step 1: Upload media to get media ID
|
# Step 1: Upload media to get media ID
|
||||||
my $media_id = &upload_media_file($file_path, $mime_type, $alt_text);
|
my $media_info = &upload_media_file($file_path, $mime_type, $alt_text);
|
||||||
unless ($media_id) {
|
unless ($media_info && ref($media_info) eq 'HASH' && $media_info->{'id'}) {
|
||||||
print $stdout "-- ERROR: Media upload failed\n";
|
print $stdout "-- ERROR: Media upload failed\n";
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
my $media_id = $media_info->{'id'};
|
||||||
print $stdout "-- Media uploaded successfully (ID: $media_id)\n";
|
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
|
# Step 2: Create post with media attachment
|
||||||
return &create_post_with_media($media_id, $post_message);
|
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 {
|
sub upload_media_file {
|
||||||
my ($file_path, $mime_type, $alt_text) = @_;
|
my ($file_path, $mime_type, $alt_text) = @_;
|
||||||
|
|
||||||
# Mastodon media upload endpoint
|
# Use the asynchronous v2 upload endpoint. Processing status is queried
|
||||||
my $media_url = "${apibase}/media";
|
# 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#);
|
||||||
|
|
||||||
# Build curl command for multipart file upload
|
my @formFields = ("file=\@$file_path;type=$mime_type");
|
||||||
my $curl_cmd = "$baseagent";
|
push(@formFields, "description=$alt_text") if (length($alt_text));
|
||||||
|
|
||||||
# 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);
|
|
||||||
print $stdout "-- DEBUG: Uploading to $media_url\n" if ($verbose);
|
print $stdout "-- DEBUG: Uploading to $media_url\n" if ($verbose);
|
||||||
|
|
||||||
# Execute the upload
|
my ($response, $exit_code) =
|
||||||
my $response = `$curl_cmd 2>/dev/null`;
|
&run_multipart_request('POST', $media_url, \@formFields);
|
||||||
my $exit_code = $? >> 8;
|
|
||||||
|
|
||||||
if ($exit_code != 0) {
|
if ($exit_code != 0) {
|
||||||
print $stdout "-- ERROR: Upload failed (curl exit code: $exit_code)\n";
|
print $stdout "-- ERROR: Upload failed (curl exit code: $exit_code)\n";
|
||||||
@@ -9388,7 +9549,46 @@ sub upload_media_file {
|
|||||||
return undef;
|
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 {
|
sub create_post_with_media {
|
||||||
@@ -11761,62 +11961,38 @@ sub update_server_profile {
|
|||||||
|
|
||||||
print $stdout "-- Updating profile on server...\n" if ($verbose);
|
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 $update_url = "${apibase}/accounts/update_credentials";
|
||||||
my $curl_cmd = "$baseagent";
|
my @formFields = ();
|
||||||
|
push(@formFields, 'display_name=' . $changes->{'display_name'})
|
||||||
# Add standard TTYverse curl options
|
if defined($changes->{'display_name'});
|
||||||
foreach my $arg (@wend) {
|
push(@formFields, 'note=' . $changes->{'bio'})
|
||||||
$curl_cmd .= " '$arg'";
|
if defined($changes->{'bio'});
|
||||||
}
|
|
||||||
|
|
||||||
# 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
|
# Website is handled as a custom field, not direct 'url' parameter
|
||||||
if (defined($changes->{'website'})) {
|
if (defined($changes->{'website'})) {
|
||||||
# Find existing Website field or add as new field
|
# Find existing Website field or add as new field
|
||||||
my $website_field_index = 0;
|
my $website_field_index = 0;
|
||||||
$curl_cmd .= " -F 'fields_attributes[${website_field_index}][name]=Website'";
|
push(@formFields, "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}][value]=" . $changes->{'website'});
|
||||||
}
|
}
|
||||||
|
|
||||||
# Custom fields (Mastodon multipart format)
|
# Custom fields (Mastodon multipart format)
|
||||||
if ($changes->{'custom_fields'}) {
|
if ($changes->{'custom_fields'}) {
|
||||||
my $field_index = 0;
|
my $field_index = defined($changes->{'website'}) ? 1 : 0;
|
||||||
foreach my $field_data (@{$changes->{'custom_fields'}}) {
|
foreach my $field_data (@{$changes->{'custom_fields'}}) {
|
||||||
if ($field_data =~ /^([^=]+)\s*=\s*(.+)$/) {
|
if ($field_data =~ /^([^=]+)\s*=\s*(.+)$/) {
|
||||||
my ($name, $value) = ($1, $2);
|
my ($name, $value) = ($1, $2);
|
||||||
$name =~ s/^\s+|\s+$//g;
|
$name =~ s/^\s+|\s+$//g;
|
||||||
$value =~ s/^\s+|\s+$//g;
|
$value =~ s/^\s+|\s+$//g;
|
||||||
# Escape single quotes in field values
|
push(@formFields, "fields_attributes[${field_index}][name]=$name");
|
||||||
$name =~ s/'/'\\''/g;
|
push(@formFields, "fields_attributes[${field_index}][value]=$value");
|
||||||
$value =~ s/'/'\\''/g;
|
|
||||||
$curl_cmd .= " -F 'fields_attributes[${field_index}][name]=$name'";
|
|
||||||
$curl_cmd .= " -F 'fields_attributes[${field_index}][value]=$value'";
|
|
||||||
$field_index++;
|
$field_index++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Add the URL
|
my ($return, $exit_code) =
|
||||||
$curl_cmd .= " '$update_url'";
|
&run_multipart_request('PATCH', $update_url, \@formFields);
|
||||||
|
|
||||||
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: 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 length: " . length($return) . " bytes\n" if ($verbose);
|
||||||
|
|||||||
Reference in New Issue
Block a user