setup.pl 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315
  1. #!/usr/bin/perl
  2. # setup.pl - guided setup for pisg. Run it with: perl setup.pl
  3. #
  4. # It looks for IRC logs that already exist on this computer (eggdrop, ZNC, irssi, WeeChat, HexChat,
  5. # mIRC / AdiIRC), asks a few questions, writes pisg.cfg, makes the first stats page, and helps you
  6. # run it on a schedule and put it on the web for free. Nothing is changed without asking, and an
  7. # existing pisg.cfg is never overwritten (it is backed up first, if you choose to replace it).
  8. #
  9. # perl setup.pl --dry-run show what would be written, change nothing
  10. # perl setup.pl --help
  11. use strict;
  12. use warnings;
  13. use File::Basename qw(dirname basename);
  14. use File::Spec;
  15. use File::Path qw(make_path);
  16. use Cwd qw(abs_path);
  17. use POSIX qw(strftime);
  18. my $WIN = $^O eq 'MSWin32';
  19. my $HOME = $ENV{HOME} || $ENV{USERPROFILE} || '.';
  20. my $APPDATA = $ENV{APPDATA} || '';
  21. my $HERE = dirname(abs_path($0));
  22. my $DRY = grep { $_ eq '--dry-run' } @ARGV;
  23. if (grep { $_ eq '--help' || $_ eq '-h' } @ARGV) { print "Usage: perl setup.pl [--dry-run]\n"; exit 0; }
  24. $| = 1;
  25. sub say_ { print @_, "\n" }
  26. sub rule { say_ "\n" . ('-' x 72) }
  27. sub ask {
  28. my ($q, $def) = @_;
  29. print $q . (defined $def && length $def ? " [$def]" : '') . ' ';
  30. my $a = <STDIN>;
  31. exit 0 unless defined $a; # end of input: stop quietly
  32. $a =~ s/^\s+|\s+$//g;
  33. return length $a ? $a : (defined $def ? $def : '');
  34. }
  35. sub yes {
  36. my ($q, $def) = @_;
  37. my $a = lc ask("$q (y/n)", $def ? 'y' : 'n');
  38. return $a =~ /^y/;
  39. }
  40. sub slash { my $p = shift; $p =~ s{\\}{/}g; return $p } # pisg.cfg accepts / everywhere, Windows too
  41. sub clean { my $v = shift; $v =~ s/["\r\n]//g; return $v } # a value goes between quotes in pisg.cfg
  42. sub count_files { my ($dir, $prefix) = @_; my @f = grep { -f } glob(quotemeta_glob("$dir/$prefix") . '*'); return scalar @f }
  43. sub quotemeta_glob { my $s = shift; $s =~ s/([\[\]{}*?\\ ])/\\$1/g; return $s }
  44. # ---- what formats does this pisg know? --------------------------------------------------------
  45. my @FORMATS = sort map { basename($_, '.pm') } glob("$HERE/modules/Pisg/Parser/Format/*.pm");
  46. @FORMATS = grep { $_ ne 'Template' } @FORMATS;
  47. # ---- looking for logs ---------------------------------------------------------------------------
  48. # Each candidate: { app, channel, network, format, dir | file, prefix, note }
  49. my @found;
  50. sub add { push @found, { @_ } }
  51. sub detect_eggdrop {
  52. my %seen;
  53. my @confs = grep { -f } (glob("$HOME/eggdrop*/eggdrop.conf"), glob("$HOME/*/eggdrop.conf"), glob("$HOME/*/*/eggdrop.conf"));
  54. for my $conf (grep { !$seen{$_}++ } @confs) {
  55. my $base = dirname($conf);
  56. open(my $fh, '<', $conf) or next;
  57. my @lines = <$fh>; close $fh;
  58. my $keep = (grep { /^\s*set\s+keep-all-logs\s+1/ } @lines) ? 1 : 0;
  59. for (@lines) {
  60. next unless /^\s*logfile\s+\S+\s+(\S+)\s+"?([^"\s]+)"?/;
  61. my ($chan, $path) = ($1, $2);
  62. next unless $chan =~ /^[#&]/;
  63. $path = File::Spec->catfile($base, $path) unless File::Spec->file_name_is_absolute($path);
  64. add(app => 'eggdrop', channel => $chan, network => '', format => 'eggdrop',
  65. dir => dirname($path), prefix => basename($path) . '.',
  66. note => $keep ? '' : "eggdrop keeps only the current log. For a long history add these to eggdrop.conf and .rehash:\n"
  67. . " set keep-all-logs 1\n set logfile-suffix \".%Y%m%d\"\n set switch-logfiles-at 300");
  68. }
  69. }
  70. }
  71. sub detect_znc {
  72. my %roots = map { $_ => 1 } grep { -d } ($ENV{ZNC_DATADIR} || '', "$HOME/.znc", ($APPDATA ? "$APPDATA/znc" : ()));
  73. for my $root (keys %roots) {
  74. # user scope: users/<user>/moddata/log/<network>/<#channel>/YYYY-MM-DD.log
  75. for my $d (glob("$root/users/*/moddata/log/*/*")) {
  76. next unless -d $d && basename($d) =~ /^[#&]/;
  77. add(app => 'ZNC', channel => basename($d), network => basename(dirname($d)), format => 'energymech', dir => $d, prefix => '', note => '');
  78. }
  79. # network scope: users/<user>/networks/<network>/moddata/log/<#channel>/...
  80. for my $d (glob("$root/users/*/networks/*/moddata/log/*")) {
  81. next unless -d $d && basename($d) =~ /^[#&]/;
  82. (my $net = $d) =~ s{.*/networks/([^/]+)/moddata/log/.*}{$1};
  83. add(app => 'ZNC', channel => basename($d), network => $net, format => 'energymech', dir => $d, prefix => '', note => '');
  84. }
  85. # global scope: moddata/log/<user>/<network>/<#channel>/...
  86. for my $d (glob("$root/moddata/log/*/*/*")) {
  87. next unless -d $d && basename($d) =~ /^[#&]/;
  88. add(app => 'ZNC', channel => basename($d), network => basename(dirname($d)), format => 'energymech', dir => $d, prefix => '', note => '');
  89. }
  90. }
  91. }
  92. # One log file per channel (irssi, WeeChat, HexChat, mIRC ...): the name holds the channel.
  93. sub file_candidates {
  94. my ($app, $format, $dir, $rx, $network) = @_;
  95. return unless -d $dir;
  96. opendir(my $dh, $dir) or return;
  97. for my $f (sort grep { !/^\./ && -f "$dir/$_" } readdir $dh) {
  98. my $chan = $f =~ $rx ? $1 : next;
  99. add(app => $app, channel => $chan, network => ($network // ''), format => $format, file => "$dir/$f", note => '');
  100. }
  101. closedir $dh;
  102. }
  103. sub detect_clients {
  104. # irssi: ~/irclogs/<network>/<#channel>.log (autolog default)
  105. for my $n (grep { -d } glob("$HOME/irclogs/*")) {
  106. file_candidates('irssi', 'irssi', $n, qr/^([#&][^.]*)\.log$/, basename($n));
  107. }
  108. # WeeChat: irc.<server>.<#channel>.weechatlog
  109. for my $d ("$HOME/.local/share/weechat/logs", "$HOME/.weechat/logs") {
  110. file_candidates('WeeChat', 'weechat3', $d, qr/^irc\.[^.]+\.([#&].+)\.weechatlog$/);
  111. }
  112. # HexChat / XChat: logs/<NETWORK>/<#channel>.log
  113. for my $r ("$HOME/.config/hexchat/logs", "$HOME/.xchat2/xchatlogs", ($APPDATA ? "$APPDATA/HexChat/logs" : ())) {
  114. for my $n (grep { -d } glob("$r/*")) { file_candidates('HexChat', 'xchat', $n, qr/^([#&].+)\.log$/, basename($n)) }
  115. }
  116. # mIRC / AdiIRC on Windows: <#channel>.<network>.log or <#channel>.log
  117. for my $d (($APPDATA ? ("$APPDATA/mIRC/logs", "$APPDATA/AdiIRC/Logs") : ()), "$HOME/.wine/drive_c/mIRC/logs") {
  118. file_candidates('mIRC/AdiIRC', 'mIRC', $d, qr/^([#&][^.]+)(?:\..+)?\.log$/);
  119. for my $n (grep { -d } glob("$d/*")) { file_candidates('mIRC/AdiIRC', 'mIRC', $n, qr/^([#&][^.]+)(?:\..+)?\.log$/, basename($n)) }
  120. }
  121. }
  122. # ---- go -----------------------------------------------------------------------------------------
  123. rule();
  124. say_ "pisg setup" . ($DRY ? " (dry run: nothing will be written)" : '');
  125. say_ "";
  126. say_ "pisg turns IRC chat logs into a web page of statistics: who talks most, when the channel is";
  127. say_ "busy, who talks to whom. I will look for logs on this computer, ask a few questions and write";
  128. say_ "the configuration for you. You can stop at any time with Ctrl+C; nothing is changed until the";
  129. say_ "end.";
  130. detect_eggdrop(); detect_znc(); detect_clients();
  131. my @chosen;
  132. rule();
  133. say_ "Step 1 of 4: which channels do you want statistics for?";
  134. say_ "";
  135. if (@found) {
  136. say_ "I found these logs:";
  137. my $i = 0;
  138. for my $c (@found) {
  139. $i++;
  140. my $where = $c->{dir} ? $c->{dir} : $c->{file};
  141. my $n = $c->{dir} ? count_files($c->{dir}, $c->{prefix} // '') . " files" : (-s $c->{file} ? int((-s $c->{file}) / 1024) . " KB" : 'empty');
  142. printf " %2d) %-14s %-16s %-10s %s (%s)\n", $i, $c->{app}, $c->{channel}, $c->{network}, $where, $n;
  143. }
  144. say_ "";
  145. my $pick = ask("Type the numbers you want, separated by commas (for example 1,3), or 0 to type a folder yourself:", '1');
  146. for my $n (grep { /^\d+$/ && $_ >= 1 && $_ <= @found } split /\s*,\s*/, $pick) { push @chosen, { %{ $found[$n - 1] } } }
  147. } else {
  148. say_ "I did not find any IRC logs in the usual places.";
  149. say_ "";
  150. say_ " * Using a bouncer or a bot? Turn logging on first: eggdrop needs a 'logfile' line, ZNC needs the";
  151. say_ " 'log' module (/msg *status LoadMod log), irssi needs /set autolog on. Then run this again.";
  152. say_ " * Already have logs somewhere else? Type the folder below.";
  153. }
  154. if (!@chosen) {
  155. say_ "";
  156. my $where = ask("Folder or file with your logs (leave empty to stop):", '');
  157. if (!length $where) { say_ "Nothing to do. Run this again once you have logs."; exit 0; }
  158. $where =~ s/^~(?=\/|$)/$HOME/;
  159. if (!-e $where) { say_ "That does not exist: $where"; exit 1; }
  160. say_ "";
  161. say_ "Which program wrote them? Formats pisg understands:";
  162. say_ " " . join(', ', @FORMATS);
  163. my $fmt = ask("Format:", 'eggdrop');
  164. my $chan = ask("Channel name (for example #mychannel):", '#mychannel');
  165. push @chosen, { app => 'your logs', channel => $chan, network => '', format => $fmt,
  166. (-d $where ? (dir => $where, prefix => ask("Only files whose name starts with (empty: all of them):", '')) : (file => $where)), note => '' };
  167. }
  168. rule();
  169. say_ "Step 2 of 4: a few details";
  170. say_ "";
  171. my $maintainer = clean(ask("Your name or nick, shown as the maintainer of the page:", $ENV{USER} || $ENV{USERNAME} || 'me'));
  172. my $network = clean(ask("Name of the IRC network (for example Undernet, Libera):", $chosen[0]{network} || 'IRC'));
  173. say_ "";
  174. say_ "Look of the pages: modern (light and dark, follows your system) midnight amoled terminal default";
  175. my $scheme = clean(ask("Colour scheme:", 'modern'));
  176. my $outdir = ask("Folder for the finished pages:", File::Spec->catdir($HERE, 'output'));
  177. $outdir =~ s/^~(?=\/|$)/$HOME/;
  178. my $landing = @chosen > 1 || yes("Add a front page (index.html) that links to your channel pages?", 1);
  179. my $cfg = File::Spec->catfile($HERE, 'pisg.cfg');
  180. my @out;
  181. push @out, "# Written by setup.pl on " . strftime('%Y-%m-%d %H:%M', localtime) . ". Every option is explained in pisg.cfg.example.";
  182. push @out, qq(<set maintainer="$maintainer">), qq(<set ColorScheme="$scheme">), qq(<set Charset="utf-8">);
  183. push @out, qq(<set HomeLink="index.html">) if $landing;
  184. push @out, '';
  185. my %usedfile;
  186. for my $c (@chosen) {
  187. my $slug = lc(clean($c->{channel})); $slug =~ s/^[#&]+//; $slug =~ s/[^a-z0-9._-]+/-/g; $slug ||= 'channel';
  188. $slug .= '-' . (++$usedfile{$slug}) if $usedfile{$slug}++;
  189. push @out, '<channel="' . clean($c->{channel}) . '">';
  190. if ($c->{dir}) {
  191. push @out, ' LogDir="' . clean(slash($c->{dir})) . '"';
  192. push @out, ' LogPrefix="' . clean($c->{prefix}) . '"' if length($c->{prefix} // '');
  193. } else {
  194. push @out, ' Logfile="' . clean(slash($c->{file})) . '"';
  195. }
  196. push @out, ' Format="' . clean($c->{format}) . '"';
  197. push @out, ' Network="' . ($c->{network} && $network eq 'IRC' ? clean($c->{network}) : $network) . '"';
  198. push @out, ' OutputFile="' . clean(slash(File::Spec->catfile($outdir, "$slug.html"))) . '"';
  199. push @out, '</channel>', '';
  200. }
  201. my $text = join("\n", @out) . "\n";
  202. rule();
  203. say_ "Step 3 of 4: this is the configuration I will write";
  204. say_ "";
  205. say_ " $cfg";
  206. say_ "";
  207. say_ join("\n", map { " $_" } @out);
  208. for my $c (@chosen) { say_ " Note for $c->{channel}: $c->{note}\n" if $c->{note} }
  209. if ($DRY) { say_ "Dry run: nothing written."; exit 0; }
  210. exit 0 unless yes("Write it?", 1);
  211. if (-e $cfg) {
  212. say_ "";
  213. say_ "There is already a pisg.cfg here.";
  214. if (yes("Back it up (pisg.cfg.bak-DATE) and replace it? If not, the new one is saved as pisg.cfg.new", 0)) {
  215. my $bak = "$cfg.bak-" . strftime('%Y%m%d-%H%M%S', localtime);
  216. rename($cfg, $bak) or do { say_ "Could not back it up: $!"; exit 1 };
  217. say_ " old file kept as $bak";
  218. } else {
  219. $cfg .= '.new';
  220. }
  221. }
  222. make_path($outdir) unless -d $outdir;
  223. open(my $out, '>', $cfg) or do { say_ "Could not write $cfg: $!"; exit 1 };
  224. print $out $text; close $out;
  225. say_ "Wrote $cfg";
  226. say_ " To use it instead of your current configuration: mv pisg.cfg.new pisg.cfg" if $cfg =~ /\.new$/;
  227. if ($landing) {
  228. my ($src, $dst) = ("$HERE/site/index.html", File::Spec->catfile($outdir, 'index.html'));
  229. if (-f $src && !-e $dst) { require File::Copy; File::Copy::copy($src, $dst) and say_ "Copied the front page to $dst" }
  230. }
  231. # first run
  232. say_ "";
  233. if (yes("Make the first statistics now?", 1)) {
  234. say_ "Running pisg (this can take a little while with a lot of logs) ...";
  235. my $perl = $^X;
  236. my $rc = system($perl, File::Spec->catfile($HERE, 'pisg'), ($cfg =~ /\.new$/ ? ('-co', $cfg) : ()));
  237. if ($rc == 0) {
  238. say_ "";
  239. say_ "Done. Your page is in: $outdir";
  240. say_ " " . slash(File::Spec->catfile($outdir, 'index.html')) if $landing;
  241. } else {
  242. say_ "";
  243. say_ "pisg reported a problem (above). Common causes: the wrong format for these logs, or logs with";
  244. say_ "no timestamps. Run perl setup.pl again and pick another format, or ask on GitHub: https://github.com/PISG/pisg";
  245. }
  246. }
  247. # scheduling
  248. rule();
  249. say_ "Step 4 of 4: keep the statistics up to date";
  250. say_ "";
  251. say_ "pisg makes a fresh page each time it runs. Run it on a schedule so the page stays current.";
  252. my $hours = ask("How often, in hours (1, 3, 6 or 12)?", '3');
  253. $hours = 3 unless $hours =~ /^(1|2|3|4|6|8|12|24)$/;
  254. if ($WIN) {
  255. my $cmd = qq(schtasks /Create /SC HOURLY /MO $hours /TN "pisg" /TR "cmd /c cd /d \\"$HERE\\" && \\"$^X\\" pisg" /F);
  256. say_ "";
  257. say_ " $cmd";
  258. if (yes("Create this Windows scheduled task now?", 0)) { system($cmd) }
  259. else { say_ " (run the line above in a command prompt when you want it.)" }
  260. say_ " The computer must be on for it to run.";
  261. } else {
  262. my $line = "5 */$hours * * * cd '$HERE' && '$^X' pisg >> pisg_cron.log 2>&1";
  263. say_ "";
  264. say_ " $line";
  265. if (yes("Add this line to your crontab now?", 0)) {
  266. my $old = `crontab -l 2>/dev/null` // '';
  267. if ($old =~ /pisg_cron\.log/) { say_ " There is already a pisg line in your crontab; not adding another." }
  268. elsif (open(my $c, '|-', 'crontab', '-')) {
  269. print $c $old, (length $old && $old !~ /\n\z/ ? "\n" : ''), "$line\n";
  270. close $c;
  271. say_ " Added.";
  272. } else { say_ " Could not run crontab: $!" }
  273. } else { say_ " (add it yourself with: crontab -e )" }
  274. }
  275. rule();
  276. say_ "Put the pages on the web (free)";
  277. say_ "";
  278. say_ "Your pages are plain files, so any static host works. Good free choices:";
  279. say_ "";
  280. say_ " * GitHub Pages free, simple, and it can publish straight from a git repository.";
  281. say_ " 1) create a free account at github.com and a repository named YOURNAME.github.io";
  282. say_ " 2) put the contents of your output folder in it and push";
  283. say_ " 3) your stats are then at https://YOURNAME.github.io/";
  284. say_ " * Cloudflare Pages free, fast everywhere; upload the folder by drag and drop or connect a repository.";
  285. say_ " * Netlify free plan; drag the output folder onto app.netlify.com/drop.";
  286. say_ " * GitLab Pages free, like GitHub Pages (uses a .gitlab-ci.yml file).";
  287. say_ " * Your own server point Apache or nginx at the output folder.";
  288. say_ "";
  289. say_ "Two things to know:";
  290. say_ " * The front page reads channels.json, so it needs a web server. Opening index.html straight from";
  291. say_ " your disk will not list the channels. To try it on your own computer, run this inside the output";
  292. say_ " folder, then open http://localhost:8000/ : python3 -m http.server 8000";
  293. say_ " * The pages show nicknames and a random line from the chat. Tell your channel that stats are";
  294. say_ " published, and use BadUrls, <user ... ignore=\"y\"> or a private host if that matters to you.";
  295. say_ "";
  296. say_ "More: pisg.cfg.example lists every option, docs/pisg-doc.html is the whole manual.";
  297. say_ "Change your answers any time by editing pisg.cfg, or run perl setup.pl again.";