GancioF2F/gancioff

607 lines
30 KiB
PHP
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/php
<?php
/*
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
$SNAME='GancioFF';
$ENAME=strtolower($SNAME);
$SVERS='0.4';
require __DIR__.'/lib/gettlds.php';
require __DIR__.'/lib/mastodon-postLength.php';
require __DIR__.'/lib/hashtag.php';
require __DIR__.'/lib/html2text.php';
require __DIR__.'/lib/curl.php';
$help=
"[[[ SYNOPSIS ]]]
{$ENAME} [options] <configuration file path>
[[[ DESCRIPTION ]]]
This is {$SNAME} v{$SVERS}, a CLI PHP script that can be used to periodically
fetch the RSS feed from an instance of Gancio (https://gancio.org) and post
its new entries the events on the fediverse through a Mastodon account,
keeping track of already posted events GUIDs (Global Unique IDentifiers) in
order to post only the new ones on each run.
It can be useful, for example, when the admins of a Gancio instance chose not
to use its federation feature because it would be too heavy on its server: in
this case {$SNAME} is a quite light alternative, moving from the server
running Gancio to the one running Mastodon the burden of posting each event to
all the instances that host at least one follower, and of sending them the
image a Gancio user can and almost always do attach to each event, because
{$SNAME} will fetch it only once and attach it to the post for the event.
{$SNAME} is meant to be periodically run, every half an hour or so, by a cron
job, or systemd timer, or the likes (you can find a sample «{$ENAME}.timer»
and a commented sample «{$ENAME}.service» in the «systemd» directory).
In order to work, {$SNAME} needs a configuration file path to be passed to it
as an argument on the command line.
[[[ CONFIGURATION FILE ]]]
The configuration file needs to be like this:
--- Example configuration file ---
# Lines beginnig with a «#» and empty lines will be ignored
# «feed_url» is required to specify the URL to fetch the RSS feed from;
# for example:
feed_url = https://gancio.some.domain/feed/rss?show_recurrent=true
# «fedi_hostname» is required to specify the hostname of the Mastodon instance
# you want to post to; for example:
fedi_hostname = mastodon.another.domain
# «fedi_token» is required to specify an «app token» to access the account
# that you want to use on the instance defined by «fedi_hostname». On Mastodon
# default web frontend you can get such a token under «Preferences» ->
# «Development», by clicking on the «New application» button; the new
# application should have at least the «write:media» and «write:statuses»
# privileges; when youll be done setting it up, it will be listed under
# «Your applications», and by clicking on its name youll be able to copy
# «Your access token» and paste it here. For example:
fedi_token = w6oQ_Ot2LSAm_Q31hrvp0asfl22ip3O4ipYq1kV1ceY
# «state_file_absolute_path» is required to specify the absolute path of the
# state file where {$SNAME} will store the GUIDs of already posted events and
# the timestamps of the moments when it posted them (on each run, {$SNAME}
# will check for entries older than one year and discard them, in order to
# avoid the state file to grow too much). For example:
state_file_absolute_path = /var/local/cache/gancio.some.domain.feed.state
# «posts_language» is required to specify the ISO 639-1 code for the language
# of posts (see https://www.loc.gov/standards/iso639-2/php/code_list.php for
# a complete list); for example:
posts_language = it
# «posts_visibility» is optional and lets you override the default “public”
# visibility of posts; it can be set to «public» (posts will be visible in the
# «Local» and «Federated» timelines, and any user will be able to boost them),
# «unlisted» (posts will be visible only in the «Home» timeline of followers
# and on the profile of the Mastodon account in use, not in the «Local» or
# «Federated» timelines, but any user will still be able to boost them),
# «private» (AKA «followers only»: posts will be visible only by followers and
# wont be boostable by anyone), and «direct» (since {$SNAME} posts wont ever
# explicitly mention any account, posts with this visibility will be visible
# only from the Mastodon account in use). For example:
post_visibility = unlisted
# «max_post_length» is optional and lets you override the automatically
# detected maximum length that a post can have on the instance specified with
# «fedi_hostname»; it can be used for testing purposes or just to keep the
# posts shorter than they would be otherwise; for example:
max_post_length = 840
# «always_link_gancio_post» is optional and if unspecified it defaults to
# «false», which means that {$SNAME} adds to the Mastodon post a link to the
# original Gancio post only if the latter is too long to fit into the first
# (i.e. into the Mastodon instance “max post length”, or into the
# «max_post_length» specified in this configuration file - see above); this
# way, {$SNAME} reduces the burden on the Gancio instance that is due to the
# requests that it gets from every Mastodon instance where a Mastodon post
# with a link to the original Gancio post will end up, in order for each of
# them to generate a “link preview”; such burden gets reduced in different
# measures depending on the average length of a post on the Gancio instance
# and on the “max post length” on the Mastodon instance that {$SNAME} is using
# to post (or on the «max_post_length» explicitly specified in this file).
# If set to «true», {$SNAME} will instead always add a link to the original
# Gancio post.
always_link_gancio_post = true
--- End of example configuration file ---
[[[ OPTIONS ]]]
-h / --help
Show this help text and exit.
-p / --do-post <y|n>
When a state file already exists, this option defaults to «y» («yes»), which
means that {$SNAME} will try to post all the new events it may find in the
feed; if set to «n» («no»), {$SNAME} will not try to post them, but it will
save their GUIDs into the state file nonetheless, so they wont be posted
again on subsequent runs.
This is mainly useful on {$SNAME}s first run on a given feed, i.e. when
the state file specified in the configuration file doesnt exist yet and thus
all the events in the feed will be considered “new”: in this case, {$SNAME}
refuses to run unless you explicitly set this option to «y» or «n»: this is a
way to prevent you from unintentionally flooding your Mastodon instance with
all the events in the feed.
When “test mode” is active (see the next option description), setting this
option has no effect.
-t / --test
Do a test: {$SNAME} will try as always to read the configuration file, fetch
the defined Mastodon instances info, load the state file and fetch the feed,
but it will post only the first event it may find there, with a visibility of
«direct», even if according to the state file it has already been posted, and
wont update the state file.
This option also activates “verbose mode” (see below).
-v / --verbose
When this option is not set {$SNAME} prints only warning and error messages;
when it is set it also prints informational messages about what its doing.
--
Treat every possible subsequent argument as non-options. Useful only in the
very improbable case your config file is named «--help» or as another option.
[[[ EXIT VALUES ]]]
0: regular run
1: some error occurred
99: killed with signal (ctrl+c, etc.)
[[[ DISCLAIMER AND LICENSE ]]]
This program comes with ABSOLUTELY NO WARRANTY; for details see the source.
This is free software, and you are welcome to redistribute it under certain
conditions; see <http://www.gnu.org/licenses/> for details.\n";
$confFP=null;
$conf=[
'feed_url'=>['required'=>true, 'default'=>null],
'fedi_hostname'=>['required'=>true, 'default'=>null],
'fedi_token'=>['required'=>true, 'default'=>null],
'state_file_absolute_path'=>['required'=>true, 'default'=>null],
'posts_language'=>['required'=>true, 'default'=>null],
'posts_visibility'=>['required'=>false, 'default'=>'public'],
'max_post_length'=>['required'=>false, 'default'=>null],
'always_link_gancio_post'=>['required'=>false, 'default'=>false]
];
$opts=[
'test'=>false,
'do-post'=>null,
'verbose'=>false,
'update-language-codes'=>false
];
$canBeOpt=true;
for ($i=1; $i<$argc; $i++) {
if ($canBeOpt && $argv[$i][0]=='-') {
if ($argv[$i]=='--') {
$canBeOpt=false;
} elseif ($argv[$i]=='-h' || $argv[$i]=='--help') {
echo $help;
exit(0);
} elseif ($argv[$i]=='-t' || $argv[$i]=='--test') {
$opts['test']=true;
$opts['verbose']=true;
} elseif ($argv[$i]=='-p' || $argv[$i]=='--do-post') {
if ($i+1>=$argc) dieYoung("Error: option «{$argv[$i]}» requires an argument; use «-h» or «--help» to display help.\n",1);
if ($argv[$i+1]=='y')
$opts['do-post']=true;
elseif ($argv[$i+1]=='n')
$opts['do-post']=false;
else
dieYoung("Error: option «{$argv[$i]}» requires an argument of «y» for «yes» or «n» for «no»; use «-h» or «--help» to display help.\n",1);
$i++;
} elseif ($argv[$i]=='-v' || $argv[$i]=='--verbose') {
$opts['verbose']=true;
} elseif ($argv[$i]=='-u' || $argv[$i]=='--update-language-codes') {
$opts['update-language-codes']=true;
} elseif ($argv[$i]=='--make-readme') {
file_put_contents(__DIR__.'/README.md',"```text\n{$help}```\n");
exit(0);
} else {
dieYoung("Error: «{$argv[$i]}» is not a known option; use «-h» or «--help» to display help.\n",1);
}
} elseif (is_null($confFP)) {
$confFP=$argv[$i];
} else {
dieYoung("Error: could not interpret «{$argv[$i]}» (configuration file has already been set to «{$confFP}»); use «-h» or «--help» to display help.\n",1);
}
}
//declare(ticks=1);
pcntl_async_signals(true);
pcntl_signal(SIGTERM,'sighandler');// Termination ('kill' was called)
pcntl_signal(SIGHUP,'sighandler');// Terminal log-out
pcntl_signal(SIGINT,'sighandler');// Interrupted (ctrl-c is pressed)
$langsFP=__DIR__.'/lib/iso-639-1-langcodes.txt';
if ($opts['update-language-codes']) {
$langCodes=[];
$url='https://www.loc.gov/standards/iso639-2/ISO-639-2_8859-1.txt';
$res=curl($url);
if ($res['content']===false) dieYoung("Error: could not connect to «{$url}».\n",1);
if ($res['httpcode']!='200') dieYoung("Error: got http code «{$res['httpcode']}» from «{$url}».\n",1);
$res=explode("\r\n",$res['content']);
// alb|sqi|sq|Albanian|albanais
// tup|||Tupi languages|tupi, langues
foreach ($res as $val)
if (preg_match('#^[a-z]{3}\|([a-z]{3})?\|([a-z]{2})\|.+\|.+$#',$val,$matches)===1)
$langCodes[]=$matches[2];
$count=count($langCodes);
if (@file_put_contents($langsFP,implode("\n",$langCodes)."\n")===false) dieYoung("Error: could not save the {$count} ISO 639-1 language code(s) i got from «{$url}» into «{$langsFP}».\n",1);
vecho($opts['verbose'],"Info: successfully saved the {$count} ISO 639-1 language code(s) i got from «{$url}» into «{$langsFP}».\n");
exit(0);
}
if (($langs=@file($langsFP,FILE_IGNORE_NEW_LINES|FILE_SKIP_EMPTY_LINES))===false) dieYoung("Error: could not load ISO 639-1 language codes from «{$langsFP}».\n",1);
if (is_null($confFP)) dieYoung("Error: you have not specified a configuration file; use «-h» or «--help» to display help.\n",1);
vecho($opts['verbose'],"Info: trying to load configuration file «{$confFP}» from directory «".getcwd()."».\n");
if (!file_exists($confFP)) dieYoung("Error: «{$confFP}» does not exist.\n",1);
if (!is_file($confFP)) dieYoung("Error: «{$confFP}» is not a file.\n",1);
if (!is_readable($confFP)) dieYoung("Error: «{$confFP}» is not readable.\n",1);
getConf($conf,$confFP);
if (preg_match('#^/.*$#',$conf['state_file_absolute_path'])!==1) dieYoung("Error: in configuration file: «state_file_absolute_path» must be an absolute path.\n",1);
if (!in_array($conf['posts_language'],$langs)) dieYoung("Error: in configuration file: «posts_language»: «{$conf['posts_language']}» is not a known language code.\n",1);
if (!in_array($conf['posts_visibility'],['public', 'unlisted', 'private', 'direct'])) dieYoung("Error: in configuration file: «posts_visibility» must be one of «public», «unlisted», «private» or «direct».\n",1);
if (!is_null($conf['max_post_length'])) {
if (preg_match('#^\d+$#',$conf['max_post_length'])!==1 || $conf['max_post_length']+0<10) dieYoung("Error: configuration file: «max_post_length» must be an integer greater than or equal to 10.\n",1);
$conf['max_post_length']+=0;
vecho($opts['verbose'],"Info: got «{$conf['max_post_length']}» as «max_post_length» from configuration file.\n");
}
if (!is_bool($conf['always_link_gancio_post']) && preg_match('#^(true|false)$#',$conf['always_link_gancio_post'])!==1) {
dieYoung("Error: configuration file: «always_link_gancio_post» must be «true» or «false».\n",1);
} else {
($conf['always_link_gancio_post']=='true') ? $conf['always_link_gancio_post']=true : $conf['always_link_gancio_post']=false;
}
if ($opts['test']) $conf['posts_visibility']='direct';
vecho($opts['verbose'],"Info: got good configuration from configuration file.\n");
$tldsregex=gettlds(__DIR__.'/storage/tlds.txt',true);
$url="https://{$conf['fedi_hostname']}/api/v2/instance";
vecho($opts['verbose'],"Info: trying to fetch instance info from «{$url}».\n");
$res=curl($url,null,["Authorization: Bearer {$conf['fedi_token']}", 'Accept: application/json']);
if ($res['content']===false) dieYoung("Error: could not connect to «{$url}» (error: «{$res['error']}»).\n",1);
$res['content']=@json_decode($res['content'],true);
if (is_null($res['content'])) dieYoung("Error: content from «{$url}» was not good JSON.\n",1);
(isset($res['content']['error'])) ? $buff="{$res['content']['error']}»)" : $buff='';
if ($res['httpcode']!='200') dieYoung("Error: got http code «{$res['httpcode']}»{$buff} from «{$url}».\n",1);
if (!isset($res['content']['configuration']['media_attachments']['image_size_limit'])) dieYoung("Error: JSON from «{$url}» doesnt declare «image_size_limit».\n",1);
if (!is_int($res['content']['configuration']['media_attachments']['image_size_limit'])) dieYoung("Error: JSON from «{$url}» declares «image_size_limit» with an unexpected format.\n",1);
$conf['max_image_size']=$res['content']['configuration']['media_attachments']['image_size_limit']+0;
vecho($opts['verbose'],"Info: got «{$conf['max_image_size']}» as «max_image_size» from «{$url}».\n");
if (!isset($res['content']['configuration']['statuses']['max_characters'])) dieYoung("Error: JSON from «{$url}» doesnt declare «max_characters».\n",1);
if (!is_int($res['content']['configuration']['statuses']['max_characters'])) dieYoung("Error: JSON from «{$url}» declares «max_characters» with an unexpected format.\n",1);
if (is_null($conf['max_post_length'])) {
$conf['max_post_length']=$res['content']['configuration']['statuses']['max_characters']+0;
vecho($opts['verbose'],"Info: got «{$conf['max_post_length']}» as «max_post_length» from «{$url}».\n");
}
//print_r($conf);
$guids=[];
vecho($opts['verbose'],"Info: trying to load GUIDs of already posted events from state file «{$conf['state_file_absolute_path']}».\n");
if (file_exists($conf['state_file_absolute_path'])) {
if (!is_file($conf['state_file_absolute_path'])) dieYoung("Error: «{$conf['state_file_absolute_path']}» exists but its not a file.\n",1);
if (!is_readable($conf['state_file_absolute_path'])) dieYoung("Error: «{$conf['state_file_absolute_path']}» exists but its not readable.\n",1);
if (!is_writeable($conf['state_file_absolute_path'])) dieYoung("Error: «{$conf['state_file_absolute_path']}» exists but its not writable.\n",1);
$guids=[];
$buff=file($conf['state_file_absolute_path'],FILE_IGNORE_NEW_LINES|FILE_SKIP_EMPTY_LINES);
$graceTime=365*24*60*60;
$graceLine=time()-$graceTime;
$i=0;
$fh=fopen($conf['state_file_absolute_path'],'w');
foreach ($buff as $key=>$val) {
if (preg_match('#^(\d+)\t([a-z0-9]{64})\t(\S+)$#',$val,$matches)===1) {
if ($matches[1]+0>=$graceLine) {
fwrite($fh,"{$matches[1]}\t{$matches[2]}\t{$matches[3]}\n");
$guids[$matches[3]]=['timestamp'=>$matches[1], 'hash'=>$matches[2]];
} else {
$i++;
}
} else {
fwrite(STDERR,"Warning: in state file «{$conf['state_file_absolute_path']}», line ".($key+1)." had unexpected format.\n");
}
}
fclose($fh);
vecho($opts['verbose'],'Info: got '.count($guids)." GUID(s) for already posted event(s) from state file «{$conf['state_file_absolute_path']}»; removed {$i} line(s) older than one year.\n");
} elseif (is_null($opts['do-post']) && !$opts['test']) {
echo "State file «{$conf['state_file_absolute_path']}» doesnt exist yet, so this is probably a first run on feed «{$conf['feed_url']}»; thus, all the events {$SNAME} may find in the feed will be considered new and, as a precaution against flooding your local timeline, you have to explicitly declare whether you want it to post them all, or not, by explicitly setting option «-p» or «--do-post» to «y» («yes») or «n» («no»); mind that in both cases they will be recorded as posted in the state file, and wont be posted again on subsequent runs (you can use «-h» or «--help» to display help).\n";
exit(0);
} else {
vecho($opts['verbose'],"Info: state file «{$conf['state_file_absolute_path']}» was not found.\n");
}
//print_r($guids);die();
if (is_null($opts['do-post']) || $opts['test']) $opts['do-post']=true;
vecho($opts['verbose'],"Info: trying to fetch feed from «{$conf['feed_url']}».\n");
$feed=curl($conf['feed_url'],null,['Accept: application/xml']);
if ($feed['content']===false) dieYoung("Error: could not connect to «{$conf['feed_url']}» (error: «{$feed['error']}»).\n",1);
if ($feed['httpcode']!='200') dieYoung("Error: «{$conf['feed_url']} returned http code «{$res['httpcode']}».\n",1);
$feed=@simplexml_load_string($feed['content'],null,LIBXML_NOCDATA);
if ($feed===false) dieYoung("Error: got no valid XML from «{$conf['feed_url']}».\n",1);
//print_r($feed);
if (!isset($feed->channel->item) || !is_iterable($feed->channel->item) || !is_countable($feed->channel->item)) dieYoung("Error: feed from «{$conf['feed_url']}» had unexpected format.\n",1);
$itemsCount=$feed->channel->item->count();
if ($itemsCount==0) exitYoung("Info: feed from «{$conf['feed_url']}» was empty, bye.\n");
$tsfp="{$conf['state_file_absolute_path']}.tmp";
if (!$opts['test'] && ($fh=@fopen($tsfp,'w'))===false) dieYoung("Error: could not open «{$tsfp}» in «write» mode.\n",1);
$itemsToPost=0;
$goodPostsCount=0;
$index=0;
foreach ($feed->channel->item as $item) {
$index++;
//print_r($item);
if (!isset($item->guid) || !isset($item->title) || !isset($item->link) || !isset($item->description) || !isset($item->pubDate)) {
fwrite(STDERR,"Warning: event #{$index} has unexpected format, skipping.\n");
} else {
$guid=$item->guid->__toString();
$file=null;
$hash=$item->title.$item->pubDate;
if (isset($item->enclosure[0]['url']) && isset($item->enclosure[0]['type']) && isset($item->enclosure[0]['length'])) {
$file=['url'=>$item->enclosure[0]['url']->__toString(), 'type'=>$item->enclosure[0]['type']->__toString(), 'length'=>$item->enclosure[0]['length']->__toString()];
$hash.=$item->enclosure[0]['url'].$item->enclosure[0]['type'].$item->enclosure[0]['length'];
}
$buff=$item->description->__toString();
if ($buff=='') {
$ptext='';
} elseif (preg_match('#^\n?<h3>(.+)</h3><strong>(.+)</strong><br/><small>\((\w+)\W+(\d+)\W+(\w+)\W+(\d+:\d+)\)</small><br/>(.+)$#iuU',$buff,$matches)===1) {
//print_r($matches);
$matches[1]=hent($matches[1]);
$matches[2]=hent($matches[2]);
$ptext="{$matches[1]}\n\n".ucfirst($matches[3])." {$matches[4]} {$matches[5]} alle {$matches[6]} presso {$matches[2]}\n\n".html2text($matches[7]);
} else {
$ptext=html2text($item->description);
}
$hash.=$buff;
$plink="\n\n".$item->link->__toString();
$hash.=$item->link;
if (isset($item->category) && is_countable($item->category) && is_iterable($item->category) && $item->category->count()>0) {
$pcats=[];
foreach ($item->category as $val) {
$pcats[]=hashtag($val->__toString());
$hash.=$val;
}
$pcats="\n\n".implode(' ',$pcats);
} else {
$pcats='';
}
$hash=hash('sha256',$hash);
if (array_key_exists($guid,$guids)) {
if ($hash==$guids[$guid]['hash']) {
vecho($opts['verbose'],"Info: event «{$guid}» is not new and has not changed; skipping.\n");
$state='old';
} else {
vecho($opts['verbose'],"Info: event «{$guid}» is not new, but it has changed; processing.\n");
$state='changed';
$itemsToPost++;
}
} else {
vecho($opts['verbose'],"Info: event «{$guid}» is new; processing.\n");
$state='new';
$itemsToPost++;
}
if ($opts['test']) $state='new';
$post="{$ptext}{$plink}{$pcats}";
if (postLength($post,$tldsregex['tlds'])<=$conf['max_post_length'] && !$conf['always_link_gancio_post']) {
$plink='';
} else {
$pcats='';
}
$post="{$ptext}{$plink}{$pcats}";
while (postLength($post,$tldsregex['tlds'])>$conf['max_post_length'] && $ptext!='') {
$ptext=preg_replace('#\S+\W*$#','',$ptext);
//echo "[[[{$ptext}]]]\n";
$post="{$ptext}[…]{$plink}{$pcats}";
}
//echo "--- #{$index}: {$guid} ---\n{$post}\n--- (length: ".postLength($post,$tldsregex['tlds']).") ---\n\n";
if (postLength($post,$tldsregex['tlds'])>$conf['max_post_length']) {
fwrite(STDERR,"Warning: could not shorten post for event «{$guid}» to make it fit into {$conf['max_post_length']} characters; wont post.\n");
} elseif ($state=='new' || $state=='changed') {
if ($opts['do-post']) {
$doPost=false;
$postData=[];
//print_r($file);
if (is_null($file)) {
vecho($opts['verbose'],"Info: {$state} event «{$guid}» has no attachment.\n");
$doPost=true;
} elseif ($file['length']>$conf['max_image_size']) {
fwrite(STDERR,"Warning: the size of attachment «{$file['ulr']}» is greater than image upload max size on «{$conf['fedi_hostname']}»; wont post status for {$state} event «{$guid}».\n");
} else {
vecho($opts['verbose'],"Info: {$state} event «{$guid}» has an attachment; processing.\n");
$res=curl($file['url']);
if ($res['content']===false) {
fwrite(STDERR,"Warning: could not connect to «{$file['url']}» (error: «{$res['error']}»); wont post status for {$state} event «{$guid}».\n");
} elseif ($res['httpcode']!='200') {
fwrite(STDERR,"Warning: «{$file['url']}» returned http code «{$res['httpcode']}»; wont post status for {$state} event «{$guid}».\n");
} else {
// we don't use CURLStringFile because in php 7.3 it is not available
//$pd=['file'=>new CURLStringFile($res['content'],'file',$file['type']), 'description'=>'Flyer dellevento'];
$tfp=__DIR__.'/storage/'.basename($file['url']);
if (@file_put_contents($tfp,$res['content'])===false) {
fwrite(STDERR,"Warning: could not save «{$tfp}»; wont post status for {$state} event «{$guid}».\n");
} else {
$pd=['file'=>curl_file_create($tfp,$file['type'],'file'), 'description'=>'Flyer dellevento'];
$url="https://{$conf['fedi_hostname']}/api/v2/media";
$res=curl($url,'/api/v2/media',["Authorization: Bearer {$conf['fedi_token']}", 'Accept: application/json'],$pd);
if ($res['content']===false) {
fwrite(STDERR,"Warning: could not connect to «{$url}» (error: «{$res['error']}»); wont post status for {$state} event «{$guid}».\n");
} elseif (is_null($res['content']=@json_decode($res['content'],true))) {
fwrite(STDERR,"Warning: «{$url}» did not return valid JSON; wont post status for {$state} event «{$guid}».\n");
} elseif ($res['httpcode']!='200' && $res['httpcode']!='202') {
(isset($res['content']['error'])) ? $buff=" (error: «{$res['content']['error']}»)" : $buff='';
fwrite(STDERR,"Warning: «{$url}» returned http code «{$res['httpcode']}»{$buff}; wont post status for {$state} event «{$guid}».\n");
} elseif (!isset($res['content']['id'])) {
fwrite(STDERR,"Warning: no «id» in JSON from «{$url}»; file has not been uploaded successfully; wont post status for {$state} event «{$guid}».\n");
} else {
$id=$res['content']['id'];
if ($res['httpcode']=='202') {
$id=null;
$i=1;
while ($res['httpcode']!='200' && $i<5) {
sleep(2);
$url="https://{$conf['fedi_hostname']}/api/v1/media/{$res['id']}";
$res=curl($url,'/api/v1/media',["Authorization: Bearer {$conf['fedi_token']}", 'Accept: application/json']);
if ($res['content']!==false && $res['httpcode']=='200' && !is_null($res['content']=@json_decode($res,true)) && isset($res['content']['id'])) $id=$res['content']['id'];
$i++;
}
}
if (!is_null($id)) {
vecho($opts['verbose'],"Info: successfully posted attachment for {$state} event «{$guid}».\n");
$postData['media_ids[]']=$id;
$doPost=true;
} else {
fwrite(STDERR,"Warning: server took too long to process file, or could not; wont post status for {$state} event «{$guid}».\n");
}
}
if (@unlink($tfp)===false) fwrite(STDERR,"Warning: could not delete «{$tfp}».\n");
}
}
}
if ($doPost) {
$postData['status']=$post;
$postData['visibility']=$conf['posts_visibility'];
$postData['language']=$conf['posts_language'];
$url="https://{$conf['fedi_hostname']}/api/v1/statuses";
$headers=["Authorization: Bearer {$conf['fedi_token']}", 'Accept: application/json', 'Idempotency-Key: '.md5(implode('-',$postData).time())];
$res=curl($url,'/api/v1/statuses',$headers,$postData);
if ($res['content']===false) {
fwrite(STDERR,"Warning: could not connect to «{$url}» (error: «{$res['error']}»); could not post status for {$state} event «{$guid}».\n");
} elseif (is_null($res['content']=@json_decode($res['content'],true))) {
fwrite(STDERR,"Warning: «{$url}» did not return good JSON; could not post status for {$state} event «{$guid}».\n");
} elseif ($res['httpcode']!='200') {
(isset($res['content']['error'])) ? $buff=" (error: «{$res['content']['error']}»)" : $buff='';
fwrite(STDERR,"Warning: «{$url}» returned http code «{$res['httpcode']}»{$buff}; could not post status for {$state} event «{$guid}».\n");
} elseif (!isset($res['content']['url'])) {
fwrite(STDERR,"Warning: JSON from «{$url}» had unexpected format; could not post status for {$state} event «{$guid}».\n");
} else {
vecho($opts['verbose'],"Info: successfully posted status for {$state} event «{$guid}» (URL: «{$res['content']['url']}»).\n");
//print_r($res['content']);
$guids[$guid]=['timestamp'=>time(), 'hash'=>$hash];
$goodPostsCount++;
}
}
} else {
vecho($opts['verbose'],"Info: would have tried to post status for {$state} event «{$guid}».\n");
if ($state=='new' || $state=='changed') $guids[$guid]=['timestamp'=>time(), 'hash'=>$hash];
$goodPostsCount++;
}
}
if (!$opts['test'] && array_key_exists($guid,$guids)) fwrite($fh,"{$guids[$guid]['timestamp']}\t{$guids[$guid]['hash']}\t{$guid}\n");
}
if ($opts['test']) break;// to test a single post
}
if (!$opts['test']) {
fclose($fh);
rename($tsfp,$conf['state_file_absolute_path']);
}
if (!$opts['test']) {
if ($opts['do-post'])
vecho($opts['verbose'],"Info: succesfully posted {$goodPostsCount} of {$itemsToPost} new or edited event(s) (of {$itemsCount} total events in the feed).\n");
else
vecho($opts['verbose'],"Info: would have tried to post {$itemsToPost} new or changed event(s) of {$itemsCount} total events in the feed.\n");
} elseif ($goodPostsCount==1) {
vecho($opts['verbose'],"Info: successfully posted the first of {$itemsCount} total events in the feed ({$itemsToPost} are new or changed).\n");
} else {
vecho($opts['verbose'],"Info: failed to post the first of {$itemsCount} total events in the feed ({$itemsToPost} are new or changed).\n");
}
exit(0);
function getConf(&$conf,&$confFP) {
$errors=[];
$nconf=[];
$buff=@file($confFP,FILE_IGNORE_NEW_LINES|FILE_SKIP_EMPTY_LINES);
if ($buff===false) dieYoung("Error: could not read configuration file «$confFP}».\n",1);
$i=0;
foreach ($buff as $line) {
$i++;
if ($line[0]!=='#') {
if (preg_match('#^([^=]+)=(.+)$#',$line,$matches)===1) {
$matches[1]=trim($matches[1]);
$matches[2]=trim($matches[2]);
if (array_key_exists($matches[1],$conf))
$nconf[$matches[1]]=$matches[2];
else
$errors[]="line {$i}: «{$matches[1]}» is an unknown key.\n";
} else {
$errors[]="could not interpret line {$i}{$line}»)";
}
}
}
//print_r($nconf);
foreach ($conf as $key=>$val) {
if ($conf[$key]['required'] && !array_key_exists($key,$nconf))
$errors[]="«{$key}» is not defined";
if (array_key_exists($key,$nconf))
$conf[$key]=$nconf[$key];
else
$conf[$key]=$conf[$key]['default'];
}
$errorsCount=count($errors);
if ($errorsCount>0) {
fwrite(STDERR,"Sorry, there are errors in configuration file «{$confFP}»:\n");
for ($i=1; $i<=$errorsCount; $i++)
fwrite(STDERR," {$i}. {$errors[$i-1]}\n");
fwrite(STDERR,"Use «-h» or «--help» to display help.\n");
exit(1);
}
}
function cknap($napid) {
global $naps;
$now=time();
if (isset($naps[$napid]) && $naps[$napid]>$now) {
$sleepsecs=$naps[$napid]-$now;
echo "Info: reached rate limit on «{$napid}»; sleeping until ".date('c',$naps[$napid]).' ...';
sleep($sleepsecs);
echo "\n";
$naps[$napid]=0;
}
}
function hent($str) {
return html_entity_decode($str,ENT_QUOTES,'UTF-8');
}
function vecho($do,$msg) {
if ($do) echo $msg;
}
function sighandler($sig) {
global $fh;
if (isset($fh)) fclose($fh);
echo "\nInfo: received signal {$sig}, shutting down.\n";
exit(99);
}
function dieYoung($msg,$ec) {
if (isset($fh)) fclose($fh);
fwrite(STDERR,$msg);
die($ec);
}
function exitYoung($msg) {
if (isset($fh)) fclose($fh);
echo $msg;
exit(0);
}
?>