comparison mod_sms_clickatell/mod_sms_clickatell.lua @ 346:2e6a74842c00

mod_sms_clickatell: initial import
author Phil Stewart <phil.stewart@lichp.co.uk>
date Thu, 24 Mar 2011 19:49:42 +0000
parents
children cd838419a85d
comparison
equal deleted inserted replaced
345:445178d15b51 346:2e6a74842c00
1 -- mod_sms_clickatell
2 --
3 -- A Prosody module for sending SMS text messages from XMPP using the
4 -- Clickatell gateway's HTTP API
5 --
6 -- Hacked from mod_twitter by Phil Stewart, March 2011. Anything from
7 -- mod_twitter copyright The Guy Who Wrote mod_twitter. Everything else
8 -- copyright 2011 Phil Stewart. Licensed under the same terms as Prosody
9 -- (MIT license, as per below)
10 --
11 --[[
12 Permission is hereby granted, free of charge, to any person obtaining a copy
13 of this software and associated documentation files (the "Software"), to deal
14 in the Software without restriction, including without limitation the rights
15 to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16 copies of the Software, and to permit persons to whom the Software is
17 furnished to do so, subject to the following conditions:
18
19 The above copyright notice and this permission notice shall be included in
20 all copies or substantial portions of the Software.
21
22 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
28 THE SOFTWARE.
29 --]]
30
31 -- Raise an error if the modules hasn't been loaded as a component in prosody's config
32 if module:get_host_type() ~= "component" then
33 error(module.name.." should be loaded as a component, check out http://prosody.im/doc/components", 0);
34 end
35
36 local jid_split = require "util.jid".split;
37 local st = require "util.stanza";
38 local componentmanager = require "core.componentmanager";
39 local datamanager = require "util.datamanager";
40 local timer = require "util.timer";
41 local config_get = require "core.configmanager".get;
42 local http = require "net.http";
43 local base64 = require "util.encodings".base64;
44 local serialize = require "util.serialization".serialize;
45 local pairs, ipairs = pairs, ipairs;
46 local setmetatable = setmetatable;
47
48 local component_host = module:get_host();
49 local component_name = module.name;
50 local data_cache = {};
51
52 local clickatell_api_id = module:get_option_string("clickatell_api_id");
53 local sms_message_prefix = module:get_option_string("sms_message_prefix") or "";
54 local sms_source_number = module:get_option_string("sms_source_number") or "";
55
56 --local users = setmetatable({}, {__mode="k"});
57
58 -- User data is held in smsuser objects
59 local smsuser = {};
60 smsuser.__index = smsuser;
61
62 -- Users table is used to store user data in the form of smsuser objects.
63 -- It is indexed by the base jid of the user, so when a non-extant entry in the
64 -- table is referenced, we pass the jid to smsuser:register to load the user
65 local users = {};
66 setmetatable(users, { __index = function (table, key)
67 return smsuser:register(key);
68 end });
69
70 -- Create a new smsuser object
71 function smsuser:new()
72 newuser = {};
73 setmetatable(newuser, self);
74 return newuser;
75 end
76
77 -- Store (save) the user object
78 function smsuser:store()
79 datamanager.store(self.jid, component_host, "data", self.data);
80 end
81
82 -- For debug
83 function smsuser:logjid()
84 module:log("logjid: ", self.jid);
85 end
86
87 -- Register a user against the base jid of the client. If a user entry for the
88 -- bjid is already stored in the Prosody data manager, retrieve its data
89 function smsuser:register(bjid)
90 reguser = smsuser:new();
91 reguser.jid = bjid;
92 reguser.data = datamanager.load(bjid, component_host, "data") or {};
93 return reguser;
94 end
95
96 -- Add a roster entry for the user
97 -- SMS users must me of the form number@component_host
98 function smsuser:roster_add(sms_number)
99 if self.data.roster == nil then
100 self.data.roster = {}
101 end
102 if self.data.roster[sms_number] == nil then
103 self.data.roster[sms_number] = {screen_name=sms_number, subscription=nil};
104 end
105 self:store();
106 end
107
108 -- Update the roster entry of sms_number with new screen name
109 function smsuser:roster_update_screen_name(sms_number, screen_name)
110 if self.data.roster[sms_number] == nil then
111 smsuser:roster_add(sms_number);
112 end
113 self.data.roster[sms_number].screen_name = screen_name;
114 self:store();
115 end
116
117 -- Update the roster entry of sms_number with new subscription detail
118 function smsuser:roster_update_subscription(sms_number, subscription)
119 if self.data.roster[sms_number] == nil then
120 smsuser:roster_add(sms_number);
121 end
122 self.data.roster[sms_number].subscription = subscription;
123 self:store();
124 end
125
126 -- Delete an entry from the roster
127 function smsuser:roster_delete(sms_number)
128 self.data.roster[sms_number] = nil;
129 self:store();
130 end
131
132 --
133 function smsuser:roster_stanza_args(sms_number)
134 if self.data.roster[sms_number] == nil then
135 return nil
136 end
137 local args = {jid=sms_number.."@"..component_host, name=self.data.roster[sms_number].screen_name}
138 if self.data.roster[sms_number].subscription ~= nil then
139 args.subscription = self.data.roster[sms_number].subscription
140 end
141 return args
142 end
143
144 --[[ From mod_twitter, keeping 'cos I might use it later :-)
145 function send_stanza(stanza)
146 if stanza ~= nil then
147 core_route_stanza(prosody.hosts[component_host], stanza)
148 end
149 end
150
151 function dmsg(jid, msg)
152 module:log("debug", msg or "nil");
153 if jid ~= nil then
154 send_stanza(st.message({to=jid, from=component_host, type='chat'}):tag("body"):text(msg or "nil"):up());
155 end
156 end
157
158 function substring(string, start_string, ending_string)
159 local s_value_start, s_value_finish = nil, nil;
160 if start_string ~= nil then
161 _, s_value_start = string:find(start_string);
162 if s_value_start == nil then
163 -- error
164 return nil;
165 end
166 else
167 return nil;
168 end
169 if ending_string ~= nil then
170 _, s_value_finish = string:find(ending_string, s_value_start+1);
171 if s_value_finish == nil then
172 -- error
173 return nil;
174 end
175 else
176 s_value_finish = string:len()+1;
177 end
178 return string:sub(s_value_start+1, s_value_finish-1);
179 end
180 --]]
181
182 local http_timeout = 30;
183 local http_queue = setmetatable({}, { __mode = "k" }); -- auto-cleaning nil elements
184 data_cache['prosody_os'] = prosody.platform;
185 data_cache['prosody_version'] = prosody.version;
186 local http_headers = {
187 ["user-Agent"] = "Prosody ("..data_cache['prosody_version'].."; "..data_cache['prosody_os']..")" --"ELinks (0.4pre5; Linux 2.4.27 i686; 80x25)",
188 };
189
190 function http_action_callback(response, code, request, xcallback)
191 if http_queue == nil or http_queue[request] == nil then return; end
192 local id = http_queue[request];
193 http_queue[request] = nil;
194 if xcallback == nil then
195 dmsg(nil, "http_action_callback reports that xcallback is nil");
196 else
197 xcallback(id, response, request);
198 end
199 return true;
200 end
201
202 function http_add_action(tid, url, method, post, fcallback)
203 local request = http.request(url, { headers = http_headers or {}, body = "", method = method or "GET" }, function(response, code, request) http_action_callback(response, code, request, fcallback) end);
204 http_queue[request] = tid;
205 timer.add_task(http_timeout, function() http.destroy_request(request); end);
206 return true;
207 end
208
209 -- Clickatell SMS HTTP API interaction function
210 function clickatell_send_sms(user, number, message)
211 module.log("info", "Clickatell API interaction function triggered");
212 -- Don't attempt to send an SMS with a null or empty message
213 if message == nil or message == "" then
214 return false;
215 end
216
217 local sms_message = sms_message_prefix..message;
218 local clickatell_base_url = "https://api.clickatell.com/http/sendmsg";
219 local params = {user=user.data.username, password=user.data.password, api_id=clickatell_api_id, from=sms_source_number, to=number, text=sms_message};
220 local query_string = "";
221
222 for param, data in pairs(params) do
223 --module:log("info", "Inside query constructor: "..param..data);
224 if query_string ~= "" then
225 query_string = query_string.."&";
226 end
227 query_string = query_string..param.."="..http.urlencode(data);
228 end
229 local url = clickatell_base_url.."?"..query_string;
230 module:log("info", "Clickatell SMS URL: "..url);
231 http_add_action(message, url, "GET", params, nil);
232 return true;
233 end
234
235 function iq_success(origin, stanza)
236 local reply = data_cache.success;
237 if reply == nil then
238 reply = st.iq({type='result', from=stanza.attr.to or component_host});
239 data_cache.success = reply;
240 end
241 reply.attr.id = stanza.attr.id;
242 reply.attr.to = stanza.attr.from;
243 origin.send(reply);
244 return true;
245 end
246
247 -- XMPP Service Discovery (disco) info callback
248 -- When a disco info query comes in, returns the identity and feature
249 -- information as per XEP-0030
250 function iq_disco_info(stanza)
251 module:log("info", "Disco info triggered");
252 local from = {};
253 from.node, from.host, from.resource = jid_split(stanza.attr.from);
254 local bjid = from.node.."@"..from.host;
255 local reply = data_cache.disco_info;
256 if reply == nil then
257 --reply = st.iq({type='result', from=stanza.attr.to or component_host}):query("http://jabber.org/protocol/disco#info");
258 reply = st.reply(stanza):query("http://jabber.org/protocol/disco#info");
259 reply:tag("identity", {category='gateway', type='sms', name=component_name}):up();
260 reply:tag("feature", {var="urn:xmpp:receipts"}):up();
261 reply:tag("feature", {var="jabber:iq:register"}):up();
262 reply:tag("feature", {var="http://jabber.org/protocol/rosterx"}):up();
263 --reply = reply:tag("feature", {var="http://jabber.org/protocol/commands"}):up();
264 --reply = reply:tag("feature", {var="jabber:iq:time"}):up();
265 --reply = reply:tag("feature", {var="jabber:iq:version"}):up();
266 data_cache.disco_info = reply;
267 end
268 reply.attr.id = stanza.attr.id;
269 reply.attr.to = stanza.attr.from;
270 return reply;
271 end
272
273 -- XMPP Service Discovery (disco) items callback
274 -- When a disco info query comes in, returns the items
275 -- information as per XEP-0030
276 -- (Nothing much happening here at the moment)
277 --[[
278 function iq_disco_items(stanza)
279 module:log("info", "Disco items triggered");
280 local reply = data_cache.disco_items;
281 if reply == nil then
282 reply = st.iq({type='result', from=stanza.attr.to or component_host}):query("http://jabber.org/protocol/disco#items")
283 :tag("item", {jid='testuser'..'@'..component_host, name='SMS Test Target'}):up();
284 data_cache.disco_items = reply;
285 end
286 reply.attr.id = stanza.attr.id;
287 reply.attr.to = stanza.attr.from;
288 return reply;
289 end
290 --]]
291
292 -- XMPP Register callback
293 -- The client must register with the gateway. In this case, the gateway is
294 -- Clickatell's http api, so we
295 function iq_register(origin, stanza)
296 module:log("info", "Register event triggered");
297 if stanza.attr.type == "get" then
298 local reply = data_cache.registration_form;
299 if reply == nil then
300 reply = st.iq({type='result', from=stanza.attr.to or component_host})
301 :tag("query", { xmlns="jabber:iq:register" })
302 :tag("instructions"):text("Enter the Clickatell username and password to use with API ID "..clickatell_api_id):up()
303 :tag("username"):up()
304 :tag("password"):up();
305 data_cache.registration_form = reply;
306 end
307 reply.attr.id = stanza.attr.id;
308 reply.attr.to = stanza.attr.from;
309 origin.send(reply);
310 elseif stanza.attr.type == "set" then
311 local from = {};
312 from.node, from.host, from.resource = jid_split(stanza.attr.from);
313 local bjid = from.node.."@"..from.host;
314 local username, password = "", "";
315 local reply;
316 for _, tag in ipairs(stanza.tags[1].tags) do
317 if tag.name == "remove" then
318 iq_success(origin, stanza);
319 return true;
320 end
321 if tag.name == "username" then
322 username = tag[1];
323 end
324 if tag.name == "password" then
325 password = tag[1];
326 end
327 end
328 if username ~= nil and password ~= nil then
329 users[bjid] = smsuser:register(bjid);
330 users[bjid].data.username = username;
331 users[bjid].data.password = password;
332 users[bjid]:store();
333 end
334 iq_success(origin, stanza);
335 return true;
336 end
337 end
338
339 -- XMPP Roster callback
340 -- When the client requests the roster associated with the gateway, returns
341 -- the users accessible via text to the client's roster
342 function iq_roster(stanza)
343 module:log("info", "Roster request triggered");
344 local from = {}
345 from.node, from.host, from.resource = jid_split(stanza.attr.from);
346 local from_bjid = nil;
347 if from.node ~= nil and from.host ~= nil then
348 from_bjid = from.node.."@"..from.host;
349 elseif from.host ~= nil then
350 from_bjid = from.host;
351 end
352 local reply = st.iq({type='result', from=stanza.attr.to or component_host}):query("")
353 if users[from_bjid].data.roster ~= nil then
354 for sms_number, sms_data in pairs(users[from_bjid].data.roster) do
355 reply:tag("item", users[from_bjid]:roster_stanza_args(sms_number)):up();
356 end
357 end
358 reply.attr.id = stanza.attr.id;
359 reply.attr.to = stanza.attr.from;
360 return reply;
361 end
362
363 -- Roster Exchange: iq variant
364 -- Sends sms targets to client's roster
365 function iq_roster_push(origin, stanza)
366 module:log("info", "Sending Roster iq");
367 local from = {}
368 from.node, from.host, from.resource = jid_split(stanza.attr.from);
369 local from_bjid = nil;
370 if from.node ~= nil and from.host ~= nil then
371 from_bjid = from.node.."@"..from.host;
372 elseif from.host ~= nil then
373 from_bjid = from.host;
374 end
375 reply = st.iq({to=stanza.attr.from, type='set'});
376 reply:tag("query", {xmlns="jabber:iq:roster"});
377 if users[from_bjid].data.roster ~= nil then
378 for sms_number, sms_data in pairs(users[from_bjid].data.roster) do
379 reply:tag("item", users[from_bjid]:roster_stanza_args(sms_number)):up();
380 end
381 end
382 origin.send(reply);
383 end
384
385 -- XMPP Presence handling
386 function presence_stanza_handler(origin, stanza)
387 module:log("info", "Presence handler triggered");
388 local to = {};
389 local from = {};
390 local pres = {};
391 to.node, to.host, to.resource = jid_split(stanza.attr.to);
392 from.node, from.host, from.resource = jid_split(stanza.attr.from);
393 pres.type = stanza.attr.type;
394 for _, tag in ipairs(stanza.tags) do pres[tag.name] = tag[1]; end
395 local from_bjid = nil;
396 if from.node ~= nil and from.host ~= nil then
397 from_bjid = from.node.."@"..from.host;
398 elseif from.host ~= nil then
399 from_bjid = from.host;
400 end
401 local to_bjid = nil
402 if to.node ~= nil and to.host ~= nil then
403 to_bjid = to.node.."@"..to.host
404 end
405
406 if to.node == nil then
407 -- Component presence
408 -- If the client is subscribing, send a 'subscribed' presence
409 if pres.type == 'subscribe' then
410 origin.send(st.presence({to=from_bjid, from=component_host, type='subscribed'}));
411 --origin.send(st.presence{to=from_bjid, type='subscribed'});
412 end
413
414 -- The component itself is online, so send component's presence
415 origin.send(st.presence({to=from_bjid, from=component_host}));
416
417 -- Do roster item exchange: send roster items to client
418 iq_roster_push(origin, stanza);
419 else
420 -- SMS user presence
421 if pres.type == 'subscribe' then
422 users[from_bjid]:roster_add(to.node);
423 origin.send(st.presence({to=from_bjid, from=to_bjid, type='subscribed'}));
424 end
425 if pres.type == 'unsubscribe' then
426 users[from_bjid]:roster_update_subscription(to.node, 'none');
427 iq_roster_push(origin, stanza);
428 origin.send(st.presence({to=from_bjid, from=to_bjid, type='unsubscribed'}));
429 users[from_bjid]:roster_delete(to.node)
430 end
431 if users[from_bjid].data.roster[to.node] ~= nil then
432 origin.send(st.presence({to=from_bjid, from=to_bjid}));
433 end
434 end
435
436
437 return true;
438 end
439
440 --[[ Not using this ATM
441 function confirm_message_delivery(event)
442 local reply = st.message({id=event.stanza.attr.id, to=event.stanza.attr.from, from=event.stanza.attr.to or component_host}):tag("received", {xmlns = "urn:xmpp:receipts"});
443 origin.send(reply);
444 return true;
445 end
446 --]]
447
448 -- XMPP Message handler - this is the bit that Actually Does Things (TM)
449 -- bjid = base JID i.e. without resource identifier
450 function message_stanza_handler(origin, stanza)
451 module:log("info", "Message handler triggered");
452 local to = {};
453 local from = {};
454 local msg = {};
455 to.node, to.host, to.resource = jid_split(stanza.attr.to);
456 from.node, from.host, from.resource = jid_split(stanza.attr.from);
457 local bjid = nil;
458 if from.node ~= nil and from.host ~= nil then
459 from_bjid = from.node.."@"..from.host;
460 elseif from.host ~= nil then
461 from_bjid = from.host;
462 end
463 local to_bjid = nil;
464 if to.node ~= nil and to.host ~= nil then
465 to_bjid = to.node.."@"..to.host;
466 elseif to.host ~= nil then
467 to_bjid = to.host;
468 end
469
470 -- This bit looks like it confirms message receipts to the client
471 for _, tag in ipairs(stanza.tags) do
472 msg[tag.name] = tag[1];
473 if tag.attr.xmlns == "urn:xmpp:receipts" then
474 confirm_message_delivery({origin=origin, stanza=stanza});
475 end
476 -- can handle more xmlns
477 end
478
479 -- Now parse the message
480 if stanza.attr.to == component_host then
481 -- Messages directly to the component jget echoed
482 origin.send(st.message({to=stanza.attr.from, from=component_host, type='chat'}):tag("body"):text(msg.body):up());
483 elseif users[from_bjid].data.roster[to.node] ~= nil then
484 -- If message contains a body, send message to SMS Test User
485 if msg.body ~= nil then
486 clickatell_send_sms(users[from_bjid], to.node, msg.body);
487 end
488 end
489 return true;
490 end
491 --]]
492
493 -- Component event handler
494 function sms_event_handler(origin, stanza)
495 module:log("debug", "Received stanza: "..stanza:pretty_print());
496 local to_node, to_host, to_resource = jid_split(stanza.attr.to);
497
498 -- Handle component internals (stanzas directed to component host, mainly iq stanzas)
499 if to_node == nil then
500 local type = stanza.attr.type;
501 if type == "error" or type == "result" then return; end
502 if stanza.name == "presence" then
503 presence_stanza_handler(origin, stanza);
504 end
505 if stanza.name == "iq" and type == "get" then
506 local xmlns = stanza.tags[1].attr.xmlns
507 if xmlns == "http://jabber.org/protocol/disco#info" then
508 origin.send(iq_disco_info(stanza));
509 return true;
510 --[[
511 elseif xmlns == "http://jabber.org/protocol/disco#items" then
512 origin.send(iq_disco_items(stanza));
513 return true;
514 --]]
515 elseif xmlns == "jabber:iq:register" then
516 iq_register(origin, stanza);
517 return true;
518 end
519 elseif stanza.name == "iq" and type == "set" then
520 local xmlns = stanza.tags[1].attr.xmlns
521 if xmlns == "jabber:iq:roster" then
522 origin.send(iq_roster(stanza));
523 elseif xmlns == "jabber:iq:register" then
524 iq_register(origin, stanza);
525 return true;
526 end
527 end
528 end
529
530 -- Handle presence (both component and SMS users)
531 if stanza.name == "presence" then
532 presence_stanza_handler(origin, stanza);
533 end
534
535 -- Handle messages (both component and SMS users)
536 if stanza.name == "message" then
537 message_stanza_handler(origin, stanza);
538 end
539 end
540
541 -- Prosody hooks: links our handler functions with the relevant events
542 --module:hook("presence/host", presence_stanza_handler);
543 --module:hook("message/host", message_stanza_handler);
544
545 --module:hook("iq/host/jabber:iq:register:query", iq_register);
546 module:add_feature("http://jabber.org/protocol/disco#info");
547 module:add_feature("http://jabber.org/protocol/disco#items");
548 --module:hook("iq/self/http://jabber.org/protocol/disco#info:query", iq_disco_info);
549 --module:hook("iq/host/http://jabber.org/protocol/disco#items:query", iq_disco_items);
550 --module:hook("account-disco-info", iq_disco_info);
551 --module:hook("account-disco-items", iq_disco_items);
552 --[[
553 module:hook("iq/host", function(data)
554 -- IQ to a local host recieved
555 local origin, stanza = data.origin, data.stanza;
556 if stanza.attr.type == "get" or stanza.attr.type == "set" then
557 return module:fire_event("iq/host/"..stanza.tags[1].attr.xmlns..":"..stanza.tags[1].name, data);
558 else
559 module:fire_event("iq/host/"..stanza.attr.id, data);
560 return true;
561 end
562 end);
563 --]]
564
565 -- Component registration hooks: these hook in with the Prosody component
566 -- manager
567 module.unload = function()
568 componentmanager.deregister_component(component_host);
569 end
570 component = componentmanager.register_component(component_host, sms_event_handler);