summaryrefslogtreecommitdiff
path: root/localmarkdown2sms
blob: 401ee88bcbf3e6b07ae3fb6b4ba3582c02359295 (plain)
  1. #!/usr/bin/perl
  2. #
  3. # /usr/local/sbin/localmarkdown2sms
  4. # Copyright 2009-2010, Jonas Smedegaard <dr@jones.dk>
  5. #
  6. # Send series of messages through Kannel from simplified Markdown files
  7. # * Lines starting with "#" are "keywords" activating a message series
  8. # * write only a single word
  9. # * use each keyword only once across the whole system
  10. # * use only minuscles (not majuscles, i.e. CAPITAL LETTERS)
  11. # * Lines starting with "##" express pauses
  12. # * a pause is a number + a single letter, without spaces between
  13. # * a pause line can contain multiple pauses, separated by space
  14. # Suggestion for writing style:
  15. #
  16. # * Write explicitly how to activate next series
  17. # * pick keywords tied to nex series rather than the previous
  18. # * use same instruction jargon across all series in the system
  19. use strict;
  20. use warnings;
  21. use utf8; # this document itself is UTF-8 encoded.
  22. use Env qw[$debug $info $warn $dummy $nosleep $urldecode];
  23. use Log::Log4perl qw(:easy);
  24. use File::Spec;
  25. use File::Slurp;
  26. use Time::Duration::Parse;
  27. use Text::Unidecode;
  28. use Encode 2.12 qw(encode decode _utf8_off); # need v2.12 to support coderef
  29. use LWP::UserAgent;
  30. use URI::Escape;
  31. use Proc::Daemon;
  32. # TODO: Use Coro instead
  33. Proc::Daemon::Init unless ($debug);
  34. my $sms_url = $ENV{SMS_URL} || "http://localhost:13013/cgi-bin/sendsms";
  35. my $sms_usertag = $ENV{SMS_USERTAG} || "username";
  36. my $sms_user = $ENV{SMS_USER} || "tester";
  37. my $sms_pw = $ENV{SMS_PW} || "foobar";
  38. my $sms_api = $ENV{SMS_API};
  39. my $sms_callback = $ENV{SMS_CALLBACK};
  40. my $sms_phone = $ENV{SMS_PHONE};
  41. my $sms_errfrom = $ENV{SMS_ERRFROM} || $sms_phone;
  42. my $sms_errto1 = $ENV{SMS_ERRTO1};
  43. my $sms_errto2 = $ENV{SMS_ERRTO2};
  44. my $sms_smsc = $ENV{SMS_SMSC};
  45. my $sms_msgtag = $ENV{SMS_MSGTAG} || "text";
  46. my $sms_cp = $ENV{SMS_CP} || "utf8";
  47. my $sms_concatenation = $ENV{SMS_CONCATENATION};
  48. my $sms_concat = $ENV{SMS_CONCAT};
  49. my $sms_dlr_mask = $ENV{SMS_DLR_MASK};
  50. my $sms_dlr_url = $ENV{SMS_DLR_URL};
  51. my $sms_escalate = $ENV{SMS_ESCALATE};
  52. my $sms_validity = $ENV{SMS_VALIDITY};
  53. my $sms_req_feat = $ENV{SMS_REQ_FEAT};
  54. my $sms_binfo = $ENV{SMS_BINFO};
  55. my $stripprefix = $ENV{stripprefix};
  56. my $path = $ENV{mdpath};
  57. my (%file, %delay, %reply);
  58. # decode data if passed from kannel
  59. if ($urldecode) {
  60. @ARGV = uri_unescape(@ARGV);
  61. }
  62. my ($phone) = shift @ARGV;
  63. my $inputstring = decode('UTF-8', join(' ', @ARGV));
  64. my ($key) = lc(decode('UTF-8', shift @ARGV));
  65. # strip prefix
  66. # (prefix is optional some places and illegal at other places - forgot where)
  67. if ($stripprefix) {
  68. $phone =~ s/^\+//g;
  69. $sms_phone =~ s/^\+//g;
  70. $sms_errfrom =~ s/^\+//g;
  71. $sms_errto1 =~ s/^\+//g;
  72. $sms_errto2 =~ s/^\+//g;
  73. }
  74. # strip non-word chars from keyword
  75. $key = transliterate($key);
  76. # use only first chunk of word chars as keyword
  77. $key =~ s/^(\S+).*?$/$1/;
  78. # strip problematic chars from keyword
  79. # FIXME: strip as part of transliterate function instead
  80. # TODO: Maintain language-specific lists instead (á is specific to Hungary)
  81. $key =~ tr/à/a/;
  82. if ($debug) {
  83. Log::Log4perl->easy_init($DEBUG);
  84. } elsif ($INFO) {
  85. Log::Log4perl->easy_init($INFO);
  86. } elsif ($WARN) {
  87. Log::Log4perl->easy_init($WARN);
  88. } elsif ($ERROR) {
  89. Log::Log4perl->easy_init($ERROR);
  90. }
  91. unless ($path and -d $path) {
  92. ERROR "environment variable \"mdpath\" missing or wrong.";
  93. exit 1;
  94. }
  95. foreach my $file (read_dir( $path )) {
  96. my ($key, $i, $skipkeysection, $skipcontent);
  97. # suppress repeated warnings for same issue
  98. my ($warn_nonkey_delay, $warn_nonkey_content);
  99. next unless ($file =~ /\.mdwn$/);
  100. foreach my $line (read_file( File::Spec->catfile($path, $file))) {
  101. $line = &transliterate(decode('UTF-8', $line));
  102. chomp $line;
  103. my $content;
  104. # headline
  105. if ($line =~ /^(#+)\s*(.*?)\s*$/) {
  106. # tidy latest reply
  107. if (defined($key) and defined($reply{$key}[$i])) {
  108. $reply{$key}[$i] = &tidymsg($reply{$key}[$i]);
  109. ($reply{$key}[$i]) || delete $reply{$key}[$i];
  110. }
  111. my $level = length($1);
  112. $content = $2;
  113. # key
  114. if ($level == 1 and $content =~ /(\w+)/) {
  115. $key = lc($1);
  116. $i = 0;
  117. $skipkeysection = undef;
  118. $skipcontent = undef;
  119. if (lc($content) ne $key) {
  120. WARN "key \"$key\" extracted from fuzzy string \"$content\" in file \"$file\"";
  121. }
  122. if (!defined( $delay{$key})) {
  123. $delay{$key}[0] = 0;
  124. $warn_nonkey_delay = undef;
  125. $warn_nonkey_content = undef;
  126. } else {
  127. WARN "skipping non-unique key \"$key\" in file \"$file\"";
  128. $key = undef;
  129. $skipkeysection = 1;
  130. $skipcontent = 1;
  131. }
  132. # delay
  133. } elsif ($level == 2 and $content =~ /((\d+[sm]\s?)+)/) {
  134. $skipcontent = undef;
  135. if (defined( $key)) {
  136. my $delay = parse_duration($1);
  137. if (defined($reply{$key}[$i])) {
  138. $i++;
  139. $delay{$key}[$i] = $delay{$key}[$i - 1];
  140. }
  141. # $delay{$key}[$i] += $delay; # accumulate: forked replies
  142. $delay{$key}[$i] = $delay; # simple: queued replies
  143. if ($content ne $1) {
  144. WARN "delay (${delay}s) resolved from fuzzy string \"$content\" in file \"$file\"";
  145. }
  146. } elsif ($skipkeysection or $warn_nonkey_delay) {
  147. # skipping - already warned about it...
  148. } else {
  149. WARN "ignoring non-key'ed delay line \"$1\" in file \"$file\"";
  150. $warn_nonkey_delay = 1;
  151. $skipcontent = 1;
  152. }
  153. } else {
  154. WARN "ignoring non-parsable headline \"$line\" in file \"$file\"";
  155. $skipcontent = 1;
  156. }
  157. # reply
  158. } else {
  159. $content = $line . "\n";
  160. # ikiwiki directives - strip from content and parse for tags
  161. $content =~ s/(?<!\\)\[\[([^\[\]]*)(?<!\\)\]\]//gs and do {
  162. my $directive_string = $1;
  163. my ($directive, $directive_content);
  164. $directive_string =~ /^\s*\!(tag|taglink)\s*((\s*?\b\w+)+)/ and $file{$file}{'directive'}{'tag'} = [ split /\s+/, $2 ];
  165. };
  166. if ( defined( $key ) and not ($skipcontent)) {
  167. $reply{$key}[$i] .= $content;
  168. } elsif ($skipkeysection or $skipcontent or $warn_nonkey_content) {
  169. # skipping - already warned about it...
  170. } else {
  171. $content =~ /\S/s && WARN "skipping non-key'ed content \"$line\" in file \"$file\"";
  172. $warn_nonkey_content = 1;
  173. }
  174. }
  175. }
  176. # tidy latest reply
  177. if (defined($key) and defined($reply{$key}[$i])) {
  178. $reply{$key}[$i] = &tidymsg($reply{$key}[$i]);
  179. ($reply{$key}[$i]) || delete $reply{$key}[$i];
  180. }
  181. }
  182. sub tidymsg {
  183. my $msg = shift @_;
  184. $msg =~ s/^\h*$//g; # clean virtually empty lines
  185. $msg =~ s/(\S)\h$/$1/g; # strip single trailing space
  186. $msg =~ s/\n\n+/\n\n/g; # strip excess newlines
  187. $msg =~ s/(\S)\n([^\n])/$1 $2/g; # convert newline to space
  188. $msg =~ s/\h*$//g; # strip all trailing spaces
  189. $msg =~ s/^\s*(\w*?.*?)\s*$/$1/s; # strip surrounding space
  190. $msg =~ s/’/'/g; #'simplify non-GSM chars
  191. $msg =~ s/…/.../g; # simplify ellipsis
  192. $msg =~ s/\.\.+/.../g; # multiple dots → psudo-ellipsis
  193. return $msg;
  194. }
  195. # based on Text::Unidecode bug#8017: http://rt.cpan.org/Ticket/Display.html?id=8017#txn-322351
  196. sub transliterate {
  197. my $string = shift @_;
  198. my $res = decode('GSM0338', encode('GSM0338', $string, sub {
  199. my $ascii = unidecode(chr $_[0]);
  200. _utf8_off($ascii);
  201. $ascii;
  202. }));
  203. return $res || "";
  204. }
  205. sub sendmsg {
  206. my ($from, $to, $desc, $msg) = @_;
  207. my $ua = LWP::UserAgent->new(agent => "localmarkdown2sms");
  208. $ua->timeout(10);
  209. my $url = $sms_url
  210. . '?' . $sms_usertag . '=' . uri_escape($sms_user)
  211. . '&password=' . uri_escape($sms_pw)
  212. . '&to=' . uri_escape($to);
  213. $url .= '&api_id=' . uri_escape($sms_api) if ($sms_api);
  214. $url .= '&callback=' . uri_escape($sms_callback) if ($sms_callback);
  215. $url .= '&from=' . uri_escape($from) if ($from);
  216. $url .= '&smsc=' . uri_escape($sms_smsc) if ($sms_smsc);
  217. $url .= '&concatenation=true' if ($sms_concatenation);
  218. $url .= '&concat=' . uri_escape($sms_concat) if ($sms_concat);
  219. $url .= '&dlr-mask=' . uri_escape($sms_dlr_mask) if ($sms_dlr_mask);
  220. $url .= '&dlr-url=' . uri_escape($sms_dlr_url) if ($sms_dlr_url);
  221. $url .= '&escalate=' . uri_escape($sms_escalate) if ($sms_escalate);
  222. $url .= '&validity=' . uri_escape($sms_validity) if ($sms_validity);
  223. $url .= '&req_feat=' . uri_escape($sms_req_feat) if ($sms_req_feat);
  224. $url .= '&binfo=' . uri_escape($sms_binfo) if ($sms_binfo);
  225. $url .= '&' . $sms_msgtag . '=' . uri_escape(encode($sms_cp, $msg));
  226. unless ($dummy) {
  227. DEBUG "Sending request: $url";
  228. my $response = $ua->request(HTTP::Request->new('GET', $url));
  229. unless ($response->is_success) {
  230. ERROR $response->status_line;
  231. }
  232. DEBUG "Done $desc";
  233. } else {
  234. DEBUG "[DUMMY] Sending request: $url";
  235. print STDERR "\n [$from --> $to: $desc]\n\n";
  236. print STDERR $msg . "\n";
  237. }
  238. }
  239. my $num_children = $#{ $reply{$key} } + 1; # How many children we'll create
  240. if (0 == $num_children) {
  241. my $err_da = "Ikke genkendt som et nøgleord: Check venligst for tastefejl og prøv igen.";
  242. my $err_en = "Sorry, not recognized as a keyword: Please check spelling and try again.";
  243. my $err_hu = "Érvénytelen kulcsszó. Kérjük, ellenőrizd és próbáld újra.";
  244. my $err_it = "Spiacente, non riconosco come parola chiave: Controlla l'ortografia e riprova.";
  245. my $errmsg = "[warning] $phone requested unknown keyword \"$key\"\nFull text: $inputstring";
  246. # FIXME: Make use of local errormsg optional and configurable.
  247. &sendmsg($sms_phone, $phone, "fallback message", "$key?\n$err_hu\n$err_en");
  248. ($sms_errto1) and &sendmsg($sms_errfrom, $sms_errto1, "warning", $errmsg);
  249. ($sms_errto2) and &sendmsg($sms_errfrom, $sms_errto2, "warning", $errmsg);
  250. exit;
  251. }
  252. if ($debug) {
  253. DEBUG "queueing $num_children replies:";
  254. for my $num ( 0 .. $num_children - 1 ) {
  255. DEBUG " [" . $delay{$key}[$num] . "s]";
  256. }
  257. # DEBUG "\n";
  258. }
  259. for my $num ( 0 .. $num_children - 1 ) {
  260. sleep($delay{$key}[$num]) unless ($nosleep);
  261. &sendmsg($sms_phone, $phone, "reply #$num [" . $delay{$key}[$num] . "s]", $reply{$key}[$num]);
  262. }
  263. 1;