#!/usr/bin/php [test]\nnote: settings-.php must exist\n"); } $instance = $argv[1]; // check some requirements foreach (['sqlite3', 'curl', 'mbstring'] as $re) { if (!extension_loaded($re)) { echo "The $re extension is required. On Ubuntu or Debian, try sudo apt install php-$re. The best PPA is from https://deb.sury.org\n"; } } $db = init_db(); upgrade(1); // test mode if (isset($argv[2]) && $argv[2] == "test") { $instance .= "-test"; $channel = $test_channel; $nick = $test_nick; } // init $instance_hash = md5(file_get_contents(dirname(__FILE__) . '/bot.php')); if (empty($network) || !in_array($network, ['freenode', 'rizon', 'gamesurge', 'libera', 'other'])) { echo "Missing or invalid \$network setting. Using default Freenode.\n"; $network = 'freenode'; } (get_data('nick') && $nick = get_data('nick')); $helptxt = "*** $nick $channel !help ***\n\nglobal commands:\n"; foreach ($custom_triggers ?? [] as $v) { $helptxt .= !empty($v[3]) ? " $v[3]\n" : ''; } $helptxt .= " !w - search Wikipedia and output a link if something is found !g - create and output a Google search link !g- - create and output a LMGTFY search link !i - create and output a Google Images link\n"; $helptxt .= !empty($youtube_api_key) ? " !yt - search YouTube and output a link to the first result\n" : ''; $helptxt .= !empty($omdb_key) ? " !m - search OMDB and output media info if found\n" : ''; $helptxt .= !empty($currencylayer_key) ? " !cc - currency converter\n" : ''; $helptxt .= !empty($wolfram_appid) ? " !wa - query Wolfram Alpha\n" : ''; $helptxt .= " !ud [definition #] - query Urban Dictionary with optional definition number\n"; $helptxt .= !empty($gcloud_translate_keyfile) ? " !tr or e.g. !tr en-fr - translate text to english or between other languages. see https://bit.ly/iso639-1\n" : ''; $helptxt .= " !flip - flip a coin (call heads or tails first!) (uses random.org) !rand [num] - get random numbers with optional number of numbers (uses random.org) !8 or !8ball - magic 8-ball (modified to 50/50, uses random.org)\n"; $helptxt .= file_exists('/usr/games/fortune') ? " !f or !fortune - fortune\n" : ''; $helptxt .= "\nadmin commands: !s or !say - output text to channel !e or !emote - emote text to channel !t or !topic - change channel topic !k or !kick [message] - kick a single user with an optional message\n"; $helptxt .= $network == 'freenode' ? " !r or !remove [message] - remove a single user with an optional message (quiet, no 'kick' notice to client)\n" : ''; $helptxt .= " !b or !ban [message] - ban by nick (*!*@mask) or hostmask. if by nick, also remove user with optional message !ub or !unban - unban by hostmask\n"; $helptxt .= ($network == 'freenode' || $network == 'libera') ? " !q or !quiet [mins] - quiet by nick (*!*@mask) or hostmask for optional [mins] or default no expiry\n" : ''; $helptxt .= $network == 'freenode' ? " !rq or !removequiet [mins] [message] - remove user then quiet for optional [mins] with optional [message]\n" : ''; $helptxt .= ($network == 'freenode' || $network == 'libera') ? " !uq or !unquiet - unquiet by hostmask\n" : ''; $helptxt .= " !nick - Change the bot's nick !invite - invite to channel !restart [message] - reload bot with optional quit message !update [message] - update bot with the latest from github and reload with optional quit message !die [message] - kill bot with optional quit message note: commands may be used in channel or pm. separate multiple hostmasks with spaces. bans," . ($network == 'freenode' ? ' quiets,' : '') . " invites occur in $channel."; $help_url = init_help(); // pastebin help if changed // init $connect_ip = (isset($connect_ip) && strpos($connect_ip, ':') !== false) ? "[$connect_ip]" : $connect_ip; // add brackets to ipv6 $curl_iface = (isset($curl_iface) && strpos($curl_iface, ':') !== false) ? "[$curl_iface]" : $curl_iface; if ((empty($user) || empty($pass)) && (empty($disable_sasl) || empty($disable_nickserv))) { echo "Username or password not set. Disabling authentication.\n"; $disable_sasl = true; $disable_nickserv = true; } if ($network == 'gamesurge' && empty($disable_sasl)) { echo "GameSurge network doesn't support SASL, disabling.\n"; $disable_sasl = true; } $ircname = empty($ircname) ? $user : $ircname; $ident = empty($ident) ? 'bot' : $ident; $user_agent = empty($user_agent) ? 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' : $user_agent; $num_file_get_retries = 2; $gcloud_translate_max_chars = empty($gcloud_translate_max_chars) ? 50000 : $gcloud_translate_max_chars; $ignore_urls = empty($ignore_urls) ? [] : $ignore_urls; $ignore_urls = array_merge($ignore_urls, ['google.com/search', 'google.com/images', 'scholar.google.com']); $skip_dupe_output = empty($skip_dupe_output) ? false : $skip_dupe_output; $last_send = ''; $always_opped = !empty($op_bot); $orignick = $nick; $last_nick_change = 0; $opped = false; $connect = 1; $opqueue = []; $doopdop_lock = false; $check_lock = false; $lasttime = 0; $users = []; // user state data (nick, ident, host) $flood_lines = []; $base_msg_len = 60; $custom_loop_functions ??= []; $title_cache_enabled = !empty($title_cache_enabled); $title_cache_size = ($title_cache_enabled && empty($title_cache_size)) ? 128 : $title_cache_size; $title_bold = !empty($title_bold) ? "\x02" : ''; $twitter_nitter_instance = (!empty($twitter_nitter_enabled) && empty($twitter_nitter_instance)) ? 'https://nitter.net' : $twitter_nitter_instance; if (!empty($nitter_links_via_twitter)) { $nitter_hosts_time = 0; $nitter_hosts = ''; nitter_hosts_update(); } $short_url_token_index = 0; $reddit_token = ''; $reddit_token_expires = 0; $spotify_token = ''; $spotify_token_expires = 0; $max_download_size ??= 26214400; // 25MiB $amt_is_gemini = !empty($ai_media_titles_enabled) ? substr($ai_media_titles_model, 0, 6) == 'gemini' : false; $amt_mt_regex = !empty($ai_media_titles_more_types) ? '|' . implode('|', explode(',', $ai_media_titles_more_types)) : ''; while (1) { if ($connect) { $in_channel = 0; // connect loop while (1) { echo "Connecting...\n"; $botmask = ''; $socket_options = $custom_connect_ip ? ['socket' => ['bindto' => "$connect_ip:0"]] : []; if ($network == 'gamesurge') { $socket_options['ssl'] = ['verify_peer' => false]; } $socket_context = stream_context_create($socket_options); $socket = stream_socket_client($host, $errno, $errstr, 15, STREAM_CLIENT_CONNECT, $socket_context); echo "* connect errno=$errno errstr=$errstr\n"; if (!$socket || $errno <> 0) { sleep(15); continue; } stream_set_timeout($socket, $stream_timeout, 0); $connect_time = time(); if (empty($disable_sasl)) { echo "Authenticating with SASL\n"; send("CAP LS\n"); while ($data = fgets($socket)) { echo $data; $ex = explode(' ', trim($data)); if (strpos($data, 'CAP * LS') !== false && strpos($data, 'sasl') !== false) { send("CAP REQ :multi-prefix sasl\n"); } elseif (strpos($data, 'CAP * ACK') !== false) { send("AUTHENTICATE PLAIN\n"); } elseif (strpos($data, 'AUTHENTICATE +') !== false || strpos($data, 'AUTHENTICATE :+') !== false) { send("AUTHENTICATE " . base64_encode("\0$user\0$pass") . "\n"); } // if($ex[1]=='900') $botmask=substr($ex[3],strpos($ex[3],'@')+1); if (strpos($data, 'SASL authentication successful') !== false) { send("CAP END\n"); break; } if (empty($data) || strpos($data, "ERROR") !== false) { echo "ERROR authenticating with SASL, restarting in 5s..\n"; sleep(5); dorestart(null, false); } } } if (!empty($server_pass)) { send("PASS $server_pass\n"); } send("NICK $nick\n"); send("USER $ident $user $user :$ircname\n"); // first $user can be changed to modify ident and account login still works if ($network == 'freenode' || $network == 'libera') { send("CAP REQ account-notify\n"); send("CAP REQ extended-join\n"); send("CAP END\n"); } elseif (empty($disable_sasl)) { send("CAP END\n"); } // set up and wait til end of motd while ($data = fgets($socket)) { echo $data; $ex = explode(' ', trim($data)); if ($ex[0] == "PING") { send_no_filter("PONG " . rtrim($ex[1]) . "\n"); continue; } if ($ex[1] == '433') { echo "Nick in use.. changing and reconnecting\n"; $nick = $orignick . $altchars[rand(0, count($altchars) - 1)]; continue(2); } if ($ex[1] == '376' || $ex[1] == '422') { break; } // end if (empty($data) || strpos($data, "ERROR") !== false) { echo "ERROR waiting for MOTD, restarting in 5s..\n"; sleep(5); dorestart(null, false); } } if (!empty($disable_sasl) && empty($disable_nickserv)) { if ($network == 'gamesurge') { echo "Authenticating with AuthServ\n"; send("PRIVMSG AuthServ@Services.GameSurge.net :auth $user $pass\n"); } else { echo "Authenticating with NickServ\n"; send("PRIVMSG NickServ :IDENTIFY $user $pass\n"); } sleep(2); // helps ensure cloak is applied on join } if (!empty($perform_on_connect)) { $cs = explode(';', $perform_on_connect); foreach ($cs as $c) { send(trim(str_replace('$nick', $nick, $c)) . "\n"); sleep(1); } } send("WHOIS $nick\n"); // botmask detection sleep(1); send("JOIN $channel" . ($channel_key ? " $channel_key" : '') . "\n"); $connect = false; break; } } // main loop while ($data = fgets($socket)) { echo $data; $time = time(); $ex = explode(' ', $data); $incnick = substr($ex[0], 1, strpos($ex[0], '!') - 1); if ($ex[1] == 'PRIVMSG') { if (isme()) { continue; } preg_match('/^:[^ ]*? PRIVMSG [^ ]*? :(([^ ]*?)( .*)?)\r\n$/', $data, $m); $msg = trim($m[1]); $trigger = $m[2]; if (!empty($m[3]) && !empty(trim($m[3]))) { $args = trim($m[3]); } else { $args = ''; } if ($ex[2] == $nick) { $privto = $incnick; } else { $privto = $channel; } // for PM response $baselen = $base_msg_len + strlen($privto); // for str_shorten max length } else { $trigger = ''; $args = ''; $msg = ''; $privto = ''; $baselen = $base_msg_len; } // echo "msg=\"$msg\"\ntrigger=\"$trigger\"\nargs=\"$args\"\n"; // ongoing checks if ($time - $lasttime > 2 && $time - $connect_time > 10 && !$check_lock) { $check_lock = true; $lasttime = $time; // unquiet expired q if (get_data('timed_quiets')) { $tqs = json_decode(get_data('timed_quiets'), true); // check if timeout $tounban = []; foreach ($tqs as $k => $f) { list($ftime, $fdur, $fhost) = explode('|', $f); if (time() - $ftime >= $fdur) { if (strpos($fhost, '!') === false && strpos($fhost, '$a:') === false) { $fhost .= '!*@*'; } $tounban[] = $fhost; unset($tqs[$k]); } } if (!empty($tounban)) { set_data('timed_quiets', json_encode($tqs)); if ($network == 'freenode') { foreach ($tounban as $who) { send("PRIVMSG chanserv :UNQUIET $channel $who\n"); } } elseif ($network == 'libera' || $network == 'other') { $opqueue[] = ['-q', $tounban]; getops(); } } } $check_lock = false; } // ignore specified nicks with up to one non-alpha char if (isset($ignore_nicks) && is_array($ignore_nicks) && !empty($incnick)) { foreach ($ignore_nicks as $n) { if (preg_match("/^" . preg_quote($n) . "[^a-zA-Z]?$/", $incnick)) { echo "Ignoring $incnick\n"; continue(2); } } } // custom loop functions foreach ($custom_loop_functions as $f) { if ($f() == 2) { continue(2); } } // get botmask from WHOIS on connect if ($ex[1] == '311') { if ($ex[2] == $nick) { $botmask = $ex[5]; echo "Detected botmask: $botmask\n"; $base_msg_len = strlen(":$nick!~$ident@$botmask PRIVMSG :\r\n"); } } // recover main nick if ($nick <> $orignick && $time - $connect_time >= 10 && $time - $last_nick_change >= 10) { send(":$nick NICK $orignick\n"); $last_nick_change = $time; continue; } // nick changes if ($ex[1] == 'NICK') { if (isme()) { $newnick = trim(ltrim($ex[2], ':')); echo "Changed bot nick to $newnick\n"; $nick = $newnick; $orignick = $nick; set_data('nick', $nick); if (($network == 'freenode' || $network == 'libera') && (empty($disable_nickserv) || empty($disable_sasl))) { send("PRIVMSG NickServ GROUP\n"); } elseif ($network == 'rizon' && (empty($disable_nickserv) || empty($disable_sasl))) { send("PRIVMSG NickServ GROUP $user $pass\n"); } $base_msg_len = strlen(":$nick!~$ident@$botmask PRIVMSG :\r\n"); } else { list($tmpnick) = parsemask($ex[0]); $id = search_multi($users, 'nick', $tmpnick); if (!empty($id)) { $users[$id]['nick'] = rtrim(substr($ex[2], 1)); } else { echo "ERROR: Nick changed but not in \$users. This should not happen!\n"; } if ($network == 'rizon') { send("WHO $tmpnick\n"); } // check for account again } continue; } // ping pong if ($ex[0] == "PING") { send_no_filter("PONG " . rtrim($ex[1]) . "\n"); if (!$in_channel) { send("JOIN $channel" . ($channel_key ? " $channel_key" : '') . "\n"); } continue; } // got ops, run op queue if (preg_match('/^:ChanServ!ChanServ@services[^ ]* MODE ' . preg_quote("$channel +o $nick") . '$/', rtrim($data))) { echo "Got ops, running op queue\n"; print_r($opqueue); $opped = true; $getops_lock = false; doopdop(); continue; } // end of NAMES list, joined main channel so do a WHO now if ($ex[1] == '366') { $in_channel = 1; if (in_array($network, ['freenode', 'gamesurge', 'libera'])) { send("WHO $channel %hna\n"); } else { send("WHO $channel\n"); } continue; } // parse WHO listing if ($ex[1] == '352') { // rfc1459 - rizon if (strpos($ex[8], 'r') !== false) { $a = $ex[7]; } else { $a = '0'; } $id = search_multi($users, 'nick', $ex[7]); if (empty($id)) { $users[] = ['nick' => $ex[7], 'host' => $ex[5], 'account' => $a]; } else { $users[$id]['host'] = $ex[5]; $users[$id]['account'] = $a; } if ($host_blacklist_enabled) { check_blacklist($ex[4], $ex[3]); } // check_dnsbl($ex[7],$ex[5],true); continue; } if ($ex[1] == '354') { // freenode, gamesurge, libera $id = search_multi($users, 'nick', $ex[4]); if (empty($id)) { $users[] = ['nick' => $ex[4], 'host' => $ex[3], 'account' => ltrim(rtrim($ex[5]), ':')]; } else { $users[$id]['host'] = $ex[3]; $users[$id]['account'] = ltrim(rtrim($ex[5]), ':'); } if ($host_blacklist_enabled) { check_blacklist($ex[4], $ex[3]); } // check_dnsbl($ex[7],$ex[5],true); continue; } // 315 end of WHO list if ($ex[1] == '315') { if (empty($first_join_done)) { echo "Join to $channel complete.\n"; if (!empty($op_bot)) { send("PRIVMSG ChanServ :OP $channel $nick\n"); } if (!empty($voice_bot)) { send("PRIVMSG ChanServ :VOICE $channel $nick\n"); } $first_join_done = true; } continue; } // Update $users on JOIN, PART, QUIT, KICK, NICK if ($ex[1] == 'JOIN' && !isme()) { // just add user to array because they shouldnt be there already // parse ex0 for username and hostmask list($tmpnick, $tmphost) = parsemask($ex[0]); if ($network == 'freenode' || $network == 'libera') { // extended-join with account if ($ex[3] == '*') { $ex[3] = '0'; } $users[] = ['nick' => $tmpnick, 'host' => $tmphost, 'account' => $ex[3]]; } else { $users[] = ['nick' => $tmpnick, 'host' => $tmphost, 'account' => '0']; if ($network == 'gamesurge') { send("WHO $tmpnick %hna\n"); } else { send("WHO $tmpnick\n"); } } if ($host_blacklist_enabled) { check_blacklist($tmpnick, $tmphost); } // if(!isadmin()) check_dnsbl($tmpnick,$tmphost); else echo "dnsbl check skipped: isadmin\n"; continue; } if ($ex[1] == 'PART' || $ex[1] == 'QUIT' || $ex[1] == 'KICK') { if (($ex[1] == 'PART' && isme()) || ($ex[1] == 'KICK' && $ex[3] == $nick)) { // left channel, rejoin $in_channel = 0; send("JOIN $channel" . ($channel_key ? " $channel_key" : '') . "\n"); continue; } if ($ex[1] == 'KICK') { $tmpnick = $ex[3]; } else { list($tmpnick) = parsemask($ex[0]); } $id = search_multi($users, 'nick', $tmpnick); if (!empty($id)) { unset($users[$id]); $users = array_values($users); } continue; } if ($ex[1] == 'ACCOUNT') { // find user and update account list($tmpnick) = parsemask($ex[0]); $id = search_multi($users, 'nick', $tmpnick); if (!empty($id)) { $users[$id]['account'] = rtrim($ex[2]); } else { echo "ERROR: Account changed but not in \$users. This should not happen!\n"; } continue; } // admin triggers if (!empty($trigger) && substr($trigger, 0, 1) == '!' && isadmin()) { if ($trigger == '!s' || $trigger == '!say') { send("PRIVMSG $channel :$args \n"); continue; } elseif ($trigger == '!e' || $trigger == '!emote') { send("PRIVMSG $channel :" . pack('C', 0x01) . "ACTION $args" . pack('C', 0x01) . "\n"); continue; } elseif ($trigger == '!ban' || $trigger == '!b') { // if there's a space get the ban reason and use it for remove if (strpos($args, ' ') !== false) { $reason = substr($args, strpos($args, ' ') + 1); } else { $reason = "Goodbye."; } list($mask) = explode(' ', $args); // if contains $ or @, ban by mask, else build mask from nick if (strpos($mask, '@') === false && strpos($mask, '$') === false) { $tmpnick = $mask; $id = search_multi($users, 'nick', $mask); if (!$id) { if ($ex[2] == $nick) { $tmp = $incnick; } else { $tmp = $channel; } // allow PM response send("PRIVMSG $tmp :Nick not found in channel.\n"); continue; } if (($network == 'freenode' || $network == 'libera') && $users[$id]['account'] <> '0') { $mask = '$a:' . $users[$id]['account']; } else { $mask = "*!*@" . $users[$id]['host']; } } else { $tmpnick = ''; } $mask = str_replace('@gateway/web/freenode/ip.', '@', $mask); echo "Ban $mask\n"; $opqueue[] = ['+b', [$mask, $reason, $tmpnick]]; getops(); } elseif ($trigger == '!unban' || $trigger == '!ub') { $opqueue[] = ['-b', explode(' ', $args)]; getops(); } elseif (($trigger == '!quiet' || $trigger == '!q') && ($network == 'freenode' || $network == 'libera' || $network == 'other')) { $arr = explode(' ', $args); if (is_numeric($arr[0])) { $timed = 1; $tqtime = $arr[0] * 60; unset($arr[0]); $arr = array_values($arr); } else { $timed = false; } if (empty($arr)) { continue; } // ensure there's data foreach ($arr as $who) { // check if nick or mask if (strpos($who, '@') === false && strpos($who, '$') === false) { $id = search_multi($users, 'nick', $who); if (!$id) { if ($ex[2] == $nick) { $tmp = $incnick; } else { $tmp = $channel; } // allow PM response send("PRIVMSG $tmp :Nick not found in channel.\n"); continue; } // if has account use it else create mask if (($network == 'freenode' || $network == 'libera') && $users[$id]['account'] <> '0') { $who = '$a:' . $users[$id]['account']; } else { $who = "*!*@" . $users[$id]['host']; } } echo "Quiet $who, timed=$timed tqtime=$tqtime\n"; if ($network == 'freenode') { $who = str_replace('@gateway/web/freenode/ip.', '@', $who); } if ($timed) { timedquiet($tqtime, $who); } else { if ($network == 'freenode') { send("PRIVMSG chanserv :QUIET $channel $who\n"); } elseif ($network == 'libera' || $network == 'other') { $opqueue[] = ['+q', $who]; getops(); } } } continue; } elseif (($trigger == '!removequiet' || $trigger == '!rq') && $network == 'freenode') { // shadowquiet when channel +z $arr = explode(' ', $args); if (is_numeric($arr[0])) { $timed = 1; $tqtime = $arr[0] * 60; unset($arr[0]); $arr = array_values($arr); } else { $timed = false; } if (empty($arr)) { continue; } // ensure there's data $who = $arr[0]; unset($arr[0]); $arr = array_values($arr); $m = trim(implode(' ', $arr)); // check if nick or mask if (strpos($who, '@') === false && strpos($who, '$') === false) { $id = search_multi($users, 'nick', $who); if (!$id) { if ($ex[2] == $nick) { $tmp = $incnick; } else { $tmp = $channel; } // allow PM response send("PRIVMSG $tmp :Nick not found in channel.\n"); continue; } else { $thenick = $who; } // if has account use it else create mask if ($network == 'freenode' && $users[$id]['account'] <> '0') { $who = '$a:' . $users[$id]['account']; } else { $who = "*!*@" . $users[$id]['host']; } } echo "Quiet $who, timed=$timed tqtime=$tqtime\n"; if ($network == 'freenode') { $who = str_replace('@gateway/web/freenode/ip.', '@', $who); } $opqueue[] = ['remove_quiet', $who, ['nick' => $thenick, 'msg' => $m, 'timed' => $timed, 'tqtime' => $tqtime]]; getops(); continue; } elseif (($trigger == '!unquiet' || $trigger == '!uq')) { if ($network == 'freenode') { send("PRIVMSG chanserv :UNQUIET $channel $args\n"); continue; } elseif ($network == 'libera' || $network == 'other') { $opqueue[] = ['-q', explode(' ', $args)]; getops(); continue; } } elseif ($trigger == '!t' || $trigger == '!topic') { if (in_array($network, ['freenode', 'gamesurge', 'rizon', 'libera'])) { send("PRIVMSG ChanServ :TOPIC $channel $args\n"); } else { $opqueue[] = ['topic', null, ['msg' => $args]]; getops(); } continue; } elseif ($trigger == '!die') { send("QUIT :" . (!empty($args) ? $args : 'shutdown') . "\n"); exit; } elseif ($trigger == '!k' || $trigger == '!kick') { $arr = explode(' ', $args); if (empty($arr)) { continue; } if ($arr[1]) { $m = substr($args, strpos($args, ' ') + 1); } else { $m = false; } $opqueue[] = ['kick', $arr[0], ['msg' => $m]]; getops(); continue; } elseif (($trigger == '!r' || $trigger == '!remove') && $network == 'freenode') { $arr = explode(' ', $args); if (empty($arr)) { continue; } if ($arr[1]) { $m = substr($args, strpos($args, ' ') + 1); } else { $m = false; } echo "Remove $arr[0], msg=$m\n"; $opqueue[] = ['remove', $arr[0], ['msg' => $m]]; getops(); continue; } elseif ($trigger == '!nick') { if (empty($args)) { continue; } send("NICK $args\n"); continue; } elseif ($trigger == '!invite') { $arr = explode(' ', $args); $opqueue[] = ['invite', $arr[0]]; getops(); continue; } elseif ($trigger == '!restart') { dorestart($args); } elseif ($trigger == '!update') { $r = curlget([CURLOPT_URL => 'https://raw.githubusercontent.com/dhjw/php-irc-bot/master/bot.php']); if (empty($r)) { send("PRIVMSG $privto :Error downloading update\n"); continue; } if ($instance_hash == md5($r)) { send("PRIVMSG $privto :Already up to date\n"); continue; } if (file_get_contents(dirname(__FILE__) . '/bot.php') <> $r && !file_put_contents(dirname(__FILE__) . '/bot.php', $r)) { send("PRIVMSG $privto :Error writing updated bot.php\n"); continue; } send("PRIVMSG $privto :Update installed. See https://bit.ly/bupd8 for changes. Restarting\n"); dorestart(!empty($args) ? $args : 'update'); } elseif ($trigger == '!raw') { send("$args\n"); continue; } } // custom triggers if (!empty($trigger) && isset($custom_triggers)) { foreach ($custom_triggers as $k => $v) { @list($trig, $text, $pm) = $v; $pm ??= true; $target = $pm ? $privto : $channel; if ($trigger == $trig) { echo "Custom trigger $trig called\n"; if (substr($text, 0, 9) == 'function:') { $func = substr($text, 9); $func(); } else { send("PRIVMSG $target :$text\n"); } continue(2); } } } // global triggers if (!empty($trigger) && substr($trigger, 0, 1) == '!' && !$disable_triggers) { $privto = $ex[2] == $nick ? $incnick : $channel; // allow PM response if ($trigger == '!help') { // foreach(explode("\n",$helptxt) as $line){ send("PRIVMSG $incnick :$line\n"); sleep(1); } if (!empty($help_url) && empty($disable_help)) { send("PRIVMSG $incnick :Please visit $help_url\n"); } else { send("PRIVMSG $privto :Help disabled\n"); } continue; } elseif ($trigger == '!w' || $trigger == '!wiki') { if (empty($args)) { continue; } $u = "https://en.wikipedia.org/w/index.php?search=" . urlencode($args); for ($i = $num_file_get_retries; $i > 0; $i--) { $noextract = false; $nooutput = false; echo "Searching Wikipedia.. "; $response = curlget([CURLOPT_URL => $u]); if (empty($response)) { echo "no response/connect failed, retrying\n"; sleep(1); $nooutput = true; continue; } $url = $curl_info['EFFECTIVE_URL']; if (strstr($response, 'wgInternalRedirectTargetUrl') !== false) { echo "getting internal/actual wiki url.. "; $tmp = substr($response, strpos($response, 'wgInternalRedirectTargetUrl') + 30); $tmp = substr($tmp, 0, strpos($tmp, '"')); echo "found $tmp\n"; if (!empty($tmp)) { $url = "https://en.wikipedia.org$tmp"; } } $noextract = false; $nooutput = false; if (strpos($response, 'mw-search-nonefound') !== false || strpos($response, 'mw-search-createlink') !== false) { send("PRIVMSG $privto :There were no results matching the query.\n"); $noextract = true; $nooutput = true; break; } elseif (strpos($response, 'disambigbox') !== false) { if (strpos($url, 'disambiguation') === false) { $url .= ' (disambiguation)'; } $noextract = true; break; } $e = get_wiki_extract(substr($url, strrpos($url, '/') + 1)); break; } if (!empty($e) && !$noextract) { $url = "\"$e\" $url"; } if (!$nooutput) { send("PRIVMSG $privto :$url\n"); } continue; // Google } elseif ($trigger == '!g' || $trigger == '!i' || $trigger == '!g-' || $trigger == '!google') { if (empty($args)) { continue; } if ($trigger == '!g-') { send("PRIVMSG $privto :https://lmgtfy.com/?q=" . urlencode($args) . "\n"); continue; } if ($trigger == '!g' || $trigger == '!google') { $tmp = 'search'; } else { $tmp = 'images'; } send("PRIVMSG $privto :https://www.google.com/$tmp?q=" . urlencode($args) . "\n"); continue; } elseif ($trigger == '!ddg' || $trigger == '!ddi' || $trigger == '!dg' || $trigger == '!di') { // DDG if (empty($args)) { continue; } if ($trigger == '!ddi' || $trigger == '!di') { $tmp = "&iax=1&ia=images"; } else { $tmp = ''; } send("PRIVMSG $privto :https://duckduckgo.com/?q=" . urlencode($args) . "$tmp\n"); continue; } elseif ($trigger == '!yt') { if (empty($args)) { send("PRIVMSG $privto :Provide a query.\n"); continue; } for ($i = $num_file_get_retries; $i > 0; $i--) { $tmp = file_get_contents("https://www.googleapis.com/youtube/v3/search?q=" . urlencode($args) . "&part=snippet&maxResults=1&type=video&key=$youtube_api_key"); $tmp = json_decode($tmp); if (!empty($tmp)) { break; } elseif ($i > 1) { sleep(1); } } $v = $tmp->items[0]->id->videoId; if (empty($tmp)) { send("PRIVMSG $privto :[ Temporary YouTube API error ]\n"); continue; } elseif (empty($v)) { send("PRIVMSG $privto :There were no results matching the query.\n"); continue; } for ($i = $num_file_get_retries; $i > 0; $i--) { $tmp2 = file_get_contents("https://www.googleapis.com/youtube/v3/videos?id=$v&part=contentDetails,statistics&key=$youtube_api_key"); $tmp2 = json_decode($tmp2); print_r($tmp2); if (!empty($tmp2)) { break; } elseif ($i > 1) { sleep(1); } } $ytextra = ''; $dur = covtime($tmp2->items[0]->contentDetails->duration); if ($dur <> '0:00') { $ytextra .= " | $dur"; } $ytextra .= " | {$tmp->items[0]->snippet->channelTitle}"; $ytextra .= " | " . number_format($tmp2->items[0]->statistics->viewCount) . " views"; $title = html_entity_decode($tmp->items[0]->snippet->title, ENT_QUOTES); send("PRIVMSG $privto :https://youtu.be/$v | $title$ytextra\n"); continue; } // OMDB, check for movie or series only (no episode or game) elseif ($trigger == '!m') { echo "Searching OMDB.. "; ini_set('default_socket_timeout', 30); // by id only $tmp = rtrim($ex[4]); if (substr($tmp, 0, 2) == 'tt') { $cmd = "https://www.omdbapi.com/?i=" . urlencode($tmp) . "&apikey=$omdb_key"; echo "by id\n"; for ($i = $num_file_get_retries; $i > 0; $i--) { $tmp = curlget([CURLOPT_URL => $cmd]); $tmp = json_decode($tmp); print_r($tmp); if (!empty($tmp)) { break; } elseif ($i > 1) { sleep(1); } } if (empty($tmp)) { send("PRIVMSG $privto :OMDB API error.\n"); continue; } if ($tmp->Type == 'movie') { $tmp3 = ''; } else { $tmp3 = " $tmp->Type"; } if ($tmp->Response == 'True') { send("PRIVMSG $privto :\xe2\x96\xb6 $tmp->Title ($tmp->Year$tmp3) | $tmp->Genre | $tmp->Actors | \"$tmp->Plot\" https://www.imdb.com/title/$tmp->imdbID/ [$tmp->imdbRating]\n"); } elseif ($tmp->Response == 'False') { send("PRIVMSG $privto :$tmp->Error\n"); } else { send("PRIVMSG $privto :OMDB API error.\n"); } continue; } // search movies and series // check if final parameter is a year 1800 to 2200 if (count($ex) > 5) { // only if 2 words provided $tmp = rtrim($ex[count($ex) - 1]); if (is_numeric($tmp) && ($tmp > 1800 && $tmp < 2200)) { echo "year detected. appending api query and truncating msg\n"; $tmp2 = "&y=$tmp"; $args = substr($args, 0, strrpos($args, ' ')); } else { $tmp2 = ''; } } else { $tmp2 = ''; } // call with year first, without year after while (1) { foreach (['movie', 'series'] as $k => $t) { // multiple calls are needed $cmd = "https://www.omdbapi.com/?apikey=$omdb_key&type=$t$tmp2&t=" . urlencode($args); echo "url=$cmd\n"; for ($i = $num_file_get_retries; $i > 0; $i--) { $tmp = curlget([CURLOPT_URL => $cmd]); $tmp = json_decode($tmp); if (!empty($tmp)) { break; } elseif ($i > 1) { sleep(1); } } if (empty($tmp)) { send("PRIVMSG $privto :OMDB API error ($k)\n"); continue; } if ($tmp->Response == 'True') { break(2); } //usleep(100000); } if (!empty($tmp2)) { echo "now trying without year\n"; $tmp2 = ''; } else { break; } } if ($tmp->Response == 'False') { send("PRIVMSG $privto :Media not found.\n"); continue; } $tmp3 = ($tmp->Type == 'movie') ? '' : " $tmp->Type"; if (isset($tmp->Response)) { send("PRIVMSG $privto :\xe2\x96\xb6 $tmp->Title ($tmp->Year$tmp3) | $tmp->Genre | $tmp->Actors | \"$tmp->Plot\" https://www.imdb.com/title/$tmp->imdbID/ [$tmp->imdbRating]\n"); } else { send("PRIVMSG $privto :OMDB API error.\n"); } continue; } elseif (!empty($gcloud_translate_keyfile) && ($trigger == '!tr' || $trigger == '!translate')) { $words = explode(' ', $args); if (strpos($words[0], '-') !== false && strlen($words[0]) == 5) { list($from_lang, $to_lang) = explode('-', $words[0]); unset($words[0]); $words = array_values($words); $args = implode(' ', $words); $is_auto = false; } else { $from_lang = ''; $to_lang = 'en'; $is_auto = true; } if (empty($args)) { send("PRIVMSG $privto :Usage: !tr or e.g. !tr en-fr (see https://bit.ly/iso639-1)\n"); continue; } elseif (!$is_auto) { if (get_lang($from_lang) == 'Unknown' || get_lang($to_lang) == 'Unknown') { $e = []; if (get_lang($from_lang) == 'Unknown') { $e[] = $from_lang; } if (get_lang($to_lang) == 'Unknown') { $e[] = $to_lang; } send("PRIVMSG $privto :Unknown language code" . (count($e) > 1 ? 's' : " \"{$e[0]}\"") . ". See https://bit.ly/iso639-1\n"); continue; } elseif ($from_lang == $to_lang) { send("PRIVMSG $privto :From and to language codes must be different. See https://bit.ly/iso639-1\n"); continue; } } $r = google_translate(['text' => $args, 'from_lang' => $from_lang, 'to_lang' => $to_lang]); if (isset($r->text)) { if ($is_auto) { $out = "(" . get_lang($r->from_lang) . ") $r->text"; } else { $out = "(" . get_lang($r->from_lang) . " to " . get_lang($r->to_lang) . ") $r->text"; } send("PRIVMSG $privto :$out\n"); } else { send("PRIVMSG $privto :Could not translate.\n"); } continue; } elseif ($trigger == '!cc') { // currency converter echo "Converting currency..\n"; $ex = explode(' ', trim(str_ireplace(' in ', ' ', $data))); if (empty($ex[4]) || empty($ex[5]) || empty($ex[6]) || !empty($ex[7])) { send("PRIVMSG $privto :Usage: !cc \n"); continue; } $ex[count($ex) - 1] = rtrim($ex[count($ex) - 1]); // todo: do this globally at beginning $ex[4] = (float)preg_replace('/[^0-9.]/', '', $ex[4]); // strip non numeric $ex[5] = strtoupper(preg_replace('/[^a-zA-Z]/', '', $ex[5])); // strip non alpha $ex[6] = strtoupper(preg_replace('/[^a-zA-Z]/', '', $ex[6])); if ($ex[5] == 'BTC') { $tmp1 = strlen(substr(strrchr($ex[4], '.'), 1)); } else { $tmp1 = 2; } // precision1 if ($ex[6] == 'BTC') { $tmp2 = strlen(substr(strrchr($ex[4], '.'), 1)); if ($tmp2 < 5) { $tmp2 = 5; } } else { $tmp2 = 2; } // precision2 echo "ex4=$ex[4] from=$ex[5] to=$ex[6] precision=$tmp1 time=$time cclast=$cclast\n"; if ($ex[5] == $ex[6]) { send("PRIVMSG $privto :A wise guy, eh?\n"); continue; } if (empty($cccache) || $time - $cclast >= 300) { // cache results for 5 mins $cmd = "https://www.apilayer.net/api/live?access_key=$currencylayer_key&format=1"; for ($i = $num_file_get_retries; $i > 0; $i--) { $tmp = file_get_contents($cmd); $tmp = json_decode($tmp); if (!empty($tmp)) { break; } elseif ($i > 1) { sleep(1); } } if (empty($tmp)) { send("PRIVMSG $privto :Finance API error.\n"); continue; } if ($tmp->success) { echo "got success, caching\n"; $cccache = $tmp; $cclast = $time; } else { echo "got error, not caching\n"; } } else { $tmp = $cccache; } if (isset($tmp->quotes)) { if (!isset($tmp->quotes->{'USD' . $ex[5]})) { send("PRIVMSG $privto :Currency $ex[5] not found.\n"); continue; } if (!isset($tmp->quotes->{'USD' . $ex[6]})) { send("PRIVMSG $privto :Currency $ex[6] not found.\n"); continue; } $tmp3 = $tmp->quotes->{'USD' . $ex[5]} / $tmp->quotes->{'USD' . $ex[6]}; // build rate from USD echo "rate=$tmp3\n"; send("PRIVMSG $privto :" . number_format($ex[4], $tmp1) . " $ex[5] = " . number_format(($ex[4] / $tmp3), $tmp2) . " $ex[6] (" . make_short_url("https://finance.yahoo.com/quote/$ex[5]$ex[6]=X") . ")\n"); } else { send("PRIVMSG $privto :Finance API error.\n"); } continue; } elseif ($trigger == '!wa') { // wolfram alpha $u = "https://api.wolframalpha.com/v2/query?input=" . urlencode($args) . "&output=plaintext&appid=$wolfram_appid"; try { $xml = new SimpleXMLElement(file_get_contents($u)); } catch (Exception $e) { send("PRIVMSG $privto :API error, try again\n"); print_r($e); continue; } if (!empty($xml) && !empty($xml->pod[1]->subpod->plaintext)) { print_r([$xml->pod[0], $xml->pod[1]]); if ($xml->pod[1]->subpod->plaintext == '(data not available)') { send("PRIVMSG $privto :Data not available.\n"); } else { $o = str_shorten(trim(str_replace("\n", ' • ', $xml->pod[1]->subpod->plaintext)), 999, ['nowordcut' => 1]); echo "o=\"$o\"\n"; // turn fraction into decimal if (preg_match('#^(\d+)/(\d+)(?: \(irreducible\))?#', $o, $m)) { if (extension_loaded('bcmath')) { bcscale(64); $o = rtrim(bcdiv($m[1], $m[2]), '0'); if (strpos($o, '.') !== false) { list($a, $b) = explode('.', $o); if (strlen($b) > 63) { $b = substr($b, 0, 63) . '...'; } $o = "$a.$b"; } } else { echo "Can't reduce Wolfram fraction result to decimal because bcmath extension not loaded.\n"; } } send("PRIVMSG $privto :$o\n"); } } else { send("PRIVMSG $privto :Data not available.\n"); } continue; } elseif ($trigger == '!ud') { // urban dictionary if (empty($args)) { send("PRIVMSG $privto :Provide a term to define.\n"); continue; } $a = explode(' ', $args); if (is_numeric($a[count($a) - 1])) { $num = $a[count($a) - 1] - 1; unset($a[count($a) - 1]); $q = implode(' ', $a); } else { $num = 0; $q = $args; } echo "Searching Urban Dictionary.. q=$q num=$num\n"; $r = curlget([CURLOPT_URL => 'https://api.urbandictionary.com/v0/define?term=' . urlencode($q)]); $r = json_decode($r); if (empty($r) || empty($r->list[0])) { send("PRIVMSG $privto :Term not found.\n"); continue; } if (empty($r->list[$num])) { send("PRIVMSG $privto :Definition not found.\n"); continue; } $d = str_replace(["\r", "\n", "\t"], ' ', $r->list[$num]->definition); $d = trim(preg_replace('/\s+/', ' ', str_replace(["[", "]"], '', $d))); $d = str_replace(' .', '.', $d); $d = str_replace(' ,', ',', $d); $d = str_shorten($d, 360); $d = "\"$d\""; if (strtolower($r->list[$num]->word) <> strtolower($q)) { $d = "({$r->list[$num]->word}) $d"; } $d .= ' ' . make_short_url(get_final_url($r->list[0]->permalink), $r->list[0]->permalink); send("PRIVMSG $privto :$d\n"); } elseif ($trigger == '!flip') { $tmp = get_true_random(0, 1); $tmp = ($tmp == 0) ? 'heads' : 'tails'; send("PRIVMSG $privto :" . pack('C', 0x01) . "ACTION flips a coin, which lands \x02$tmp\x02 side up." . pack('C', 0x01) . "\n"); continue; } elseif ($trigger == '!8' || $trigger == '!8ball') { $answers = ["It is certain", "It is decidedly so", "Without a doubt", "Yes definitely", "You may rely on it", "As I see it, yes", "Most likely", "Outlook good", "Yes", "Signs point to yes", "Signs point to no", "No", "Nope", "Absolutely not", "Heck no", "Don't count on it", "My reply is no", "My sources say no", "Outlook not so good", "Very doubtful"]; $tmp = get_true_random(0, count($answers) - 1); send("PRIVMSG $privto :$answers[$tmp]\n"); continue; } elseif ($trigger == '!f' || $trigger == '!fortune') { // expects /usr/games/fortune to be installed echo "Getting fortune..\n"; $args = trim(preg_replace('#[^[:alnum:][:space:]-/]#u', '', $args)); for ($i = 0; $i < 2; $i++) { $f = trim(preg_replace('/\s+/', ' ', str_replace("\n", ' ', shell_exec("/usr/games/fortune -s '$args' 2>&1")))); if ($f == 'No fortunes found') { echo "Fortune type not found, getting from all.\n"; $args = ''; continue; } break; } send("PRIVMSG $privto :$f\n"); continue; } elseif ($trigger == '!rand') { echo "Getting random numbers, min=$ex[4] max=$ex[5] cnt=$ex[6]\n"; if (!is_numeric($ex[4]) || !is_numeric(trim($ex[5]))) { send("PRIVMSG $privto :Please provide two numbers for min and max. e.g. !rand 1 5\n"); continue; } send("PRIVMSG $privto :" . get_true_random($ex[4], $ex[5], !empty($ex[6]) ? $ex[6] : 1) . "\n"); continue; } } // URL Titles if ($ex[1] == 'PRIVMSG' && $ex[2] == $channel && !isme() && !$disable_titles) { /** @noinspection RegExpSuspiciousBackref */ preg_match_all('#\bhttps?://(?:\b[a-z\d-]{1,63}\b\.)+[a-z]+(?::\d+)?(?:[/?\#](?:([^\s`!\[\]{}();\'"<>«»“”‘’]+)|\((?1)?\))+(? "https://api.imgur.com/3/image/$id", CURLOPT_HTTPHEADER => ["Authorization: Client-ID $imgur_client_id"]])); if (empty($r) || $r->status == 404) { $r = json_decode(curlget([CURLOPT_URL => "https://api.imgur.com/3/album/$id", CURLOPT_HTTPHEADER => ["Authorization: Client-ID $imgur_client_id"]])); } if (!empty($r) && !empty($r->data->section) && empty($r->data->title) && empty($r->data->description)) { $r = json_decode(curlget([CURLOPT_URL => "https://api.imgur.com/3/gallery/r/{$r->data->section}/$id", CURLOPT_HTTPHEADER => ["Authorization: Client-ID $imgur_client_id"]])); } // subreddit image. title and desc may always be empty but included to be safe if (!empty($r) && $r->success == 1) { // for i.* direct links default to image description, else default to post title if (!empty($m[1])) { if (!empty($r->data->description)) { $d = $r->data->description; } else { $d = $r->data->title; } } elseif (!empty($r->data->title)) { $d = $r->data->title; } else { $d = $r->data->description; } // single image posts without a desc should use first image if (empty($d) && isset($r->data->images) && is_array($r->data->images) && count($r->data->images) == 1) { echo "using single image in album... "; $r = (object)['data' => $r->data->images[0]]; $d = $r->data->description; } $n = !empty($r->data->nsfw) ? 'NSFW' : ''; if (!empty($d)) { $d = html_entity_decode($d); $d = str_replace(["\r", "\n", "\t"], ' ', $d); $d = preg_replace('/\s+/', ' ', $d); $d = trim(strip_tags($d)); $o = str_shorten((!empty($n) ? ' - ' : '') . $d, 280); } else { $o = ''; } if (!empty($o)) { echo "ok\n"; $o = "[ $o ]"; send("PRIVMSG $channel :$title_bold$o$title_bold\n"); continue; } else { echo "No description, passing\n"; // use direct link, if not already, for ai image titles if (isset($r->data->link)) { $u = $r->data->link; $parse_url = parse_url($u); } } } } } // imgbb, get direct link for ai if (!empty($ai_media_titles_enabled) && preg_match('#^https?://(?:ibb\.co|imgbb\.com)/\w+$#', $u)) { echo "Getting direct link for AI... "; $dom = new DOMDocument(); if ($dom->loadHTML('' . curlget([CURLOPT_URL => $u]))) { $list = $dom->getElementsByTagName('input'); foreach ($list as $l) { if (!empty($l->attributes->getNamedItem('id')) && $l->attributes->getNamedItem('id')->value == 'embed-code-3') { // use medium-sized image for speed. id is one less than when logged-in preg_match('#\[img](.*)\[/img]#', $l->attributes->getNamedItem('value')->value, $m); $u = $m[1]; break; } } } echo "$u\n"; } // postimg.cc, get direct link for ai if (!empty($ai_media_titles_enabled) && preg_match('#^https?://(?:i\.)?postimg\.cc/([a-zA-Z0-9_/.-]+)$#', $u)) { $html = curlget([CURLOPT_URL => $u], ["no_curl_impersonate" => 1]); // impersonate always redirects to html if (preg_match('/ $m[1]], ["no_curl_impersonate" => 1]); // get image } else { $m = [1 => $u]; } $finfo = new finfo(FILEINFO_MIME_TYPE); $mime = $finfo->buffer($html); if (preg_match("#^\w+/(?:jpeg|png|webp|avif|gif" . ($ai_media_titles_more_types ? $amt_mt_regex : "") . ")$#", $mime)) { // dont re-dl $amt_mime = $mime; $amt_file = $html; $u = $m[1]; } else { echo "Failed to get supported image (". ($html && $mime ? "got $mime" : "no response") . "\n"; } } // youtube via api, w/invidious mirror support invidious: if (!empty($youtube_api_key)) { $yt = ''; if (preg_match('#^https?://(?:www\.|m\.|music\.)?(?:youtube\.com|invidio\.us)/(?:watch.*[?&]v=|embed/|shorts/|live/)([a-zA-Z0-9-_]+)#', $u, $m) || preg_match('#^https?://(?:youtu\.be|invidio\.us)/([a-zA-Z0-9-_]+)/?(?:$|\?)#', $u, $m)) { $yt = 'v'; } elseif (preg_match('#^https?://(?:www\.|m\.|music\.)?(?:youtube\.com|invidio\.us)/channel/([a-zA-Z0-9-_]+)/?(\w*)#', $u, $m)) { $yt = 'c'; } elseif (preg_match('#^https?://(?:www\.|m\.)?(?:youtube\.com|invidio\.us)/user/([a-zA-Z0-9-_]+)/?(\w*)#', $u, $m)) { $yt = 'u'; } elseif (preg_match('#^https?://(?:www\.|m\.)?(?:youtube\.com|invidio\.us)/@([^/]+)/?(\w*)#', $u, $m)) { $yt = 'h'; } elseif (preg_match('#^https?://(?:www\.|m\.|music\.)?(?:youtube\.com|invidio\.us)/playlist\?.*list=([a-zA-Z0-9-_]+)#', $u, $m)) { $yt = 'p'; } if (empty($yt)) { // custom channel URLs like /example or /c/example require scraping as no API endpoint if (preg_match('#^https?://(?:www\.|m\.)?(?:youtube\.com|invidio\.us)/(?:c/)?([a-zA-Z0-9-_]+)/?(\w*)#', $u, $m)) { $html = curlget([CURLOPT_URL => "https://www.youtube.com/$m[1]" . (!empty($m[2]) ? "/$m[2]" : '')]); // force load from youtube so indvidio.us works $dom = new DOMDocument(); if ($dom->loadHTML('' . $html)) { $list = $dom->getElementsByTagName('link'); foreach ($list as $l) { if (!empty($l->attributes->getNamedItem('rel')) && $l->attributes->getNamedItem('rel')->value == 'canonical') { if (preg_match('#^https?://(?:www\.|m\.)?youtube\.com/channel/([a-zA-Z0-9-_]+)#', $l->attributes->getNamedItem('href')->value, $m2)) { $m[1] = $m2[1]; $yt = 'c'; break; } } } } } } if (!empty($yt)) { if ($yt == 'v') { $r = file_get_contents("https://www.googleapis.com/youtube/v3/videos?id=$m[1]&part=snippet,contentDetails,localizations&maxResults=1&type=video&key=$youtube_api_key"); } elseif (in_array($yt, ['c', 'u', 'h'])) { $r = file_get_contents("https://www.googleapis.com/youtube/v3/channels?" . ($yt == 'c' ? 'id' : ($yt == 'u' ? 'forUsername' : 'forHandle')) . "=$m[1]&part=id,snippet,localizations&maxResults=1&key=$youtube_api_key"); } elseif ($yt == 'p') { $r = file_get_contents("https://www.googleapis.com/youtube/v3/playlists?id=$m[1]&part=snippet,localizations&key=$youtube_api_key"); } $r = json_decode($r); $s = false; if (empty($r)) { send("PRIVMSG $channel :[ Temporary YouTube API error ]\n"); continue; } elseif (empty($r->items)) { if ($yt == 'v' && preg_match('#^https?://invidio\.us#', $u)) { $s = true; } // skip if invidious short url vid not found so other site pages work else { send("PRIVMSG $channel :" . ($yt == 'v' ? 'Video' : ($yt == 'c' ? 'Channel' : (($yt == 'u' || $yt == 'h') ? 'User' : ($yt == 'p' ? 'Playlist' : '')))) . " does not exist.\n"); continue; } } if (!$s) { $x = ''; if ($yt == 'v') { $d = covtime($r->items[0]->contentDetails->duration); // todo: text for live (P0D) & waiting to start (?) if ($d <> '0:00') { $x .= " - $d"; } } elseif (in_array($yt, ['c', 'u', 'h'])) { if (!empty($m[2]) && in_array($m[2], ['videos', 'playlists', 'community', 'channels', 'search'])) { // not home/featured or about $x = ' - ' . ucfirst($m[2]); } elseif (!empty($r->items[0]->snippet->description)) { $d = isset($r->items[0]->localizations->en->description) ? $r->items[0]->localizations->en->description : $r->items[0]->snippet->description; $d = str_replace(["\r\n", "\n", "\t", "\xC2\xA0"], ' ', $d); $x = ' - ' . str_shorten(trim(preg_replace('/\s+/', ' ', $d)), 148); } } $t = "[ " . (isset($r->items[0]->localizations->en->title) ? $r->items[0]->localizations->en->title : $r->items[0]->snippet->title) . "$x ]"; send("PRIVMSG $channel :$title_bold$t$title_bold\n"); continue; } } } if ($invidious_mirror) { goto invidious_continue; } // mirror didnt hit api // odysee if (preg_match("#^https?://odysee\.com/@#", $u)) { $html = curlget([CURLOPT_URL => $u]); if (preg_match('##s', $html, $m)) { $j = json_decode($m[1]); if ($j->{'@type'} ?? false == 'VideoObject' && isset($j->name) && isset($j->duration)) { $t = "[ $j->name - " . covtime($j->duration) . ' ]'; send("PRIVMSG $channel :$title_bold$t$title_bold\n"); if ($title_cache_enabled) { add_to_title_cache($u, $t); } continue; } } } // wikipedia // todo: extracts on subdomains other than en.wikipedia.org, auto-translate? if (preg_match("#^(?:https?://(?:[^/]*?\.)?wiki[pm]edia\.org/wiki/(.*)|https?://upload\.wikimedia\.org)#", $u, $m)) { // handle file urls whether on upload.wikimedia.org thumb or full, direct or url hash $f = ''; if (preg_match("#^https?://upload\.wikimedia\.org/wikipedia/.*/thumb/.*/(.*)/.*#", $u, $m2)) { $f = $m2[1]; } elseif (preg_match("#^https?://upload\.wikimedia\.org/wikipedia/commons/.*/(.*\.\w{3})#", $u, $m2)) { $f = $m2[1]; } elseif (preg_match("#^https?://(?:[^/]*?\.)?wiki[pm]edia\.org/wiki/File:(.*)#", $u, $m2)) { $f = $m2[1]; } elseif (preg_match("#^https?://(?:[^/]*?\.)?wikipedia\.org/wiki/[^\#]*\#/media/File:(.*)#", $u, $m2)) { $f = $m2[1]; } if (!empty($f)) { if (strpos($f, '%') !== false) { $f = urldecode($f); } echo "wikipedia media file: $f\n"; $r = curlget([CURLOPT_URL => 'https://en.wikipedia.org/w/api.php?action=query&format=json&prop=imageinfo&titles=File:' . urlencode($f) . '&iiprop=extmetadata']); $r = json_decode($r, true); if (!empty($r) && !empty($r['query']) && !empty($r['query']['pages'])) { // not sure a file can have more than one desc/page, so just grab first one $k = array_keys($r['query']['pages']); if (!empty($r['query']['pages'][$k[0]])) { $e = $r['query']['pages'][$k[0]]['imageinfo'][0]['extmetadata']['ImageDescription']['value']; $e = strip_tags($e); $e = str_replace(["\r\n", "\n", "\t", "\xC2\xA0"], ' ', $e); // nbsp $e = preg_replace('/\s+/', ' ', $e); $e = html_entity_decode($e); $e = trim($e); $e = str_shorten($e, 280); } if (!empty($e)) { $e = "[ $e ]"; send("PRIVMSG $channel :$title_bold$e$title_bold\n"); if ($title_cache_enabled) { add_to_title_cache($u, $e); } continue; } } } elseif (!empty($m[1])) { // not a file, not upload.wikimedia.org, has /wiki/.* if (!preg_match("/^Category:/", $m[1])) { $e = get_wiki_extract($m[1], 320); // no bolding if (!empty($e)) { send("PRIVMSG $channel :\"$e\"\n"); // else send( "PRIVMSG $channel :Wiki if ($title_cache_enabled) { add_to_title_cache($u, "\"$e\""); } continue; } } } } // grokipedia if (preg_match("#^https://grokipedia\.com/page/.*?(?:\#(.*))?$#", $u, $m)) { $html = curlget([CURLOPT_URL => $u]); $dom = new DomDocument(); @$dom->loadHTML('' . $html); $f = new DomXPath($dom); $nl = $f->query("//article//h1"); if ($nl->length > 0) { $id = $m[1] ?? $nl->item(0)->getAttribute('id'); $nl = $f->query("//article//*[self::h1 or self::h2 or self::h3 or self::h4 or self::h5 or self::h6][@id='{$id}']")->item(0); if ($nl) { $t = ''; $lv = 7; if (preg_match('/^h(\d)$/i', $nl->nodeName, $lm)) { $lv = (int) $lm[1]; } $c = $nl->nextSibling; while ($c) { $cn = $c->nodeName; $ih = preg_match('/^h(\d)$/i', $cn, $lm); if ($ih) { $cl = (int) $lm[1]; if ($cl <= $lv) { break; } } if ($cn === 'table') { $t .= " ..."; $c = $c->nextSibling; continue; } if ($cn === 'sup') { if (preg_match('/^\[\d+]$/', trim($c->textContent))) { $c = $c->nextSibling; continue; } } if ($c->nodeType === 1) { $tmp = $c->cloneNode(true); $table_nodes = $f->query(".//table", $tmp); if ($table_nodes->length > 0) { foreach ($table_nodes as $tab) { $tab->parentNode->replaceChild($dom->createTextNode(" ..."), $tab); } } $s = $f->query(".//sup", $tmp); foreach ($s as $p) { if (preg_match('/^\[\d+]$/', trim($p->textContent))) { $p->parentNode->removeChild($p); } } $co = $dom->saveHTML($tmp); if ($ih) { $t .= "\n" . strip_tags($co) . ": \n"; } elseif ($cn === 'li') { $t .= "- " . strip_tags($co) . "\n"; } elseif (in_array($cn, ['p', 'div', 'blockquote', 'span', 'a'])) { $ct = preg_replace('/\s+/', ' ', strip_tags($co)); $t .= in_array($cn, ['p', 'div', 'blockquote']) ? ($ct . "\n\n") : ($ct . " "); } elseif ($cn === 'br') { $t .= "\n"; } } elseif ($c->nodeType === 3) { $tr = trim($c->nodeValue); if ($tr !== '') { $t .= $tr . " "; } } $c = $c->nextSibling; } $t = preg_replace("/\s*(\n\n\n+)\s*/", "\n\n", $t); $t = trim(preg_replace('/\s+/', ' ', $t)); if ($t == '...') { echo "Skipping snippet output: $t\n"; continue; } if (!empty($t)) { $t = str_shorten($t, 320); send("PRIVMSG $channel :\"$t\"\n"); if ($title_cache_enabled) { add_to_title_cache($u, "\"$e\""); } continue; } } } } // spotify short links step 2 if (preg_match("#^https://spotify\.app\.link/#", $u)) { $html = curlget([CURLOPT_URL => $u]); preg_match('/= $spotify_token_expires - 30) { $j = json_decode(curlget([ CURLOPT_CUSTOMREQUEST => "POST", CURLOPT_URL => "https://accounts.spotify.com/api/token", CURLOPT_POSTFIELDS => "grant_type=client_credentials&client_id=$spotify_client_id&client_secret=$spotify_client_secret", ])); if (isset($j->access_token)) { $spotify_token = $j->access_token; $spotify_token_expires = $time + $j->expires_in; } else { $spotify_token = ""; echo "Error getting Spotify token: " . print_r($j, true) . "\n"; } } if ($spotify_token) { $r = json_decode(curlget([ CURLOPT_URL => "https://api.spotify.com/v1/$m[1]s/$m[2]", CURLOPT_HTTPHEADER => ["Authorization: Bearer $spotify_token"] ])); if (isset($r->name)) { $r->name = trim($r->name); // an episode had a trailing space if ($m[1] == 'artist') { $t = "[ {$r->name} ]"; } elseif ($m[1] == 'playlist') { $t = "[ {$r->name} ]"; } elseif ($m[1] == 'show') { $t = "[ {$r->name} ]"; } elseif ($m[1] == 'episode') { $t = "[ {$r->name} - {$r->show->name} ]"; } elseif ($m[1] == 'album') { $t = "[ {$r->name} - {$r->artists[0]->name} ]"; } elseif ($m[1] == 'track') { $t = "[ {$r->name} - {$r->artists[0]->name} ]"; } send("PRIVMSG $channel :$title_bold$t$title_bold\n"); } else { if (isset($r->error->status) && in_array($r->error->status, [400, 404])) { $t = ucfirst($m[1]) . " not found."; } elseif (isset($r->error->message)) { $t = "API error: {$r->error->message}"; } else { $t = "API error."; } send("PRIVMSG $channel :$t\n"); } continue; } } } // reddit get media url if (preg_match("#^https://(?:\w+\.)?reddit\.com/media#", $u)) { parse_str($parse_url["query"], $tmp); $u = !empty($tmp["url"]) ? $tmp["url"] : $u; } // reddit auth if (!empty($reddit_app_id) && (preg_match("#^https://(?:\w+\.)?reddit\.com/#", $u) || preg_match("#^https://(?:\w+\.)?redd\.it/#", $u))) { if (empty($reddit_token) || $time >= $reddit_token_expires - 30) { $j = json_decode(curlget([ CURLOPT_CUSTOMREQUEST => "POST", CURLOPT_URL => "https://www.reddit.com/api/v1/access_token", CURLOPT_USERPWD => "$reddit_app_id:$reddit_app_secret", CURLOPT_POSTFIELDS => "grant_type=https://oauth.reddit.com/grants/installed_client&device_id=irc_link_previews_" . md5(gethostname()) ])); if (isset($j->access_token)) { $reddit_token = $j->access_token; $reddit_token_expires = $time + $j->expires_in; } else { $reddit_token = ""; echo "Error getting Reddit token: " . print_r($j, true) . "\n"; } } } // reddit share urls - get final url if (preg_match("#^https://(?:\w+\.)?reddit\.com/r/[^/]*?/s/#", $u, $m)) { $u = get_final_url($u, ['header' => [$reddit_token ? "Authorization: Bearer $reddit_token" : ""]]); } // reddit authed - use oauth subdomain if ($reddit_token) { $u = preg_replace('#^https://(?:\w+\.)?reddit\.com#', 'https://oauth.reddit.com', $u); } // reddit image if (strpos($u, '.redd.it/') !== false) { echo "getting reddit image title\n"; $q = substr($u, strpos($u, '.redd.it') + 1); if (strpos($q, '?') !== false) { $q = substr($q, 0, strpos($q, '?')); } for ($i = 2; $i > 0; $i--) { // 2 tries $j = json_decode(curlget([CURLOPT_URL => "https://" . ($reddit_token ? "oauth" : "www") . ".reddit.com/search.json?q=site:redd.it+url:$q", CURLOPT_HTTPHEADER => [$reddit_token ? "Authorization: Bearer $reddit_token" : ""]])); if (isset($j->data->children[0])) { $t = "[ {$j->data->children[0]->data->title} ]"; send("PRIVMSG $channel :$title_bold$t$title_bold\n"); if ($title_cache_enabled) { add_to_title_cache($u, $t); } continue(2); } } } // reddit comment if (preg_match("#^https://(?:\w+\.)?reddit\.com/r/.*?/comments/.*?/.*?/([^/?]+)#", $u, $m)) { if (strpos($m[1], '?') !== false) { $m[1] = substr($m[1], 0, strpos($m[1], '?')); } // id $m[1] = rtrim($m[1], '/'); echo "getting reddit comment. id=$m[1]\n"; if (strpos($u, '?') !== false) { $u = substr($u, 0, strpos($u, '?')); } for ($i = 2; $i > 0; $i--) { // 2 tries $j = json_decode(curlget([CURLOPT_URL => "$u.json", CURLOPT_HTTPHEADER => ["Cookie: _options=%7B%22pref_quarantine_optin%22%3A%20true%7D", $reddit_token ? "Authorization: Bearer $reddit_token" : ""]])); if (!empty($j)) { if (!is_array($j) || !isset($j[1]->data->children[0]->data->id)) { echo "unknown error. response=" . print_r($j, true); break; } if ($j[1]->data->children[0]->data->id <> $m[1]) { echo "error, comment id doesn't match\n"; break; } $a = $j[1]->data->children[0]->data->author; $e = html_entity_decode($j[1]->data->children[0]->data->body_html, ENT_QUOTES); // 'body' has weird format sometimes, predecode for &quot; $e = preg_replace('#
.*?
#ms', ' (...) ', $e); $e = preg_replace('#(.*?)#ms', " $1 ", $e); $e = str_replace('
  • ', ' • ', $e); $e = format_extract($e); if (!empty($e)) { $t = "[ $a: \"$e\" ]"; send("PRIVMSG $channel :$title_bold$t$title_bold\n"); if ($title_cache_enabled) { add_to_title_cache($u, $t); } continue(2); } else { echo "error parsing reddit comment from html\n"; } } else { echo "error getting reddit comment\n"; } if ($i <> 1) { sleep(1); } } } // reddit title if (preg_match("#^https://(?:\w+\.)?reddit\.com/r/.*?/comments/[^/?]+#", $u, $m)) { echo "getting reddit post title\n"; if (strpos($u, '?') !== false) { $u = substr($u, 0, strpos($u, '?')); } for ($i = 2; $i > 0; $i--) { // 2 tries $j = json_decode(curlget([CURLOPT_URL => "$u.json", CURLOPT_HTTPHEADER => ["Cookie: _options=%7B%22pref_quarantine_optin%22%3A%20true%7D", $reddit_token ? "Authorization: Bearer $reddit_token" : ""]])); if (!empty($j)) { if (!is_array($j) || !isset($j[0]->data->children[0]->data->title)) { echo "unknown error. response=" . print_r($j, true); break; } $t = $j[0]->data->children[0]->data->title; $t = format_extract($t, 280, ['keep_quotes' => 1]); if (!empty($t)) { $t = "[ $t ]"; send("PRIVMSG $channel :$title_bold$t$title_bold\n"); if ($title_cache_enabled) { add_to_title_cache($u, $t); } continue(2); } else { echo "error parsing reddit title from html\n"; } } else { echo "error getting reddit title\n"; } if ($i <> 1) { sleep(1); } } } // reddit general - ignore quarantine if (preg_match("#^https://(?:\w+\.)?reddit\.com/r/#", $u)) { $header = ["Cookie: _options={%22pref_quarantine_optin%22:true}"]; } // imdb if (preg_match('#https?://(?:www.)?imdb.com/title/(tt\d*)/?(?:\?.*?)?$#', $u, $m)) { echo "Found imdb link id $m[1]\n"; // same as !m by id, except no imdb link in output $cmd = "https://www.omdbapi.com/?i=" . urlencode($m[1]) . "&apikey=$omdb_key"; echo "cmd=$cmd\n"; for ($i = $num_file_get_retries; $i > 0; $i--) { $tmp = file_get_contents($cmd); $tmp = json_decode($tmp); print_r($tmp); if (!empty($tmp)) { break; } elseif ($i > 1) { sleep(1); } } if (empty($tmp)) { send("PRIVMSG $channel :OMDB API error.\n"); continue; } $tmp3 = ($tmp->Type == 'movie') ? '' : " $tmp->Type"; if ($tmp->Response == 'True') { send("PRIVMSG $channel :\xe2\x96\xb6 $tmp->Title ($tmp->Year$tmp3) | $tmp->Genre | $tmp->Actors | \"$tmp->Plot\" [$tmp->imdbRating]\n"); } elseif ($tmp->Response == 'False') { send("PRIVMSG $channel :$tmp->Error\n"); } else { send("PRIVMSG $channel :OMDB API error.\n"); } continue; } // outline.com if (preg_match('#(?:https://)?outline\.com/([a-zA-Z0-9]*)(?:$|\?)#', $u, $m)) { echo "outline.com url detected\n"; if (!empty($m[1])) { $u = "https://outline.com/stat1k/$m[1].html"; $outline = true; } else { $outline = false; } } else { $outline = false; } // twitter via Nitter if (!empty($twitter_nitter_enabled)) { // tweet if (preg_match('#^https?://(?:mobile\.)?(?:twitter|x)\.com/(?:\#!/)?\w+/status(?:es)?/(\d+)#', $u, $m)) { echo "Getting tweet via Nitter\n"; $html = curlget([CURLOPT_URL => "$twitter_nitter_instance/x/status/$m[1]"]); if (empty($html)) { continue; } $html = str_replace('https://twitter.com', 'https://x.com', $html); $dom = new DomDocument(); @$dom->loadHTML('' . $html); $f = new DomXPath($dom); // unavailable $n = $f->query("//div[contains(@id, 'm')]//div[contains(@class, 'unavailable-box')]"); if (!empty($n) && $n->length > 0) { if (strpos($n[0]->nodeValue, 'Age-restricted') !== false) { $t = "[ Age-restricted. Log in required. ]"; send("PRIVMSG $channel :$title_bold$t$title_bold\n"); if ($title_cache_enabled) { add_to_title_cache($u, $t); } } echo "Tweet unavailable: {$n[0]->nodeValue}\n"; continue; } $n = $f->query("//div[contains(@id, 'm')]//a[contains(@class, 'fullname')]"); if (empty($n) || $n->length === 0) { echo "no fullname\n"; continue; } $a = $n[0]->nodeValue; $n = $f->query("//div[contains(@id, 'm')]//div[contains(@class, 'tweet-content')]"); if (empty($n) || $n->length === 0) { echo "no tweet content\n"; continue; } $b = $n[0]->ownerDocument->saveHTML($n[0]); // get raw html incl anchor tags $b = html_entity_decode($b); $b = str_replace(["\r\n", "\n", "\t", "\xC2\xA0"], ' ', $b); $b = trim(preg_replace('/\s+/', ' ', $b)); $b = preg_replace('#^(.*)$#', '$1', $b); // if has quote-link save it and purge node so its attachments arent found $ql = ''; $n = $f->query("//div[contains(@id, 'm')]//a[contains(@class, 'quote-link')]"); if (!empty($n) && $n->length > 0) { $qh = $n[0]->getAttribute('href'); if (substr($qh, 0, 1) == '/') { $qh = "https://x.com$qh"; } // may always be true $qh = preg_replace('/#m$/', '', $qh); $ql = ' (re ' . make_short_url($qh) . ')'; $n = $f->query("//div[contains(@id, 'm')]//div[contains(@class, 'quote quote-big')]"); if (!empty($n) && $n->length > 0) { $n[0]->parentNode->removeChild($n[0]); } } // shorten and add hint for links, except ^@ and ^# $hl = 0; // track hint lengths to increase max tweet length so never cut off $b = preg_replace('#https?://nitter.net/#', 'https://x.com/', $b); // handling nitter.net is unreliable if (preg_match_all('#.*?#', $b, $m) && !empty($m[0])) { foreach ($m[0] as $v) { preg_match('#(.*)#', $v, $m2); // m2[0] full anchor [1] href [2] text if (preg_match('/^[@#$]/', $m2[2])) { $b = str_replace($m2[0], $m2[2], $b); continue; } if (preg_match('#^https?://[^/]*/i/spaces/#', $m2[1])) { // only link directly to space if mid-sentence as has no like, reply, etc. if (preg_match('#' . preg_quote($m2[0]) . '$#', $b)) { $b = preg_replace('#' . preg_quote($m2[0]) . '$#', '(space)', $b); continue; } else { $m2[1] = preg_replace('#^https?://[^/]*/i/spaces/#', 'https://x.com/i/spaces/', $m2[1]); } } if (substr($m2[1], 0, 1) == '/') { $m2[1] = "https://x.com$m2[1]"; } // shorten displayed link if possible, add hint if needed $fu = get_final_url($m2[1], ['no_body' => 1]); // if link same as quote-link and at beginning or end of tweet, remove it $tmp = '/^' . preg_quote($m2[0], '/') . '|' . preg_quote($m2[0], '/') . '$/'; if ($fu == $qh && preg_match($tmp, $b)) { $b = trim(preg_replace($tmp, '', $b)); continue; } $s = make_short_url($fu); if (mb_strlen($s) < mb_strlen($m2[1])) { $m2[1] = $s; } $h = get_url_hint($fu); if ($h <> get_url_hint($m2[1])) { if (mb_strlen("$m2[1] ($h)") < mb_strlen($fu)) { $b = str_replace($m2[0], "$m2[1] ($h)", $b); $hl += mb_strlen($h) + 3; } else { $b = str_replace($m2[0], $fu, $b); } // no hint, final url < short+hint } else { $b = str_replace($m2[0], $m2[1], $b); } // no hint, same as displayed domain } } // strip additional handles at beginning of deep replies if (substr($b, 0, 1) == '@') { $front = true; $tmps = explode(' ', $b); foreach ($tmps as $k => $tmp) { if ($k == 0) { $tmp2 = $tmp; continue; } if (substr($tmp, 0, 1) == '@' && $front) { continue; } $front = false; $tmp2 .= " $tmp"; } $b = $tmp2; } // pre-finalize $t = "$a: $b"; $t = str_shorten($t, mb_strlen($a) + 282 + $hl); // count attachments foreach (['image', 'gif', 'video'] as $m) { $n = $f->query("//div[contains(@id, 'm')]//div[contains(@class, 'attachment') and contains(@class, '$m')]"); if (!empty($n) && $n->length > 0) { $t = trim($t) . ($n->length == 1 ? " ($m)" : " ($n->length {$m}s)"); } } $n = $f->query("//div[contains(@id, 'm')]//div[contains(@class, 'poll')]"); if (!empty($n) && $n->length > 0) { $t = trim($t) . ' (poll)'; } $t .= $ql; // add quote link, no hint // finalize and output $t = "[ $t ]"; send("PRIVMSG $channel :$title_bold$t$title_bold\n"); if ($title_cache_enabled) { add_to_title_cache($u, $t); } continue; } // bio elseif (preg_match("#^https?://(?:mobile\.)?(?:twitter|x)\.com/(\w*)(?:[?\#].*)?$#", $u, $m)) { continue; } } // twitter via API if (!empty($twitter_consumer_key)) { // tweet if (preg_match('#^https?://(?:mobile\.)?(?:twitter|x)\.com/(?:\#!/)?\w+/status(?:es)?/(\d+)#', $u, $m)) { echo "getting tweet via API.. "; if (!empty($m[1])) { $r = twitter_api('/statuses/show.json', ['id' => $m[1], 'tweet_mode' => 'extended']); if (!empty($r) && !empty($r->full_text) && !empty($r->user->name)) { $t = $r->full_text; // remove twitter media URLs that lead back to the same tweet in long tweets if (isset($r->entities->urls)) { foreach ($r->entities->urls as $v) { if (preg_match('#^https://(?:twitter|x)\.com/i/web/status/(\d+)#', $v->expanded_url, $m2)) { if (!empty($m2[1]) && $m2[1] == $m[1]) { $t = str_replace("… $v->url", ' ...', $t); $t = trim(str_replace(" $v->url", ' ', $t)); } } } } $mcnt = 0; $mtyp = ''; foreach ($r->extended_entities->media as $v) { $mcnt++; $mtyp = $v->type; $t = str_replace($v->url, ' ', $t); if (isset($v->additional_media_info->call_to_actions->watch_now)) { $mtyp = 'video'; } // weird embeds that show as photos but are actually videos } if ($mtyp == 'photo') { $mtyp = 'image'; } elseif ($mtyp == 'animated_gif') { $mtyp = 'gif'; } if ($mcnt > 0) { $t .= ' ' . ($mcnt == 1 ? "($mtyp)" : "($mcnt {$mtyp}s)"); } // add a hint for external links foreach ($r->entities->urls as $v) { $h = get_url_hint($v->expanded_url); $t = str_replace($v->url, "$v->url ($h)", $t); } $t = str_replace(["\r\n", "\n", "\t"], ' ', $t); $t = html_entity_decode($t, ENT_QUOTES | ENT_HTML5, 'UTF-8'); $t = trim(preg_replace('/\s+/', ' ', $t)); if (substr($t, 0, 1) == '@') { // strip additional handles at beginning of deep replies $front = true; $tmps = explode(' ', $t); foreach ($tmps as $k => $tmp) { if ($k == 0) { $tmp2 = $tmp; continue; } if (substr($tmp, 0, 1) == '@' && $front) { continue; } $front = false; $tmp2 .= " $tmp"; } $t = $tmp2; } $t = '[ ' . str_shorten("{$r->user->name}: $t") . ' ]'; send("PRIVMSG $channel :$title_bold$t$title_bold\n"); } else { echo "failed. result=" . print_r($r, true); if (!empty($r->errors) && ($r->errors[0]->code == 8 || $r->errors[0]->code == 144)) { send("PRIVMSG $channel :Tweet not found.\n"); } } continue; // always abort, won't be a non-tweet URL } // bio } elseif (preg_match("#^https?://(?:mobile\.)?(?:twitter|x)\.com/(\w*)(?:[?\#].*)?$#", $u, $m)) { echo "getting twitter bio via API.. "; if (!empty($m[1])) { $r = twitter_api('/users/show.json', ['screen_name' => $m[1]]); if (!empty($r) && empty($r->errors)) { echo "ok\n"; $t = $r->name; if (!empty($r->description)) { $d = $r->description; foreach ($r->entities->description->urls as $v) { $h = get_url_hint($v->expanded_url); $d = str_replace($v->url, "$v->url ($h)", $d); } $d = str_replace(["\r\n", "\n", "\t"], ' ', $d); $d = html_entity_decode($d, ENT_QUOTES | ENT_HTML5, 'UTF-8'); $d = trim(preg_replace('/\s+/', ' ', $d)); $t .= " | $d"; } if (!empty($r->url)) { $u = $r->entities->url->urls[0]->expanded_url; $u = preg_replace('#^(https?://[^/]*?)/$#', "$1", $u); // strip trailing slash on domain-only links $t .= " | $u"; } $t = "[ $t ]"; send("PRIVMSG $channel :$title_bold$t$title_bold\n"); continue; // only abort if found, else might be a non-profile URL } else { echo "failed. result=" . print_r($r, true); // send("PRIVMSG $channel :Twitter user not found.\n"); } } } } // truth social if (preg_match('#^https?://truthsocial\.com/.*?(?:statuses|@\w+|posts)/(\d+)#', $u, $m)) { if ($curl_impersonate_enabled) { // has high CF protection // post echo "Getting Truth via API\n"; $r = curlget([CURLOPT_URL => "https://truthsocial.com/api/v1/statuses/$m[1]"]); $r = @json_decode($r); if (isset($r->id)) { // clean up content $b = $r->content; $b = html_entity_decode($b, ENT_QUOTES | ENT_HTML5, 'UTF-8'); $b = str_replace(["\r\n", "\n", "\t"], ' ', $b); $b = str_replace('…', '...', $b); $b = str_replace("‘", "'", str_replace("’", "'", $b)); # fancy quotes $b = str_replace("“", '"', str_replace("”", '"', $b)); $b = preg_replace("/^

    /", "", $b); $b = preg_replace("#

    $#", "", $b); $b = str_replace("

    ", "\n\n", $b); $b = str_replace("
    ", "\n", $b); $b = str_replace("
    ", "\n", $b); $b = preg_replace('#.*?(.*)#s', "$1", $b); $b = str_replace(" ", " ", str_replace(" ", " ", str_replace("", "", $b))); # weird invis char $b = preg_replace("/ +/", " ", $b); $b = str_replace("https://truthsocial.com/tags/", "#", $b); $b = trim(preg_replace('/\s+/', ' ', $b)); // save quote link $ql = !empty($r->quote) ? ' (re: ' . make_short_url($r->quote->url) . ')' : ''; // shorten and add hint for links $hl = 0; // track hint lengths to increase max tweet length so never cut off if (preg_match_all('#.*?#', $b, $m) && !empty($m[0])) { foreach ($m[0] as $v) { preg_match('#(.*)#', $v, $m2); // m2[0] full anchor [1] href [2] text // shorten displayed link if possible, add hint if needed $fu = get_final_url($m2[1], ['no_body' => 1]); $s = make_short_url($fu); if (mb_strlen($s) < mb_strlen($m2[1])) { $m2[1] = $s; } $h = get_url_hint($fu); if ($h <> get_url_hint($m2[1])) { if (mb_strlen("$m2[1] ($h)") < mb_strlen($fu)) { $b = str_replace($m2[0], "$m2[1] ($h)", $b); $hl += mb_strlen($h) + 3; } else { $b = str_replace($m2[0], $fu, $b); } // no hint, final url < short+hint } else { $b = str_replace($m2[0], $m2[1], $b); } // no hint, same as displayed domain } } // pre-finalize $t = "{$r->account->display_name}: $b"; $t = str_shorten($t, mb_strlen($r->account->display_name) + 282 + $hl); // count attachments foreach (['image', 'gifv', 'tv', 'video'] as $m) { $n = 0; foreach ($r->media_attachments as $ma) { if ($ma->type == $m) { $n++; } } if ($m == 'gifv') { $m = 'gif'; } if ($n > 0) { $t = trim($t) . ($n == 1 ? " ($m)" : " ($n {$m}s)"); } } $t .= $ql; // add quote link, no hint // finalize and output $t = "[ $t ]"; send("PRIVMSG $channel :$title_bold$t$title_bold\n"); if ($title_cache_enabled) { add_to_title_cache($u, $t); } } elseif (isset($r->error) and $r->error == 'Record not found') { send("PRIVMSG $channel : Post does not exist.\n"); } else { echo "Error getting Truth Social post. Result: " . print_r($r, true) . "\n"; } } else { echo "Truth Social links require \$curl_impersonate_enabled\n"; } continue; } // bluesky // TODO convert to at-uri without loading page? if (preg_match('#^https?://bsky.app/profile/[^/]+/post/[^/]+#', $u)) { $html = curlget([CURLOPT_URL => $u]); if ($curl_info['RESPONSE_CODE'] == 200) { $dom = new DomDocument(); @$dom->loadHTML('' . $html); $f = new DomXPath($dom); $n = $f->query("/html/head/link[starts-with(@href,'at://')]"); if (!empty($n) && $n->length > 0) { $at = $n[0]->getAttribute('href'); // echo "found bluesky at-uri $at\n"; $r = @json_decode(curlget([CURLOPT_URL => "https://public.api.bsky.app/xrpc/app.bsky.feed.getPosts?uris=$at"])); if (!empty($r)) { // print_r($r); if (isset($r->posts[0])) { // clean up content $b = $r->posts[0]->record->text; $b = html_entity_decode($b, ENT_QUOTES | ENT_HTML5, 'UTF-8'); $b = str_replace(["\r\n", "\n", "\t"], ' ', $b); $b = str_replace('…', '...', $b); $b = str_replace("‘", "'", str_replace("’", "'", $b)); # fancy quotes $b = str_replace("“", '"', str_replace("”", '"', $b)); $b = preg_replace('#(?posts[0]->author->displayName}: $b"; $t = str_shorten($t, mb_strlen($r->posts[0]->author->displayName) + 282); // + $hl $ql = ''; if (!empty($r->posts[0]->record->embed)) { $et = explode('.', $r->posts[0]->record->embed->{'$type'})[3]; if (($et == 'record' || $et == 'recordWithMedia') && strpos('/app.bsky.feed.post/', $r->posts[0]->record->embed->record->uri) !== -1) { if (isset($r->posts[0]->record->embed->record->uri)) { $uri = explode('/', $r->posts[0]->record->embed->record->uri); } else { $uri = explode('/', $r->posts[0]->record->embed->record->record->uri); } $ql = ' (re: ' . make_short_url("https://bsky.app/profile/{$uri[2]}/post/{$uri[4]}") . ')'; } if ($et == 'images' || ($et == 'recordWithMedia' && !empty($r->posts[0]->record->embed->media->images))) { if (isset($r->posts[0]->record->embed->images)) { $n = count($r->posts[0]->record->embed->images); } else { $n = count($r->posts[0]->record->embed->media->images); } $t = rtrim($t) . ' (' . ($n > 1 ? "$n " : '') . 'image' . ($n > 1 ? 's' : '') . ')'; } if ($et == 'video' || ($et == 'recordWithMedia' && !empty($r->posts[0]->record->embed->media->video))) { $t = rtrim($t) . ' (video)'; } if ($et == 'external' || ($et == 'recordWithMedia' && !empty($r->posts[0]->record->embed->media->external))) { if (isset($r->posts[0]->record->embed->external->uri)) { $uri = $r->posts[0]->record->embed->external->uri; } else { $uri = $r->posts[0]->record->embed->media->external->uri; } // if post text ends in part of the embed link, get rid of it $tmp = explode(' ', $r->posts[0]->record->text); $tmp = rtrim(trim($tmp[count($tmp) - 1]), '.'); $tmp2 = preg_replace('#^https?://#', '', $uri); if (substr($tmp2, 0, strlen($tmp)) == $tmp) { $t = trim(preg_replace('#' . preg_quote($tmp) . '\.+#', '', $t)); } // add link at the end (post-shorten; not like twitter, etc) $fu = get_final_url($uri, ['no_body' => 1]); $s = make_short_url($fu); if ($s <> $fu) { $h = get_url_hint($fu); if (mb_strlen("$s ($h)") < mb_strlen($fu)) { $t = rtrim($t) . " $s ($h)"; } else { $t = rtrim($t) . " $fu"; } } else { $t = rtrim($t) . ' (link)'; } // no short url, could be very long } // TODO facets, so mid-text / non-external embed links are processed properly, excluding duplicate externals. e.g. https://bsky.app/profile/propublica.org/post/3lmky7ypvhs2k https://bsky.app/profile/joshuajfriedman.com/post/3lmikawq2ds2j } $t = rtrim($t) . $ql; // add quote link, no hint // finalize and output $t = "[ $t ]"; send("PRIVMSG $channel :$title_bold$t$title_bold\n"); if ($title_cache_enabled) { add_to_title_cache($u, $t); } continue; } } else { // post not found echo "error reading bluesky post\n"; } } } } // tiktok if (preg_match('#^https?://(?:www\.)?tiktok\.com/@[A-Za-z0-9._]+/video/\d+#', $u, $m)) { $r = curlget([CURLOPT_URL => "https://www.tiktok.com/oembed?url=$m[0]"]); $j = @json_decode($r); if (isset($j->title) && isset($j->author_name)) { $j->title = str_shorten(trim($j->title), 160); $t = "{$j->author_name}: {$j->title}"; // author_name <= 30 $t = "[ $t ]"; send("PRIVMSG $channel :$title_bold$t$title_bold\n"); if ($title_cache_enabled) { add_to_title_cache($u, $t); } continue; } else { echo "Error getting TikTok video URL details, got:\n" . trim($r) . "\n"; } } // instagram if (preg_match('#https?://(?:www\.)?instagram\.com/p/([A-Za-z0-9-_]*)#', $u, $m)) { echo "getting instagram post info\n"; if (!empty($m[1])) { $t = ''; $r = @json_decode(file_get_contents("https://www.instagram.com/p/$m[1]/?__a=1")); if (!empty($r) && !empty($r->graphql->shortcode_media)) { $m = $r->graphql->shortcode_media; $i = 0; $v = 0; if ($m->__typename == 'GraphImage') { $i = 1; } elseif ($m->__typename == 'GraphVideo') { $v = 1; } elseif ($m->__typename == 'GraphSidecar') { foreach ($m->edge_sidecar_to_children->edges as $a) { if ($a->node->__typename == 'GraphImage') { $i++; } elseif ($a->node->__typename == 'GraphVideo') { $v++; } } } if ($i > 0 || $v > 0) { if ($i > 0 && $v > 0) { $p = "$i image" . ($i > 1 ? 's' : '') . ", $v video" . ($v > 1 ? 's' : ''); } else { if ($i > 0) { $p = $i == 1 ? 'image' : "$i images"; } elseif ($v > 0) { $p = $v == 1 ? 'video' : "$v videos"; } } } else { $p = ''; } $c = $m->edge_media_to_caption->edges[0]->node->text; // $n=$m->owner->username; $f = $r->graphql->shortcode_media->owner->full_name; if (!empty($n)) { if (!empty($c)) { $t = str_replace(["\r\n", "\n", "\t", "\xC2\xA0"], ' ', "$f: $c"); $t = trim(preg_replace('/\s+/', ' ', $t)); $t = str_shorten($t, 280); } else { $t = "$n:"; } if (!empty($p)) { $t .= " ($p)"; } $t = "[ $t ]"; send("PRIVMSG $channel :$title_bold$t$title_bold\n"); if ($title_cache_enabled) { add_to_title_cache($u, $t); } continue; } } } } # Facebook if (preg_match('#^https?://(?:www\.)?facebook\.com/reel/(\d+)#', $u, $m)) { $use_meta_tag = 'description'; } if (preg_match('#^https?://(?:www\.)?facebook\.com/photo/#', $u, $m)) { $use_meta_tag = 'description'; } // twitch via api if (!empty($twitch_client_id) && preg_match('#https?://(?:www\.)?twitch\.tv/(\w+)(/\w+)?#', $u, $m)) { // get token, don't revalidate because won't be revoked - https://dev.twitch.tv/docs/authentication echo "Getting Twitch token.. "; if (empty($twitch_token) || $twitch_token_expires < time()) { $r = json_decode(curlget([CURLOPT_URL => "https://id.twitch.tv/oauth2/token?client_id=$twitch_client_id=&client_secret=$twitch_client_secret&grant_type=client_credentials", CURLOPT_POST => 1, CURLOPT_HTTPHEADER => ["Client-ID: $twitch_client_id"]])); if (!empty($r) && !empty($r->access_token)) { echo "ok.\n"; $twitch_token = $r->access_token; $twitch_token_expires = time() + $r->expires_in - 30; } else { if (isset($r->message)) { echo "error: $r->message\n"; } else { echo "error, r=" . print_r($r, true); } $t = '[ API error ]'; send("PRIVMSG $channel :$title_bold$t$title_bold\n"); $twitch_token = ''; $twitch_token_expires = 0; continue; } } else { echo "ok.\n"; } if (!empty($twitch_token)) { // get user info - https://dev.twitch.tv/docs/api/reference#get-users echo "Getting user info for \"$m[1]\".. "; $r = json_decode(curlget([CURLOPT_URL => "https://api.twitch.tv/helix/users?login=$m[1]", CURLOPT_HTTPHEADER => ["Client-ID: $twitch_client_id", "Authorization: Bearer $twitch_token"]])); if (!empty($r) && isset($r->data)) { if (isset($r->data[0])) { echo "ok.\n"; $un = $r->data[0]->display_name; $ud = $r->data[0]->description; // shorten if (!empty($m[2])) { // just show subdir $t = "[ $un: " . ucfirst(substr($m[2], 1)) . " ]"; send("PRIVMSG $channel :$title_bold$t$title_bold\n"); continue; } else { // get live stream info - https://dev.twitch.tv/docs/api/reference#get-streams-metadata echo "Getting live stream info.. "; $r = json_decode(curlget([CURLOPT_URL => "https://api.twitch.tv/helix/streams?user_login=$m[1]", CURLOPT_HTTPHEADER => ["Client-ID: $twitch_client_id", "Authorization: Bearer $twitch_token"]])); if (!empty($r) && isset($r->data)) { if (count($r->data) > 0) { // check for live stream foreach ($r->data as $d) { if ($d->type == 'live') { echo "ok.\n"; $t = str_replace(["\r\n", "\n", "\t", "\xC2\xA0"], ' ', $d->title); $t = trim(preg_replace('/\s+/', ' ', $t)); $t = str_shorten($t, 424); $t = "[ $t ]"; send("PRIVMSG $channel :$title_bold$t$title_bold\n"); continue(2); } } } // no streams, show user info echo "not streaming\n"; $t = str_replace(["\r\n", "\n", "\t", "\xC2\xA0"], ' ', "$un: $ud"); $t = trim(preg_replace('/\s+/', ' ', $t)); $t = str_shorten($t, 424); $t = "[ $t ]"; send("PRIVMSG $channel :$title_bold$t$title_bold\n"); continue; } else { if (isset($r->message)) { echo "error: $r->message\n"; } else { echo "error, r=" . print_r($r, true); } } } } else { echo "not found, abort\n"; } } else { // api or connection error, shouldnt usually happen, continue silently if (isset($r->message)) { echo "error: $r->message\n"; } else { echo "error, r=" . print_r($r, true); } continue; } } } // gab social if (preg_match('#https://(?:www\.)?gab\.com/[^/]+/posts/(\d+)#', $u)) { $gab_post = true; $use_meta_tag = 'og:description'; } else { $gab_post = false; } // telegram (todo: use api to get message details i.e. whether has a video or image) if (preg_match("#^https?://t\.me/#", $u, $m)) { $use_meta_tag = 'og:description'; $meta_skip_blank = true; } // poa.st if (preg_match("#^https?://poa\.st/@[^/]+/posts/#", $u) || preg_match("#^https?://poa\.st/notice/#", $u)) { $use_meta_tag = 'og:description'; $meta_skip_blank = true; } // msn.com articles - title via ajax if (preg_match("#^(?:www\.)?msn\.com/.*?/ar-([^/]*)$#", $parse_url['host'] . $parse_url['path'], $m)) { $r = json_decode(curlget([CURLOPT_URL => "https://assets.msn.com/content/view/v2/Detail/en-us/$m[1]"])); if (isset($r->title)) { $t = "[ " . str_shorten($r->title, 424) . " ]"; send("PRIVMSG $channel :$title_bold$t$title_bold\n"); if ($title_cache_enabled) { add_to_title_cache($u, $t); } continue; } } // militarywatchmagazine.com articles - title via ajax if (preg_match("#^https?://(?:www\.)?militarywatchmagazine\.com/article/([^?\#]*)#", $u, $m)) { $r = json_decode(curlget([CURLOPT_URL => "https://militarywatchmagazine.com/i_s/api/records/articles?filter=article_identifier,eq,$m[1]"])); if (isset($r->records[0]->article_title)) { $t = "[ " . str_shorten($r->records[0]->article_title, 424) . " ]"; send("PRIVMSG $channel :$title_bold$t$title_bold\n"); if ($title_cache_enabled) { add_to_title_cache($u, $t); } continue; } } // govdeals.com assets - title via ajax if (preg_match('#^https?://www.govdeals.com/\w+/asset/(\d+/\d+)#', $u, $m)) { $html = curlget([CURLOPT_URL => $u]); // get main js if ($curl_info['RESPONSE_CODE'] == 200 && preg_match('#