adammck-rubysms 0.8.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,50 @@
1
+ RubySMS is a Ruby library (not a framework, although it's coming dangerously close)
2
+ which aims to make developing and deploying SMS applications easy. The sending and
3
+ receiving of "real" SMS is handled by RubyGSM[http://github.com/adammck/rubygsm],
4
+ but RubySMS also provides a couple of ways to interact with "mock" SMS via a web
5
+ interface (the HTTP Backend) and GTK GUI (the DRB backend).
6
+
7
+ RubySMS was initially developed in Malawi by {The UNICEF Innovation Team}[http://unicefinnovation.org/about.php]
8
+ and a group from Columbia University's {SIPA}[http://sipa.columbia.edu/] as the technical foundation of the
9
+ {RapidSMS Child Malnutrition Survelliance}[http://netsquared.org/projects/child-malnutrition-surveillance-and-famine-response]
10
+ (see {the source code}[http://github.com/adammck/columbawawi]) entry to the {USAID Development 2.0 Challenge}[http://netsquared.org/usaid]...
11
+ which[http://globaldevelopmentcommons.net/node/876]
12
+ we[http://unicef.org/infobycountry/usa_47068.html]
13
+ won[http://sipa.columbia.edu/news_events/announcements/sipanews10.html].
14
+
15
+
16
+ === Sample Application
17
+
18
+ require "rubygems"
19
+ require "rubysms"
20
+
21
+ class DemoApp < SMS::App
22
+ def incoming(msg)
23
+ msg.respond("Wow, that was easy!")
24
+ end
25
+ end
26
+
27
+ DemoApp.serve!
28
+
29
+
30
+ === Installing
31
+ RubySMS is distributed via GitHub[http://github.com/adammck/rubysms], which you must
32
+ add as a Gem source before installing:
33
+
34
+ $ sudo gem sources -a http://gems.github.com
35
+
36
+ Then install the Gem, which will automatically pull in the dependencies:
37
+
38
+ $ sudo gem install adammck-rubysms
39
+
40
+
41
+ === Dependencies
42
+ If you'd prefer to run RubySMS from the trunk, you'll need to install the following
43
+ Gems manually (otherwise, the HTTP backend will explode on startup. Remind me to fix
44
+ that some time).
45
+
46
+ $ sudo gem install rack mongrel
47
+
48
+ If you'd like to send real SMS via a GSM modem, you'll also need RubyGSM:
49
+
50
+ $ sudo gem install adammck-rubygsm
@@ -0,0 +1,168 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "libglade2.rb"
4
+ require "drb.rb"
5
+
6
+ SERVER_PORT = "1370"
7
+
8
+ class SmsGui
9
+ include GetText
10
+ attr :glade
11
+
12
+ def initialize
13
+
14
+ # if this file is a symlink (which is probably is, since rubygems
15
+ # automatically symlinks bin files into /usr/bin), find the original
16
+ # source file, so we can find the glade src relatively
17
+ this_file = File.symlink?(__FILE__) ? \
18
+ File.readlink(__FILE__) : __FILE__
19
+
20
+ # find and load up the glade src for the gui
21
+ glade_src = File.expand_path(File.dirname(this_file) + "/../lib/drb-client.glade")
22
+ @glade = GladeXML.new(glade_src) do |handler|
23
+ method(handler)
24
+ end
25
+
26
+ # for storing outgoing
27
+ # messages for recall
28
+ @history = []
29
+ @hist_pos = 0
30
+
31
+ # fetch references to the gtk widgets
32
+ @source = @glade.get_widget("entry_source")
33
+ @entry = @glade.get_widget("entry_message")
34
+ @log = @glade.get_widget("textview_log")
35
+ @lb = @log.buffer
36
+
37
+ # create a text mark to keep at the end of
38
+ # the log, so we can keep scrolling to it
39
+ @lb.create_mark("end", @lb.end_iter, true)
40
+
41
+ # create colors for incoming and outgoing messages
42
+ @lb.create_tag("incoming", "family"=>"monospace", "foreground"=>"#AA0000")
43
+ @lb.create_tag("outgoing", "family"=>"monospace", "foreground"=>"#0000AA")
44
+ @lb.create_tag("error", "family"=>"monospace", "foreground"=>"#FFFFFF", "background"=>"#FF0000")
45
+
46
+ # prepopulate the source phone number with six digits
47
+ @src = (1111 + rand(8888)).to_s
48
+ @source.text = @src
49
+
50
+ # fire up drb, to send outgoing messages to rubysms
51
+ @injector = DRbObject.new_with_uri("druby://localhost:#{SERVER_PORT}")
52
+ puts "Connected to RubySMS at: #{@injector.__drburi}"
53
+
54
+ # start listening for incoming messages from rubysms
55
+ @drb = DRb.start_service("druby://localhost:#{SERVER_PORT}#{@src}", self)
56
+ puts "Started DRb client at: #{@drb.uri}"
57
+
58
+ # display the GUI
59
+ @glade.get_widget("window").show
60
+ @entry.grab_focus
61
+ end
62
+
63
+
64
+ # user closed the window,
65
+ # so terminate the program
66
+ def on_quit
67
+ Gtk.main_quit
68
+ end
69
+
70
+
71
+ # user clicked "send", or pressed
72
+ # enter while in the entry field
73
+ def on_button_send_clicked
74
+ msg = @entry.text
75
+
76
+ # do nothing if the message is blank
77
+ return nil if msg.empty?
78
+ log ">> #{msg}", "incoming"
79
+
80
+ begin
81
+ # attempt to send the message
82
+ # to rubysms via drb, as if it
83
+ # were a real incoming sms
84
+ @injector.incoming(@src, msg)
85
+
86
+ @hist_pos = 0
87
+ @history.push(msg)
88
+ @entry.text = ""
89
+
90
+ # couldn't connect!
91
+ rescue DRb::DRbConnError
92
+ log("Connection to RubySMS failed!", "error")
93
+ #log("Tried URI: #{@injector.__drburi}", "error")
94
+ rescue => err
95
+ log(err.message, "error")
96
+ log(" " + err.backtrace.join("\n "), "error")
97
+ end
98
+ end
99
+
100
+ def on_entry_message_key_press(widget, event)
101
+ if event.keyval == 65362 # up arrow
102
+ if @hist_pos > -@history.length
103
+ @hist_pos -= 1
104
+ @entry.text = @history[@hist_pos]
105
+ @entry.grab_focus
106
+ end
107
+
108
+ # prevent default event,
109
+ # which bumps the focus
110
+ # upwards to the log
111
+ return true
112
+ end
113
+
114
+ if event.keyval == 65364 # down arrow
115
+ if @hist_pos < -1
116
+ @hist_pos += 1
117
+ @entry.text = @history[@hist_pos]
118
+ @entry.grab_focus
119
+ end
120
+
121
+ # prevent default
122
+ return true
123
+ end
124
+
125
+ # any other key resets
126
+ # the position in history
127
+ @hist_pos = 0
128
+ return false
129
+ end
130
+
131
+
132
+ def log(msg, tag)
133
+ # prepend a newline, unless this
134
+ # is the first entry (to avoid a
135
+ # one-line gap at the top)
136
+ msg = "\n" + msg\
137
+ if @lb.char_count > 0
138
+
139
+ # add a single entry to the message log
140
+ # (using the colors defined in #initialize)
141
+ @lb.insert(@lb.end_iter, msg, tag)
142
+
143
+ # scroll to the end of the *previous*
144
+ # message, to bring the top of this
145
+ # message into view, in case it is long
146
+ @log.scroll_to_mark(@lb.get_mark("end"), 0, true, 1, 0)
147
+
148
+ # update the position of the end marker,
149
+ # which we will scroll to (above), when
150
+ # the next message is logged
151
+ @lb.move_mark(@lb.get_mark("end"), @lb.end_iter)
152
+ @lb.end_iter.line_offset = 0
153
+ end
154
+
155
+
156
+ def incoming(msg)
157
+ # we received a message! do nothing
158
+ # except add it to the message log
159
+ msg.gsub!(/\n/, "\n ")
160
+ log "<< #{msg}", "outgoing"
161
+ end
162
+ end
163
+
164
+
165
+ # initialize, and block
166
+ # until GTK terminates
167
+ gui = SmsGui.new
168
+ Gtk.main
@@ -0,0 +1,90 @@
1
+ <?xml version="1.0"?>
2
+ <glade-interface>
3
+ <!--<requires-version lib="gtk+" version="2.12"/>-->
4
+ <widget class="GtkWindow" id="window">
5
+ <property name="border_width">6</property>
6
+ <property name="title" translatable="yes">RubySMS Virtual Device</property>
7
+ <property name="default_width">500</property>
8
+ <property name="default_height">300</property>
9
+ <property name="icon_name">mail-send-receive</property>
10
+ <signal name="delete_event" handler="on_quit"/>
11
+ <child>
12
+ <widget class="GtkVBox" id="vbox1">
13
+ <property name="visible">True</property>
14
+ <property name="spacing">6</property>
15
+ <child>
16
+ <widget class="GtkScrolledWindow" id="scrolledwindow1">
17
+ <property name="visible">True</property>
18
+ <property name="can_focus">True</property>
19
+ <property name="hscrollbar_policy">GTK_POLICY_AUTOMATIC</property>
20
+ <property name="shadow_type">GTK_SHADOW_IN</property>
21
+ <child>
22
+ <widget class="GtkTextView" id="textview_log">
23
+ <property name="visible">True</property>
24
+ <property name="can_focus">True</property>
25
+ <property name="events"></property>
26
+ <property name="editable">False</property>
27
+ </widget>
28
+ </child>
29
+ </widget>
30
+ </child>
31
+ <child>
32
+ <widget class="GtkTable" id="table2">
33
+ <property name="visible">True</property>
34
+ <property name="n_columns">3</property>
35
+ <property name="column_spacing">6</property>
36
+ <child>
37
+ <widget class="GtkEntry" id="entry_message">
38
+ <property name="visible">True</property>
39
+ <property name="can_focus">True</property>
40
+ <property name="has_tooltip">True</property>
41
+ <property name="tooltip" translatable="yes">Message text</property>
42
+ <property name="activates_default">True</property>
43
+ <signal name="key_press_event" handler="on_entry_message_key_press"/>
44
+ <accelerator key="T" signal="grab-focus" modifiers="GDK_MOD1_MASK"/>
45
+ </widget>
46
+ <packing>
47
+ <property name="left_attach">1</property>
48
+ <property name="right_attach">2</property>
49
+ <property name="y_options">GTK_FILL</property>
50
+ </packing>
51
+ </child>
52
+ <child>
53
+ <widget class="GtkButton" id="button_send">
54
+ <property name="visible">True</property>
55
+ <property name="can_focus">True</property>
56
+ <property name="can_default">True</property>
57
+ <property name="has_default">True</property>
58
+ <property name="receives_default">True</property>
59
+ <property name="label" translatable="yes">Send SMS</property>
60
+ <property name="response_id">0</property>
61
+ <signal name="clicked" handler="on_button_send_clicked"/>
62
+ </widget>
63
+ <packing>
64
+ <property name="left_attach">2</property>
65
+ <property name="right_attach">3</property>
66
+ <property name="x_options">GTK_FILL</property>
67
+ </packing>
68
+ </child>
69
+ <child>
70
+ <widget class="GtkEntry" id="entry_source">
71
+ <property name="width_request">60</property>
72
+ <property name="visible">True</property>
73
+ <property name="can_focus">True</property>
74
+ <property name="tooltip" translatable="yes">DRb TCP/IP Port</property>
75
+ <property name="editable">False</property>
76
+ </widget>
77
+ <packing>
78
+ <property name="x_options">GTK_FILL</property>
79
+ </packing>
80
+ </child>
81
+ </widget>
82
+ <packing>
83
+ <property name="expand">False</property>
84
+ <property name="position">1</property>
85
+ </packing>
86
+ </child>
87
+ </widget>
88
+ </child>
89
+ </widget>
90
+ </glade-interface>
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env ruby
2
+ #:title:RubySMS
3
+ #--
4
+ # vim: noet
5
+ #++
6
+
7
+
8
+ # during development, it's important to EXPLODE
9
+ # as early as possible when something goes wrong
10
+ Thread.abort_on_exception = true
11
+ Thread.current["name"] = "main"
12
+
13
+
14
+ # everything (should) live
15
+ # in this tidy namespace
16
+ module SMS
17
+
18
+ # store this directory name; everything
19
+ # inside here is considered to be part
20
+ # of the rubysms framework, so can be
21
+ # ignored in application backtraces
22
+ Root = File.dirname(__FILE__)
23
+ end
24
+
25
+
26
+ # load all supporting files
27
+ dir = SMS::Root + "/rubysms"
28
+ require "#{dir}/logger.rb"
29
+ require "#{dir}/router.rb"
30
+ require "#{dir}/thing.rb"
31
+ require "#{dir}/application.rb"
32
+ require "#{dir}/backend.rb"
33
+ require "#{dir}/errors.rb"
34
+ require "#{dir}/person.rb"
35
+
36
+
37
+ # message classes
38
+ require "#{dir}/message/incoming.rb"
39
+ require "#{dir}/message/outgoing.rb"
@@ -0,0 +1,207 @@
1
+ #!/usr/bin/env ruby
2
+ # vim: noet
3
+
4
+ module SMS
5
+ class App < Thing
6
+
7
+ # Creates and starts a router to serve only
8
+ # this application. Handy during development.
9
+ #
10
+ # This method accepts an arbitrary number of
11
+ # backends, each of which can be provided in
12
+ # numerous ways. This is kind of hard to wrap
13
+ # one's head around, but makes us super flexible.
14
+ # TODO: this magic will all be moved to the
15
+ # router, one day, so multiple apps
16
+ # can take advantage of it.
17
+ #
18
+ # # start the default backends
19
+ # # (one http, and one drb)
20
+ # App.serve!
21
+ #
22
+ # # just the http backend
23
+ # App.serve!(:HTTP)
24
+ #
25
+ # # the http backend... with configuration option(s)!
26
+ # # (in this case, a port). it's got to be an array,
27
+ # # so we know that we're referring to one single
28
+ # # backend here, not two "HTTP" and "8080" backends
29
+ # App.serve!([:HTTP, 8080])
30
+ #
31
+ # # two GSM backends on separate ports
32
+ # App.serve!([:GSM, "/dev/ttyS0"], [:GSM, "/dev/ttyS1"])
33
+ #
34
+ # You may notice that these arguments resemble the
35
+ # config options from the Malawi RapidSMS project...
36
+ # this is not a co-incidence.
37
+ def self.serve!(*backends)
38
+
39
+ # if no backends were explicitly requested,
40
+ # default to the HTTP + DRB offline backends
41
+ backends = [:HTTP, :DRB] if\
42
+ backends.empty?
43
+
44
+ # create a router, and attach each new backend
45
+ # in turn. because ruby's *splat operator is so
46
+ # clever, each _backend_ can be provided in many
47
+ # ways - see this method's docstring.
48
+ router = SMS::Router.new
49
+ backends.each do |backend|
50
+ router.add_backend(*backend)
51
+ end
52
+
53
+ router.add_app(self.new)
54
+ router.serve_forever
55
+ end
56
+
57
+ def incoming(msg)
58
+ if services = self.class.instance_variable_get(:@services)
59
+
60
+ # duplicate the message text before hacking it
61
+ # into pieces, so we don't alter the original
62
+ text = msg.text.dup
63
+
64
+ # lock threads while handling this message, so we don't have
65
+ # to worry about being interrupted by other incoming messages
66
+ # (in theory, this shouldn't be a problem, but it turns out
67
+ # to be a frequent source of bugs)
68
+ Thread.exclusive do
69
+ services.each do |service|
70
+ method, pattern, priority = *service
71
+
72
+ # if the pattern is a string, then assume that
73
+ # it's a case-insensitive simple trigger - it's
74
+ # a common enough use-case to warrant an exception
75
+ if pattern.is_a?(String)
76
+ pattern = /\A#{pattern}\Z/i
77
+ end
78
+
79
+ # if this pattern looks like a regex,
80
+ # attempt to match the incoming message
81
+ if pattern.respond_to?(:match)
82
+ if m = pattern.match(text)
83
+
84
+ # we have a match! attempt to
85
+ # dispatch it to the receiver
86
+ dispatch_to(method, msg, m.captures)
87
+
88
+ # the method accepted the text, but it may not be interested
89
+ # in the whole message. so crop off just the part that matched
90
+ text.sub!(pattern, "")
91
+
92
+ # stop processing if we have
93
+ # dealt with all of the text
94
+ return true unless text =~ /\S/
95
+
96
+ # there is text remaining, so
97
+ # (re-)start iterating services
98
+ # (jumps back to services.each)
99
+ retry
100
+ end
101
+
102
+ # the special :anything pattern can be used
103
+ # as a default service. once this is hit, we
104
+ # are done processing the entire message
105
+ elsif pattern == :anything
106
+ dispatch_to(method, msg, [text])
107
+ return true
108
+
109
+ # we don't understand what this pattern
110
+ # is, or how it ended up in @services.
111
+ # no big deal, but log it anyway, since
112
+ # it indicates that *something* is awry
113
+ else
114
+ log "Invalid pattern: #{pattern.inspect}", :warn
115
+ end
116
+ end#each
117
+
118
+
119
+ end#exclusive
120
+ end#if
121
+ end
122
+
123
+ def message(msg)
124
+ if msg.is_a? Symbol
125
+ begin
126
+ self.class.const_get(:Messages)[msg]
127
+
128
+ # something went wrong, but i don't
129
+ # particularly care what, right now.
130
+ # log it, and carry on regardless
131
+ rescue StandardError
132
+ log "Invalid message #{msg.inspect} for #{self.class}", :warn
133
+ "<#{msg}>"
134
+ end
135
+ else
136
+ msg
137
+ end
138
+ end
139
+
140
+ def assemble(*parts)
141
+
142
+ # the last element can be an array,
143
+ # which contains arguments to sprintf
144
+ args = parts[-1].is_a?(Array)? parts.pop : []
145
+
146
+ # resolve each remaining part
147
+ # via self#messge, which can
148
+ # (should?) be overloaded
149
+ parts.collect do |msg|
150
+ message(msg)
151
+ end.join("") % args
152
+ end
153
+
154
+ private
155
+
156
+ def dispatch_to(meth_str, msg, captures)
157
+ log_dispatch(meth_str, captures)
158
+
159
+ begin
160
+ err_line = __LINE__ + 1
161
+ send(meth_str, msg, *captures)
162
+
163
+ rescue ArgumentError => err
164
+
165
+ # if the line above (where we dispatch to the receiving
166
+ # method) caused the error, we'll log a more useful message
167
+ if (err.backtrace[0] =~ /^#{__FILE__}:#{err_line}/)
168
+ wanted = (method(meth_str).arity - 1)
169
+ problem = (captures.length > wanted) ? "Too many" : "Not enough"
170
+ log "#{problem} captures (wanted #{wanted}, got #{captures.length})", :warn
171
+
172
+ else
173
+ raise
174
+ end
175
+ end
176
+ end
177
+
178
+ # Adds a log message detailing which method is being
179
+ # invoked, with which arguments (if any)
180
+ def log_dispatch(method, args=[])
181
+ meth_str = self.class.to_s + "#" + method.to_s
182
+ meth_str += " #{args.inspect}" unless args.empty?
183
+ log "Dispatching to: #{meth_str}"
184
+ end
185
+
186
+ class << self
187
+ def serve(regex)
188
+ @serve = regex
189
+ end
190
+
191
+ def method_added(meth)
192
+ if @serve
193
+ @services = []\
194
+ unless @services
195
+
196
+ # add this method, along with the last stored
197
+ # regex, to the map of services for this app.
198
+ # the default 'incoming' method will iterate
199
+ # the regexen, and redirect the message to
200
+ # the method linked here
201
+ @services.push([meth, @serve])
202
+ @serve = nil
203
+ end
204
+ end
205
+ end
206
+ end # App
207
+ end # SMS