#!/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 "
$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('#