diff --git a/Jakefile.coffee b/Jakefile.coffee index 8759bac..78e0cf2 100644 --- a/Jakefile.coffee +++ b/Jakefile.coffee @@ -20,7 +20,7 @@ task 'default', -> jake.exec ['jake -T'], EXEC_PRINT_OPTS # Used internally to compile JavaScript executable. # Invoked with compile command string. -task 'build', async: true, ['validate'], (compile_cmd)-> +task '_build', async: true, ['validate'], (compile_cmd)-> jake.exec [compile_cmd], -> addShebang JS_EXE complete() @@ -28,11 +28,11 @@ task 'build', async: true, ['validate'], (compile_cmd)-> desc 'Build JavaScript executable from CoffeeScript source.' task 'build.coffee', -> - jake.Task['build'].invoke "coffee -c '#{CS_SRC}'" + jake.Task['_build'].invoke "coffee -c '#{CS_SRC}'" desc 'Build JavaScript executable from TypeScript source.' task 'build.ts', -> - jake.Task['build'].invoke "tsc '#{TS_SRC}'" + jake.Task['_build'].invoke "tsc '#{TS_SRC}'" desc 'Validate package.json.' task 'validate', -> @@ -52,7 +52,7 @@ task 'publish', async: true, [PUBLISHED_BUILD], -> complete() , breakOnError: false, printStdout: true, printStderr: true -desc 'Commit with message in file COMMIT.' +desc 'Commit to git with message in file COMMIT' task 'commit', [PUBLISHED_BUILD], -> jake.exec ['git commit -a -F COMMIT'], EXEC_PRINT_OPTS diff --git a/README.asciidoc b/README.asciidoc index bd3e341..ee1f7f7 100644 --- a/README.asciidoc +++ b/README.asciidoc @@ -61,8 +61,12 @@ home directory. For example: === Implementation The original version this program was https://srackham.wordpress.com/2010/03/23/command-line-sms-script/[written -in Python] but this version is written in both CoffeeScript -(`clisms.coffee`) and TypeScript (`clisms.ts`) as a learning exercise. +in Python] but this version is written in CoffeeScript +(`clisms.coffee`), TypeScript (`clisms.ts`) and Dart (`clisms.dart`) as a learning exercise. + The compiled node executable `clisms.js` file can be generated from either version (use the `jake build-coffee` command to compile `clisms.coffee`, `jake build-ts` command to compile `clisms.ts`. + +The Dart version should be run using the command-line Dart VM (`dart +clisms.dart`), I got errors compiling it with `dart2js`. diff --git a/clisms.dart b/clisms.dart new file mode 100644 index 0000000..2353e18 --- /dev/null +++ b/clisms.dart @@ -0,0 +1,231 @@ +import 'dart:io'; +import 'dart:json'; +import 'dart:uri'; + +MAN_PAGE() => ''' + +NAME + ${PROG} - Send SMS message + +SYNOPSIS + ${PROG} PHONE MESSAGE + ${PROG} -s MSGID + ${PROG} -b | -l | -p + +DESCRIPTION + A simple command-line script to send SMS messages using + Clickatell's HTTP API (see http://clickatell.com). + Records messages log in ${LOG_FILE}. + Reads configuration parameters from ${CONF_FILE} + Version ${VERSION} executed from Dart source. + +OPTIONS + -s MSGID + Query message delivery status. + + -b + Query account balance. + + -l + List message log file using ${PAGER}. + + -p + List phone book. + +AUTHOR + Written by Stuart Rackham, + +COPYING + Copyright (C) 2012 Stuart Rackham. Free use of this software is + granted under the terms of the MIT License. +'''; + +const VERSION = '0.4.1'; + +// Clickatell account configuration parameters. +// The configuration file is single JSON formatted object with the +// same attributes and attribute types as the default CONF variable below. +// Alternatively you could dispense with the configuration file and edit the +// values in the CONF variable below. +final CONF = { + 'USERNAME': '', + 'PASSWORD': '', + 'API_ID': '', + 'SENDER_ID': '', // Your registered mobile phone number. + 'PHONE_BOOK': {} +}; + + +int ARGC; +List ARGV; +String PROG; +String HOME; +String PAGER; +Path LOG_FILE; +Path CONF_FILE; + +void main() { + Options OPTS = new Options(); + ARGV = OPTS.arguments; + ARGC = ARGV.length; + PROG = new Path(OPTS.script).filename; + HOME = Platform.environment['HOME']; + HOME = HOME == null ? Platform.environment['HOMEPATH'] : HOME; + PAGER = Platform.environment['PAGER']; + if (PAGER == null) PAGER = 'less'; + LOG_FILE = new Path(HOME).join(new Path('clisms.log')); + CONF_FILE = new Path(HOME).join(new Path('.clisms.json')); + var f = new File.fromPath(CONF_FILE); + if (f.existsSync()) { + CONF = JSON.parse(f.readAsTextSync()); + } + + if (ARGC == 2) { + if (ARGV[0] == '-s') + printMessageStatus(ARGV[1]); + else + sendMessage(ARGV[0], ARGV[1]); + } + else if (ARGC == 1) { + switch (ARGV[0]) { + case '-b': + printAccountBalance(); + break; + case '-l': // View log file in pager. + shell(PAGER, [LOG_FILE.toString()], (exitCode) => exit(exitCode)); + break; + case '-p': + CONF['PHONE_BOOK'].forEach( + (name, number) => print('${name}: ${number}') + ); + break; + default: + die('Illegal option: ${ARGV[0]}'); + } + } + else + print(MAN_PAGE()); +} + +// URL query string parameters. +final QUERY = { + 'user': CONF['USERNAME'], + 'password': CONF['PASSWORD'], + 'api_id': CONF['API_ID'], + 'concat': 3, + 'req_feat': 32 // Gateway must support numeric sender ID. +}; + +// Clickatell status messages. +final MSG_STATUS = { + '001': 'message unknown', + '002': 'message queued', + '003': 'delivered to gateway', + '004': 'received by recipient', + '005': 'error with message', + '007': 'error delivering message', + '008': 'OK', + '009': 'routing error', + '012': 'out of credit' +}; + +// Execute shell command. 'cmd' is executable path; 'args' is arguments array; +// 'onExit' is executed and passed the process exit code when the command +// exits. +void shell(String cmd, List args, void onExit(int exitCode)) { + var p = Process.start(cmd, args); + p.stdout.pipe(stdout); + stdin.pipe(p.stdin); + p.onExit = (exitCode) { + p.close(); + onExit(exitCode); + }; +} + +/** +Execute Clickatell HTTP command. +The `processResponse` function is called with the HTTP response data string. +*/ +void httpCmd(String cmd, void processResponse(String data)) { + // Encode URI query parameters. + var params = []; + QUERY.forEach((k, v) { + params.add('${encodeUriComponent(k.toString())}=' + '${encodeUriComponent(v.toString())}'); + }); + var query = Strings.join(params, '&'); + var httpClient = new HttpClient(); + HttpClientConnection conn = httpClient.get( + 'api.clickatell.com', 80, '/http/${cmd}?${query}'); + conn.onResponse = (HttpClientResponse response) { + StringInputStream stream = new StringInputStream(response.inputStream); + StringBuffer data = new StringBuffer(); + stream.onData = () => data.add(stream.read()); + stream.onClosed = () { + processResponse(data.toString()); + }; + }; +} + +/// Print the status of a previously sent message. +void printMessageStatus(String messageId) { + QUERY['apimsgid'] = messageId; + httpCmd('getmsgcharge', (data) { + //TODO String.slice(startIndex, [endIndex])) method and Map.get(k, ifAbsent) + // s = MSG_STATUS.get(data.slice(-3), ''); + // ifAbsent is var not function (belts and braces overkill). + var i = data.length - 3; + if (i < 0) i = 0; + var s = MSG_STATUS[data.substring(i)]; + if (s == null) s = ''; + print('$data ($s)'); + }); +} + +// Strip number punctuation and check the number is not obviously illegal. +String sanitizePhoneNumber(String number) { + var result = number.replaceAll(new RegExp(r'[+ ()-]'), ''); + if (! new RegExp(r'^\d+$').hasMatch(result)) { + die('illegal phone number: ${number}'); + } + return result; +} + +// Send text message. The recipient phone number can be a phone number +// or the name of a phone book entry. +void sendMessage(String to, String text) { + var name; + if (CONF['PHONE_BOOK'][to] != null) { + name = to; + to = CONF['PHONE_BOOK'][to]; + } + to = sanitizePhoneNumber(to); + var sender_id = sanitizePhoneNumber(CONF['SENDER_ID']); + QUERY['from'] = sender_id; + QUERY['to'] = to; + QUERY['text'] = text; + httpCmd('sendmsg', (result) { + var now = new Date.now(); + if (name != null) to = '$to: $name'; + OutputStream out = new File.fromPath(LOG_FILE) + .openOutputStream(FileMode.APPEND); + out.writeString(''' +to: ${to} +from: ${sender_id} +date: ${now.toString()} +result: ${result} +text: ${text}\n +'''); + out.close(); + print(result); + }); +} + +void printAccountBalance() { + httpCmd('getbalance', (data) => print(data)); +} + +void die(String message, [int status=1]) { + stderr.writeString(message); + exit(status); +}