From 97fc2b30825b878521a2eee6c0daf34ae34963e9 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Tue, 29 Jul 2025 19:10:16 -0400 Subject: [PATCH] Couple of bug fixes for /followers. Added /following.. Fixed the /follow command. --- ttyverse.pl | 704 ++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 625 insertions(+), 79 deletions(-) diff --git a/ttyverse.pl b/ttyverse.pl index 00e820b..63b1960 100755 --- a/ttyverse.pl +++ b/ttyverse.pl @@ -146,6 +146,9 @@ BEGIN { # === DIRECT MESSAGES === # dmpause=0 # DM refresh rate (0=use main pause) +# === NOTIFICATIONS === +# notificationpause=0 # Notification refresh rate (0=use main pause) + # === INTERACTION === # mentions=0 # Show mentions in timeline # synch=0 # Synchronous mode (blocks on requests) @@ -237,7 +240,7 @@ EOF signals_use_posix dostream nostreamreplies streamallreplies nofilter ); %opts_sync = map { $_ => 1 } qw( - ansi pause dmpause ttytteristas verbose superverbose + ansi pause dmpause notificationpause ttytteristas verbose superverbose url rlurl dmurl newline wrap notimeline lists dmidurl queryurl track colourprompt colourme notrack colourdm colourreply colourwarn coloursearch colourlist idurl @@ -247,6 +250,7 @@ EOF ); %opts_urls = map {$_ => 1} qw( url dmurl uurl rurl wurl frurl rlurl update shoreblogurl apibase fediverseserver queryurl idurl delurl dmdelurl favsurl + notificationurl markersurl favurl favdelurl followurl leaveurl dmupdate credurl blockurl blockdelurl friendsurl modifyliurl adduliurl delliurl getliurl getlisurl getfliurl @@ -265,7 +269,7 @@ EOF ); %opts_can_set = map { $_ => 1 } qw( - url pause dmurl dmpause dmmarkread superverbose ansi verbose + url pause dmurl dmpause notificationpause dmmarkread superverbose ansi verbose update uurl rurl wurl avatar ttytteristas frurl track rlurl noprompt shoreblogurl newline wrap verify autosplit notimeline queryurl fediverseserver colourprompt colourme @@ -672,15 +676,16 @@ if ($termrl && $termrl->ReadLine eq 'Term::ReadLine::Gnu') { /help /? /quit /q /bye /end /e /exit /refresh /r /thump /again /a /dm /dmr /dmrefresh /dms /dmsent /dmagain + /notifications /notificationrefresh /nr /replies /re /reply /timeline /timelines - /media /visibility /search /se + /media /poll /mpoll /visibility /search /se /history /h /print /p /verbose /ve /ruler /ru /cls /clear /url /open /short /sh /rate /ratelimit /track /tron /troff /trends /woeids /notrack /set /unset /add /del /push /pop /list /lists /listfollowers - /listfriends /dump /du /eval /ev + /listfriends /followers /following /dump /du /eval /ev /version /update /versioncheck /updatecheck /thread /th /entities /ent /delete /deletelast /rtsof /vote /whois /w /me @@ -1344,6 +1349,9 @@ $dmupdate ||= "${apibase}/statuses"; # DMs are private statuses $dmdelurl ||= "${apibase}/statuses/%I"; $dmidurl ||= "${apibase}/statuses/%I"; +$notificationurl ||= "${apibase}/notifications"; +$markersurl ||= "${apibase}/markers"; + $favsurl ||= "${apibase}/favourites"; $favurl ||= "${apibase}/statuses/%I/favourite"; $favdelurl ||= "${apibase}/statuses/%I/unfavourite"; @@ -1396,6 +1404,9 @@ $dmpause = 4 if (!defined $dmpause); # NOT ||= ... zero is a VALID value! $dmpause = 0 if ($anonymous); $dmpause = 0 if ($pause eq '0'); $dmmarkread = 1 if (!defined $dmmarkread); # Default to enabled +$notificationpause = 6 if (!defined $notificationpause); # NOT ||= ... zero is a VALID value! +$notificationpause = 0 if ($anonymous); +$notificationpause = 0 if ($pause eq '0'); $ansi = ($noansi) ? 0 : (($ansi || $ENV{'TERM'} eq 'ansi' || $ENV{'TERM'} eq 'xterm-color') ? 1 : 0); @@ -1404,9 +1415,11 @@ $ansi = ($noansi) ? 0 : if ($synch) { $pause = 0; $dmpause = ($dmpause) ? 1 : 0; + $notificationpause = ($notificationpause) ? 1 : 0; } $dmcount = $dmpause; +$notificationcount = $notificationpause; $lastshort = undef; # ANSI sequences @@ -2008,7 +2021,7 @@ exit(0) if (length($status)); if (length($credentials)) { print "-- processing credentials: "; $my_json_ref = &map_mastodon_fields(&parsejson($credentials)); - $whoami = lc($my_json_ref->{'username'} || $my_json_ref->{'acct'}); + $whoami = lc($my_json_ref->{'acct'} || $my_json_ref->{'username'}); if (!length($whoami)) { print "FAILED!\nis your account suspended, or wrong token?\n"; exit; @@ -2060,6 +2073,7 @@ if ($daemon) { } $parent = 0; $dmcount = 1 if ($dmpause); # force fetch + $notificationcount = 1 if ($notificationpause); # force fetch $is_background = 1; DAEMONLOOP: for(;;) { my $snooze; @@ -2070,6 +2084,13 @@ if ($daemon) { &update_effpause; &refresh(0); $dont_refresh_first_time = 0; + # Check notifications before DMs since DM refresh can return early + if ($notificationpause) { + if (!--$notificationcount) { + ¬ificationrefresh(0); + $notificationcount = $notificationpause; + } + } # Move DM refresh after timeline refresh so timeline updates show even if no unread DMs if ($dmpause) { if (!--$dmcount) { @@ -3124,6 +3145,12 @@ Just type to talk! /media /path/to/file Upload media (images, video, audio) with accessibility features +/poll Your question here + Create a single choice poll + +/mpoll Your question here + Create a multiple choice poll (users can select multiple options) + /quit Resumes your boring life. @@ -3144,6 +3171,8 @@ USER COMMANDS: /wagain username - combines them all /follow username - follow a username /leave username - stop following a username + /followers [username] - show who follows you (or username) + /following [username] - show who you follow (or username follows) /dm username message - send a username a DM POST AND DM SELECTION: @@ -3239,6 +3268,7 @@ EOF if ($dostream); &thump; &dmthump_no_skip if ($dmpause); # Also refresh DMs but don't skip timeline + ¬ificationthump if ($notificationpause); # Also refresh notifications return 0; } @@ -3247,6 +3277,24 @@ EOF my $file_path = $1; return &handle_media_upload($file_path); } + + # Poll creation commands + if (m#^/poll\s+(.+)$#) { + my $poll_text = $1; + return &handle_poll_creation($poll_text, 0); # Single choice + } + if (m#^/poll\s*$#) { + print $stdout "-- ERROR: Poll requires question text. Usage: /poll Your question here\n"; + return 0; + } + if (m#^/mpoll\s+(.+)$#) { + my $poll_text = $1; + return &handle_poll_creation($poll_text, 1); # Multiple choice + } + if (m#^/mpoll\s*$#) { + print $stdout "-- ERROR: Multiple choice poll requires question text. Usage: /mpoll Your question here\n"; + return 0; + } if (m#^/a(gain)?(\s+\+\d+)?$#) { # the asynchronous form my $countmaybe = $2; $countmaybe =~ s/[^\d]//g if (length($countmaybe)); @@ -3406,7 +3454,7 @@ EOF } # this is dual-headed and supports both lists and regular followers. - if(s#^/(frs|friends|fos|followers)(\s+\+\d+)?\s*##) { + if(s#^/(frs|friends|following|fos|followers)(\s+\+\d+)?\s*##) { my $countmaybe = $2; my $mode = $1; my $arg = lc($_); @@ -3423,9 +3471,9 @@ EOF } $who ||= $whoami; if (!length($lname)) { - $what = ($mode eq 'frs' || $mode eq 'friends') - ? "friends" : "followers"; - $mode = ($mode eq 'frs' || $mode eq 'friends') + $what = ($mode eq 'frs' || $mode eq 'friends' || $mode eq 'following') + ? "following" : "followers"; + $mode = ($mode eq 'frs' || $mode eq 'friends' || $mode eq 'following') ? $friendsurl : $followersurl; } else { # List members/followers - fediverse only supports list members, not subscribers @@ -3480,59 +3528,31 @@ EOF return 0; } - # Use proper Mastodon API endpoint with account ID + # Use proper Mastodon API endpoint with account ID - much simpler than Twitter! my $followers_url = $mode; $followers_url =~ s/%I/$account_id/g; - my $accounts_ref = &grabjson("$followers_url?limit=${countmaybe}", 0, 0, 0, undef, 1); + + # Fediverse API directly returns account objects, no need for separate lookup + my $limit = &min($countmaybe, 80); # Mastodon default max is 80 + my $accounts_ref = &grabjson("$followers_url?limit=$limit", 0, 0, 0, undef, 1); + + print $stdout "-- DEBUG: API response type: " . (ref($accounts_ref) || 'not a reference') . "\n" if ($verbose); + if (ref($accounts_ref) eq 'ARRAY') { + print $stdout "-- DEBUG: Number of accounts in response: " . scalar(@{ $accounts_ref }) . "\n" if ($verbose); + } + return 0 if (!$accounts_ref || ref($accounts_ref) ne 'ARRAY'); - # Fediverse (Mastodon/Pleroma/etc) returns array of account objects, extract IDs - my @ids = map { $_->{'id'} } @{ $accounts_ref }; - @ids = sort { 0+$a <=> 0+$b } @ids; - # make it somewhat deterministic - - my $dount = &min($countmaybe, scalar(@ids)); - my $swallow = &min(100, $dount); - my @usarray = undef; shift(@usarray); # force underflow - my $l_ref = undef; - - # for each block of $countper, emit + print $stdout "-- $what for $who:\n"; my $printed = 0; - - FFABIO: while ($dount--) { - if (!scalar(@usarray)) { - my @next_ids; - - last FFABIO if (!scalar(@ids)); - - # if we asked for less than 100, get - # that. otherwise, - # get the top 100 off that list (or - # the list itself, if 100 or less) - if (scalar(@ids) <= $swallow) { - @next_ids = @ids; - @ids = (); - } else { - @next_ids = - @ids[0..($swallow-1)]; - @ids = @ids[$swallow..$#ids]; - } - - # turn it into a list to pass to - # lookupidurl and get the list - $l_ref = &postjson($lookupidurl, - "user_id=".&url_oauth_sub(join(',', @next_ids))); - last FFABIO if(ref($l_ref) ne 'ARRAY'); - @usarray = sort - { 0+($a->{'id'}) <=> 0+($b->{'id'}) } - @{ $l_ref }; - last if (!scalar(@usarray)); - } - &$userhandle(shift(@usarray)); + for my $account (@{ $accounts_ref }) { + # Fediverse returns complete account objects - just display them + print $stdout "-- DEBUG: Processing account: " . ($account->{'username'} || 'no-username') . " / " . ($account->{'acct'} || 'no-acct') . "\n" if ($verbose); + &$userhandle($account); $printed++; } - print $stdout "-- sorry, no $what found for $who.\n" - if (!$printed); + + print $stdout "-- sorry, no $what found for $who.\n" if (!$printed); return 0; } @@ -4065,14 +4085,33 @@ m#^/(un)?f(boost|a|av|ave|avorite|avourite)? ([zZ]?[a-zA-Z]?[0-9]+)$#) { } # Use Mastodon's acct field (includes @domain for remote users) or username for local users my $target; - my $acct = &descape($post->{'user'}->{'acct'} || $post->{'user'}->{'username'}); - $target = $acct; + my $acct = &descape($post->{'account'}->{'acct'} || $post->{'user'}->{'acct'} || $post->{'user'}->{'username'}); - # If acct doesn't include @domain and this is a remote post, construct it - if ($acct !~ /\@/ && $post->{'url'} && $post->{'url'} =~ m{^https?://([^/]+)/}) { - my $domain = $1; - if ($domain ne $fediverseserver) { + # Always ensure we have the full @domain format for federation + if ($acct =~ /\@/) { + # Already has domain, use as-is + $target = $acct; + } else { + # No domain - extract from post URL, account URI, or other sources + my $domain; + + # Try multiple sources for domain information + if ($post->{'account'}->{'url'} && $post->{'account'}->{'url'} =~ m{^https?://([^/]+)/}) { + $domain = $1; + } elsif ($post->{'url'} && $post->{'url'} =~ m{^https?://([^/]+)/}) { + $domain = $1; + } elsif ($post->{'account'}->{'uri'} && $post->{'account'}->{'uri'} =~ m{^https?://([^/]+)/}) { + $domain = $1; + } elsif ($post->{'uri'} && $post->{'uri'} =~ m{^https?://([^/]+)/}) { + $domain = $1; + } + + if ($domain) { $target = "$acct\@$domain"; + } else { + # Last resort fallback - but this should not happen for federation + print $stdout "-- WARNING: Could not determine domain for user $acct, using local server\n" if ($verbose); + $target = "$acct\@$fediverseserver"; } } print $stdout "-- DEBUG: Reply target acct='$post->{'user'}->{'acct'}', username='$post->{'user'}->{'username'}', url='$post->{'url'}', using='$target'\n" if ($verbose); @@ -4105,7 +4144,36 @@ m#^/(un)?f(boost|a|av|ave|avorite|avourite)? ([zZ]?[a-zA-Z]?[0-9]+)$#) { return 0; } # in the future, add DM in_reply_to here - my $target = &descape($dm->{'last_status'}->{'account'}->{'acct'} || $dm->{'last_status'}->{'account'}->{'username'}); + my $acct = &descape($dm->{'last_status'}->{'account'}->{'acct'} || $dm->{'last_status'}->{'account'}->{'username'}); + my $target; + + # Always ensure we have the full @domain format for federation + if ($acct =~ /\@/) { + # Already has domain, use as-is + $target = $acct; + } else { + # No domain - extract from DM account URL, URI, or other sources + my $domain; + + # Try multiple sources for domain information + if ($dm->{'last_status'}->{'account'}->{'url'} && $dm->{'last_status'}->{'account'}->{'url'} =~ m{^https?://([^/]+)/}) { + $domain = $1; + } elsif ($dm->{'last_status'}->{'url'} && $dm->{'last_status'}->{'url'} =~ m{^https?://([^/]+)/}) { + $domain = $1; + } elsif ($dm->{'last_status'}->{'account'}->{'uri'} && $dm->{'last_status'}->{'account'}->{'uri'} =~ m{^https?://([^/]+)/}) { + $domain = $1; + } elsif ($dm->{'last_status'}->{'uri'} && $dm->{'last_status'}->{'uri'} =~ m{^https?://([^/]+)/}) { + $domain = $1; + } + + if ($domain) { + $target = "$acct\@$domain"; + } else { + # Last resort fallback - but this should not happen for federation + print $stdout "-- WARNING: Could not determine domain for DM user $acct, using local server\n" if ($verbose); + $target = "$acct\@$fediverseserver"; + } + } if ($termrl) { $readline_completion{'@'.lc($target)}++; &save_completion_cache; @@ -4128,14 +4196,33 @@ m#^/(un)?f(boost|a|av|ave|avorite|avourite)? ([zZ]?[a-zA-Z]?[0-9]+)$#) { } # Use Mastodon's acct field (includes @domain for remote users) or username for local users my $target; - my $acct = &descape($post->{'user'}->{'acct'} || $post->{'user'}->{'username'}); - $target = $acct; + my $acct = &descape($post->{'account'}->{'acct'} || $post->{'user'}->{'acct'} || $post->{'user'}->{'username'}); - # If acct doesn't include @domain and this is a remote post, construct it - if ($acct !~ /\@/ && $post->{'url'} && $post->{'url'} =~ m{^https?://([^/]+)/}) { - my $domain = $1; - if ($domain ne $fediverseserver) { + # Always ensure we have the full @domain format for federation + if ($acct =~ /\@/) { + # Already has domain, use as-is + $target = $acct; + } else { + # No domain - extract from post URL, account URI, or other sources + my $domain; + + # Try multiple sources for domain information + if ($post->{'account'}->{'url'} && $post->{'account'}->{'url'} =~ m{^https?://([^/]+)/}) { + $domain = $1; + } elsif ($post->{'url'} && $post->{'url'} =~ m{^https?://([^/]+)/}) { + $domain = $1; + } elsif ($post->{'account'}->{'uri'} && $post->{'account'}->{'uri'} =~ m{^https?://([^/]+)/}) { + $domain = $1; + } elsif ($post->{'uri'} && $post->{'uri'} =~ m{^https?://([^/]+)/}) { + $domain = $1; + } + + if ($domain) { $target = "$acct\@$domain"; + } else { + # Last resort fallback - but this should not happen for federation + print $stdout "-- WARNING: Could not determine domain for user $acct, using local server\n" if ($verbose); + $target = "$acct\@$fediverseserver"; } } my $text = $_; @@ -4284,6 +4371,12 @@ EOF &dmthump; return 0; } + + # Notifications + if ($_ eq '/notifications' || $_ eq '/notificationrefresh' || $_ eq '/nr') { + ¬ificationthump; + return 0; + } # /dmsent, /dmagain if (m#^/dm(s|sent|a|again)(\s+\+\d+)?$#) { my $mode = $1; @@ -4838,7 +4931,36 @@ sub reply_to_all { # Get the post content and author my $post_content = $post_ref->{'text'} || ''; - my $post_author = $post_ref->{'user'}->{'acct'} || $post_ref->{'user'}->{'username'} || ''; + my $acct = $post_ref->{'account'}->{'acct'} || $post_ref->{'user'}->{'acct'} || $post_ref->{'user'}->{'username'} || ''; + my $post_author; + + # Always ensure we have the full @domain format for federation + if ($acct =~ /\@/) { + # Already has domain, use as-is + $post_author = $acct; + } else { + # No domain - extract from post URL, account URI, or other sources + my $domain; + + # Try multiple sources for domain information + if ($post_ref->{'account'}->{'url'} && $post_ref->{'account'}->{'url'} =~ m{^https?://([^/]+)/}) { + $domain = $1; + } elsif ($post_ref->{'url'} && $post_ref->{'url'} =~ m{^https?://([^/]+)/}) { + $domain = $1; + } elsif ($post_ref->{'account'}->{'uri'} && $post_ref->{'account'}->{'uri'} =~ m{^https?://([^/]+)/}) { + $domain = $1; + } elsif ($post_ref->{'uri'} && $post_ref->{'uri'} =~ m{^https?://([^/]+)/}) { + $domain = $1; + } + + if ($domain) { + $post_author = "$acct\@$domain"; + } else { + # Last resort fallback - but this should not happen for federation + print $stdout "-- WARNING: Could not determine domain for user $acct in reply-to-all, using local server\n" if ($verbose); + $post_author = "$acct\@$fediverseserver"; + } + } # Set up reply-to ID my $in_reply_to = $post_ref->{'id_str'} || $post_ref->{'id'}; @@ -4953,6 +5075,7 @@ sub sub_helper { sub sync_console { &thump; &dmthump unless (!$dmpause); + ¬ificationthump unless (!$notificationpause); } sub sync_semaphore { if ($synch) { @@ -5044,6 +5167,12 @@ $stream_failure = 0; $dm_first_time = ($dmpause) ? 1 : 0; $dm_display_only = 0; # Flag to suppress notifications during user-initiated /dms commands $dm_notification_sent = 0; # Flag to prevent duplicate notifications per refresh cycle +$notification_first_time = ($notificationpause) ? 1 : 0; +$notification_display_only = 0; # Flag to suppress notifications during user-initiated notification commands +$notification_notification_sent = 0; # Flag to prevent duplicate notifications per refresh cycle +$last_notification_marker = ''; # Track last read notification ID via markers API +# Notification tracking uses markers API for server-side read status +%notification_seen = (); # Hash to track seen notification IDs locally # DM tracking now uses Mastodon's unread flag with local read tracking fallback %dm_seen_status = (); # Hash to track seen conversation_id:last_status_id pairs &load_dm_seen_status(); # Load persistent tracking data @@ -5062,6 +5191,21 @@ for(;;) { (!$effpause && !$interactive); $dont_refresh_first_time = 0; $previous_last_id = $last_id; + if ($notificationpause && ($effpause || $synch)) { + if ($notification_first_time) { + ¬ificationrefresh(0); + $notificationcount = $notificationpause; + } elsif (!$interactive) { + print $stdout "-- DEBUG: Notification countdown: $notificationcount -> " . ($notificationcount - 1) . "\n" if ($verbose); + if (!--$notificationcount) { + print $stdout "-- DEBUG: Triggering background notification refresh\n" if ($verbose); + ¬ificationrefresh($interactive); + $notificationcount = $notificationpause; + } + } else { + print $stdout "-- DEBUG: Skipping notification countdown (interactive=$interactive)\n" if ($verbose); + } + } if ($dmpause && ($effpause || $synch)) { if ($dm_first_time) { &dmrefresh(0); @@ -5259,7 +5403,7 @@ EOF $key->{'tag'}->{'type'}. " ". # NO SPACES! unpack("${pack_magic}H*", $key->{'tag'}->{'payload'}). " ". ($key->{'reblogs_count'} || "0") . " " . - ($key->{'user'}->{'username'} || $key->{'user'}->{'acct'})." $ds $src|". + ($key->{'user'}->{'username'} || $key->{'user'}->{'acct'})." ".($key->{'user'}->{'acct'} || $key->{'user'}->{'username'})." $ds $src|". unpack("${pack_magic}H*", $key->{'text'}). $space_pad), 0, 1024); print P $key; @@ -5414,6 +5558,11 @@ EOF &send_repaint if ($termrl); $dmcount = $dmpause; goto DONT_REFRESH; + } elsif ($rout =~ /^notificationthump/) { + ¬ificationrefresh($interactive); + &send_repaint if ($termrl); + $notificationcount = $notificationpause; + goto DONT_REFRESH; } } } else { @@ -6661,7 +6810,23 @@ sub updatest { } else { if (length($user_name_dm)) { # For DMs: include mention in status and set visibility - my $dm_status = "\@${user_name_dm} ${string}"; + # Ensure we have the full @domain format for federation + my $dm_target = $user_name_dm; + if ($dm_target !~ /\@/) { + # No domain - try to find full format in completion cache + my $found_full = undef; + foreach my $cached_user (keys %readline_completion) { + if ($cached_user =~ /^\@(.+)\@(.+)$/) { + my ($cached_username) = ($1); + if (lc($cached_username) eq lc($dm_target)) { + $found_full = substr($cached_user, 1); # Remove @ prefix + last; + } + } + } + $dm_target = $found_full || $dm_target; + } + my $dm_status = "\@${dm_target} ${string}"; my $dm_urle = ''; foreach my $char (unpack("${pack_magic}C*", $dm_status)) { my $k = chr($char); @@ -6746,6 +6911,171 @@ EOF return 0; } +# refresh for notifications +sub notificationrefresh { + my $interactive = shift; + + # Reset notification flag for this refresh cycle + $notification_notification_sent = 0; + + if ($anonymous) { + print $stdout + "-- sorry, you can't read notifications if you're anonymous.\n" + if ($interactive); + return; + } + + # no point in doing this if we can't even get to our own timeline + # (unless user specifically requested it, or our timeline is off) + return if (!$interactive && !$last_id && !$notimeline); + + # Get current markers to see what's been read + my $markers_ref = &grabjson($markersurl . "?timeline[]=notifications", + 0, 0, 0, undef, 1); + + my $since_id = 0; + if (defined($markers_ref) && ref($markers_ref) eq 'HASH' && + $markers_ref->{'notifications'} && + $markers_ref->{'notifications'}->{'last_read_id'}) { + $since_id = $markers_ref->{'notifications'}->{'last_read_id'}; + $last_notification_marker = $since_id; + print $stdout "-- DEBUG: Got notification marker: $since_id\n" if ($verbose); + } + + # Fetch notifications since the marker + my $my_json_ref = &grabjson($notificationurl, $since_id, 0, + ($interactive ? 20 : 50), undef, 1); + + return if (!defined($my_json_ref) || ref($my_json_ref) ne 'ARRAY'); + + my $printed = 0; + my $max_id = 0; + my $disp_max = &min($print_max, scalar(@{ $my_json_ref })); + + print $stdout "-- DEBUG: Notification response: " . scalar(@{ $my_json_ref }) . " notifications, disp_max=$disp_max\n" if ($verbose); + + # For background refresh, check if there are any new notifications first + if (!$interactive && $disp_max) { + my $has_new = 0; + for (my $check_i = 0; $check_i < $disp_max; $check_i++) { + my $check_notif = $my_json_ref->[$check_i]; + next if (!$check_notif->{'id'}); + my $notif_id = $check_notif->{'id'}; + + # Skip if we've already seen this notification locally + if ($notification_seen{$notif_id}) { + print $stdout "-- DEBUG: Skipping notification $notif_id - already seen locally\n" if ($verbose); + next; + } + + $has_new = 1; + last; + } + if (!$has_new) { + print $stdout "-- DEBUG: No new notifications found in background refresh, returning early\n" if ($verbose); + return; + } + print $stdout "-- DEBUG: Found new notifications in background refresh, proceeding\n" if ($verbose); + } + + if ($disp_max) { + if ($notification_first_time) { + sleep 5 while ($suspend_output > 0); + &send_removereadline if ($termrl); + print $stdout + "-- checking for most recent notifications:\n"; + $disp_max = 3; + $interactive = 1; + $notification_display_only = 1; # Suppress sounds during first-time load + print $stdout "-- DEBUG: notification_first_time: disp_max reduced to $disp_max, sounds suppressed\n" if ($verbose); + } + + 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]; + print $stdout "-- DEBUG: Processing notification #$i (index $g)\n" if ($verbose); + + # Skip if missing data + if (!$notif->{'id'} || !$notif->{'type'}) { + print $stdout "-- DEBUG: Skipping notification #$i - missing id or type\n" if ($verbose); + next; + } + + my $notif_id = $notif->{'id'}; + + # For background refresh, skip notifications we've already seen locally + if (!$interactive) { + if ($notification_seen{$notif_id}) { + print $stdout "-- DEBUG: Skipping notification #$i - already seen locally (id: $notif_id)\n" if ($verbose); + next; + } + + # Mark as seen locally for this session + $notification_seen{$notif_id} = 1; + print $stdout "-- DEBUG: Marked notification $notif_id as seen locally\n" if ($verbose); + } + + # Track highest ID for marker update + my $current_id = 0+$notif_id; + $max_id = $current_id if ($current_id > $max_id); + + # 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'); + $sound_class = 'follow' if ($type eq 'follow'); + + print $stdout "-- DEBUG: Processing notification type '$type' as sound class '$sound_class'\n" if ($verbose); + + # Use existing notification display handler + if ($notification_display_only) { + # During first-time load, display without sounds + print $stdout "-- DEBUG: Displaying notification without sound (display_only mode)\n" if ($verbose); + &$handle($notif, ''); # Empty class = no sound + } else { + ¬ifications_tdisplay([ $notif ]); + } + $printed++; + } + } + + sleep 5 while ($suspend_output > 0); + if (($interactive || $verbose) && !$printed && !$notification_first_time) { + &send_removereadline if ($termrl); + print $stdout "-- no new notifications.\n"; + $wrapseq = 1; + } + + # Update marker to highest ID we processed (for background refresh or manual check) + if (!$interactive || $notification_first_time || ($interactive && $max_id > 0)) { + if ($max_id > 0) { + my $marker_data = "notifications%5Blast_read_id%5D=$max_id"; + my $marker_result = &backticks($baseagent, '/dev/null', undef, + $markersurl, $marker_data, 0, @wend); + + if ($? == 0) { + $last_notification_marker = $max_id; + print $stdout "-- DEBUG: Updated notification marker to $max_id\n" if ($verbose); + } else { + print $stdout "-- DEBUG: Failed to update notification marker (exit code: $?)\n" if ($verbose); + } + } + + print $stdout "-- DEBUG: Updated last_notification_marker to $last_notification_marker (interactive=$interactive, notification_first_time=$notification_first_time, max_id=$max_id)\n" if ($verbose); + } else { + print $stdout "-- DEBUG: NOT updating notification marker (interactive=$interactive, keeping last_notification_marker=$last_notification_marker)\n" if ($verbose); + } + + $notification_first_time = 0 if ($max_id || !scalar(@{ $my_json_ref })); + $notification_display_only = 0; # Reset sound suppression flag + print $stdout "-- notification bookmark is $last_notification_marker.\n" if ($verbose); + + return 0; +} + # this dispatch routine replaces the common logic of deletest, deletedm, # follow, leave and the favourites system. # this is a modified, abridged version of &updatest. @@ -6834,10 +7164,48 @@ sub foruuser { return 1; } + # Substitute account ID into URL template + my $api_url = $basef; + $api_url =~ s/%I/$account_id/g; + + print $stdout "-- DEBUG: Calling follow/unfollow API with account_id=$account_id, basef=$basef\n" if ($verbose); my ($en, $em) = ¢ral_cd_dispatch("id=$account_id", - $interactive, $basef); - print $stdout "-- ok, you have $verb following user $uname.\n" - if ($interactive && !$en); + $interactive, $api_url); + print $stdout "-- DEBUG: Follow API returned - error code: $en, message length: " . length($em) . "\n" if ($verbose); + print $stdout "-- DEBUG: Follow API response: $em\n" if ($verbose && $em); + + # Check for HTTP error responses in the message + if (!$en && $em && $em =~ /(\d+)\s+([^<]+)<\/title>/) { + my $http_code = $1; + my $http_message = $2; + print $stdout "-- ERROR: $verb follow failed - HTTP $http_code: $http_message\n" if ($interactive); + return 1; + } + + # Parse JSON response to show accurate relationship status + if (!$en && $interactive && $em) { + my $response_data = &parsejson($em); + if (ref($response_data) eq 'HASH') { + if ($verb eq 'started') { + if ($response_data->{'following'}) { + print $stdout "-- ok, you are now following user $uname.\n"; + } elsif ($response_data->{'requested'}) { + print $stdout "-- ok, follow request sent to user $uname (awaiting approval).\n"; + } else { + print $stdout "-- ok, you have $verb following user $uname.\n"; + } + } else { + # For unfollow + if (!$response_data->{'following'} && !$response_data->{'requested'}) { + print $stdout "-- ok, you are no longer following user $uname.\n"; + } else { + print $stdout "-- ok, you have $verb following user $uname.\n"; + } + } + } else { + print $stdout "-- ok, you have $verb following user $uname.\n"; + } + } return 0; } @@ -6855,8 +7223,12 @@ sub boruuser { return 1; } + # Substitute account ID into URL template + my $api_url = $basef; + $api_url =~ s/%I/$account_id/g; + my ($en, $em) = ¢ral_cd_dispatch("id=$account_id", - $interactive, $basef); + $interactive, $api_url); print $stdout "-- ok, you have $verb blocking user $uname.\n" if ($interactive && !$en); return 0; @@ -7818,17 +8190,42 @@ sub lookup_account_id { print $stdout "-- DEBUG: Looking up account ID for username: $username\n" if ($verbose); + # Special case: if looking up our own account, use verify_credentials + if (lc($username) eq lc($whoami)) { + print $stdout "-- DEBUG: Looking up own account via verify_credentials\n" if ($verbose); + my $creds_result = &grabjson($credurl, 0, 0, 0, undef, 1); + if ($creds_result && $creds_result->{'id'}) { + print $stdout "-- DEBUG: Found own account ID: " . $creds_result->{'id'} . "\n" if ($verbose); + return $creds_result->{'id'}; + } + print $stdout "-- DEBUG: Failed to get own account from verify_credentials\n" if ($verbose); + } + # Use Mastodon search API to find account - my $search_result = &grabjson("${searchurl}?q=${username}&type=accounts&limit=1", 0, 0, 0, undef, 1); + my $search_url = "${searchurl}?q=${username}&type=accounts&limit=1"; + print $stdout "-- DEBUG: Search URL: $search_url\n" if ($verbose); + my $search_result = &grabjson($search_url, 0, 0, 0, undef, 1); + + print $stdout "-- DEBUG: Search result: " . ($search_result ? "SUCCESS" : "FAILED") . "\n" if ($verbose); + if ($search_result && ref($search_result) eq 'HASH') { + print $stdout "-- DEBUG: Search result has accounts: " . ($search_result->{'accounts'} ? "YES" : "NO") . "\n" if ($verbose); + if ($search_result->{'accounts'}) { + print $stdout "-- DEBUG: Number of accounts found: " . scalar(@{$search_result->{'accounts'}}) . "\n" if ($verbose); + } + } if ($search_result && $search_result->{'accounts'} && @{$search_result->{'accounts'}}) { my $account = $search_result->{'accounts'}->[0]; my $found_username = $account->{'username'} || $account->{'acct'}; + print $stdout "-- DEBUG: Found account - username: '" . ($account->{'username'} || 'null') . "', acct: '" . ($account->{'acct'} || 'null') . "'\n" if ($verbose); + # Verify we found the right account (case-insensitive match) if (lc($found_username) eq lc($username) || lc($account->{'acct'}) eq lc($username)) { print $stdout "-- DEBUG: Found account ID: " . $account->{'id'} . " for $username\n" if ($verbose); return $account->{'id'}; + } else { + print $stdout "-- DEBUG: Username mismatch - looking for '$username', found '$found_username'\n" if ($verbose); } } @@ -7925,7 +8322,7 @@ sub defaultautocompletion { '/orepost', '/erepost', '/frepost', '/liston', '/listoff', '/dmsent', '/rtsof', '/rtson', '/rtsoff', '/lists', '/withlist', '/add', '/padd', '/push', - '/pop', '/followers', '/friends', '/lfollow', + '/pop', '/followers', '/following', '/friends', '/lfollow', '/lleave', '/listfollowers', '/listfriends', '/unset', '/verbose', '/short', '/follow', '/unfollow', '/doesfollow', '/search', '/tron', '/troff', @@ -8105,8 +8502,8 @@ sub get_post { $w->{'tag'}->{'type'}, $w->{'tag'}->{'payload'}, $w->{'reblogs_count'}, - $w->{'user'}->{'username'}, $w->{'created_at'}, - $l) = split(/\s/, $k, 17); + $w->{'user'}->{'username'}, $w->{'user'}->{'acct'}, $w->{'created_at'}, + $l) = split(/\s/, $k, 18); ($w->{'source'}, $k) = split(/\|/, $l, 2); $w->{'text'} = pack("H*", $k); $w->{'place'}->{'full_name'} = pack("H*",$w->{'place'}->{'full_name'}); @@ -8220,6 +8617,7 @@ sub thump { } sub dmthump { print C "dmthump------------\n"; &sync_semaphore; } sub dmthump_no_skip { print C "dmthump_no_skip----\n"; &sync_semaphore; } +sub notificationthump { print C "notificationthump--\n"; &sync_semaphore; } sub sync_n_quit { # Save completion cache before exiting @@ -8663,6 +9061,154 @@ sub create_post_with_media { } } +##### Poll Creation Functions ##### + +sub handle_poll_creation { + my $poll_text = shift; + my $multiple_choice = shift || 0; # Default to single choice + + my $poll_type = $multiple_choice ? "multiple choice" : "single choice"; + print $stdout "Creating $poll_type poll: $poll_text\n"; + + # Ask for poll duration first (default 24h) + print $stdout "Poll duration (default 24h): "; + my $duration_input = <STDIN>; + chomp($duration_input); + + my $expires_in = &parse_duration($duration_input); + + # Collect poll options interactively + my @options = (); + my $option_num = 1; + + print $stdout "Enter options (press Enter with no text to finish):\n"; + + while ($option_num <= 10) { # Allow up to 10 options (instance may limit to 4) + print $stdout "Option $option_num: "; + + # Read user input for option + my $option = <STDIN>; + chomp($option); + + # If empty input, stop collecting options + if ($option eq "") { + last; + } + + # Validate option length (Mastodon limit is 50 characters) + if (length($option) > 50) { + print $stdout "-- ERROR: Option too long (max 50 characters): " . length($option) . " chars\n"; + print $stdout "-- Please enter a shorter option or press Enter to skip.\n"; + next; # Ask for this option again + } + + # Add the option to our list + push @options, $option; + $option_num++; + } + + # Validate we have at least 2 options + if (scalar(@options) < 2) { + print $stdout "-- ERROR: Poll cancelled. Need at least 2 options.\n"; + return 0; + } + + # Create and post the poll + return &create_poll_post($poll_text, \@options, $expires_in, $multiple_choice); +} + +sub parse_duration { + my $input = shift; + + # Default to 24 hours if empty + return 24 * 3600 if ($input eq ""); + + # Parse duration format: number + letter (h/d/m for hours/days/minutes) + if ($input =~ /^(\d+)([hdm])$/i) { + my ($num, $unit) = ($1, lc($2)); + my $hours; + + if ($unit eq 'h') { + $hours = $num; + } elsif ($unit eq 'd') { + $hours = $num * 24; + } elsif ($unit eq 'm') { + $hours = $num / 60.0; + } + + # Validate bounds + if ($hours <= 0) { + print $stdout "-- ERROR: Invalid duration. Using default 24 hours.\n"; + return 24 * 3600; + } + if ($hours > 8760) { # More than a year + print $stdout "-- WARNING: Duration capped at 365 days.\n"; + $hours = 8760; + } + + return int($hours * 3600); # Convert to seconds + } else { + print $stdout "-- ERROR: Invalid format. Use format like 24h, 3d, 90m. Using default 24h.\n"; + return 24 * 3600; + } +} + +sub create_poll_post { + my ($poll_text, $options_ref, $expires_in, $multiple_choice) = @_; + my @options = @$options_ref; + $multiple_choice = $multiple_choice || 0; # Default to single choice + + # Build the poll parameters for the API + my $poll_params = ''; + for my $i (0 .. $#options) { + $poll_params .= "&poll[options][]=" . &url_oauth_sub($options[$i]); + } + $poll_params .= "&poll[expires_in]=$expires_in"; + $poll_params .= "&poll[multiple]=" . ($multiple_choice ? "true" : "false"); + $poll_params .= "&poll[hide_totals]=false"; + + # Prepare the status post with poll + my $status_param = &url_oauth_sub($poll_text); + my $post_data = "status=$status_param$poll_params"; + + # Add visibility if set + if (defined($visibility)) { + $post_data .= "&visibility=$visibility"; + } + + print $stdout "-- Posting poll with " . scalar(@options) . " options...\n" if ($verbose); + + # Use the same authentication method as regular posts + my $post_url = "${apibase}/statuses"; + print $stdout "-- DEBUG: Poll API call: $post_url\n" if ($verbose); + print $stdout "-- DEBUG: Poll data: $post_data\n" if ($superverbose); + + my $return = &backticks($baseagent, '/dev/null', undef, $post_url, $post_data, 0, @wend); + + print $stdout "-- DEBUG: Poll API response length: " . length($return) . " bytes\n" if ($verbose); + + if ($return =~ /"id":\s*"([^"]+)"/) { + my $post_id = $1; + print $stdout "-- Poll posted successfully (ID: $post_id)\n"; + print $stdout "-- DEBUG: Poll created with ID: $post_id\n" if ($verbose); + return 1; + } else { + print $stdout "-- ERROR: Failed to create poll\n"; + if ($verbose) { + print $stdout "-- DEBUG: Full poll response: $return\n"; + } + + # Provide helpful error messages for common issues + if ($return =~ /too many options/i || $return =~ /exceed.*options/i) { + print $stdout "-- This instance may limit polls to 4 options or fewer.\n"; + } elsif ($return =~ /exceed max chars/i) { + print $stdout "-- One or more options exceed the character limit (usually 50).\n"; + } + + return 0; + } +} + ##### optimizers -- these compile into an internal format ##### # utility routine for tquery support