├── .gitignore ├── MIT-LICENSE ├── README.textile ├── Rakefile ├── bin ├── jschat-client ├── jschat-server └── jschat-web ├── jschat.gemspec ├── lib └── jschat │ ├── client.rb │ ├── errors.rb │ ├── flood_protection.rb │ ├── http │ ├── config.ru │ ├── helpers │ │ └── url_for.rb │ ├── jschat.rb │ ├── public │ │ ├── favicon.ico │ │ ├── iOS-114.png │ │ ├── iOS-57.png │ │ ├── iOS-72.png │ │ ├── images │ │ │ ├── emoticons │ │ │ │ ├── angry.gif │ │ │ │ ├── arr.gif │ │ │ │ ├── blink.gif │ │ │ │ ├── blush.gif │ │ │ │ ├── brucelee.gif │ │ │ │ ├── btw.gif │ │ │ │ ├── chuckle.gif │ │ │ │ ├── clap.gif │ │ │ │ ├── cool.gif │ │ │ │ ├── drool.gif │ │ │ │ ├── drunk.gif │ │ │ │ ├── dry.gif │ │ │ │ ├── eek.gif │ │ │ │ ├── flex.gif │ │ │ │ ├── happy.gif │ │ │ │ ├── holmes.gif │ │ │ │ ├── huh.gif │ │ │ │ ├── laugh.gif │ │ │ │ ├── lol.gif │ │ │ │ ├── mad.gif │ │ │ │ ├── mellow.gif │ │ │ │ ├── noclue.gif │ │ │ │ ├── oh.gif │ │ │ │ ├── ohmy.gif │ │ │ │ ├── panic.gif │ │ │ │ ├── ph34r.gif │ │ │ │ ├── pimp.gif │ │ │ │ ├── punch.gif │ │ │ │ ├── realmad.gif │ │ │ │ ├── rock.gif │ │ │ │ ├── rofl.gif │ │ │ │ ├── rolleyes.gif │ │ │ │ ├── sad.gif │ │ │ │ ├── scratch.gif │ │ │ │ ├── shifty.gif │ │ │ │ ├── shock.gif │ │ │ │ ├── shrug.gif │ │ │ │ ├── sleep.gif │ │ │ │ ├── sleeping.gif │ │ │ │ ├── smile.gif │ │ │ │ ├── suicide.gif │ │ │ │ ├── sweat.gif │ │ │ │ ├── thumbs.gif │ │ │ │ ├── tongue.gif │ │ │ │ ├── unsure.gif │ │ │ │ ├── w00t.gif │ │ │ │ ├── wacko.gif │ │ │ │ ├── whistling.gif │ │ │ │ ├── wink.gif │ │ │ │ ├── worship.gif │ │ │ │ └── yucky.gif │ │ │ ├── jschat.gif │ │ │ └── shadow.png │ │ ├── javascripts │ │ │ ├── all.js │ │ │ ├── app │ │ │ │ ├── controllers │ │ │ │ │ ├── chat_controller.js │ │ │ │ │ └── signon_controller.js │ │ │ │ ├── helpers │ │ │ │ │ ├── emote_helper.js │ │ │ │ │ ├── form_helpers.js │ │ │ │ │ ├── link_helper.js │ │ │ │ │ ├── page_helper.js │ │ │ │ │ └── text_helper.js │ │ │ │ ├── lib │ │ │ │ │ └── split.js │ │ │ │ ├── models │ │ │ │ │ ├── cookie.js │ │ │ │ │ └── user.js │ │ │ │ ├── protocol │ │ │ │ │ ├── change.js │ │ │ │ │ ├── chat_request.js │ │ │ │ │ └── display.js │ │ │ │ └── ui │ │ │ │ │ ├── commands.js │ │ │ │ │ └── tab_completion.js │ │ │ └── init.js │ │ └── stylesheets │ │ │ ├── ipad.css │ │ │ ├── iphone.css │ │ │ └── screen.css │ ├── tmp │ │ └── restart.txt │ └── views │ │ ├── form.erb │ │ ├── index.erb │ │ ├── ipad.erb │ │ ├── iphone.erb │ │ ├── iphone_message_form.erb │ │ ├── layout.erb │ │ ├── message_form.erb │ │ └── twitter.erb │ ├── init.rb │ ├── server.rb │ ├── server_options.rb │ └── storage │ ├── init.rb │ ├── mongo.rb │ └── null.rb └── test ├── server_test.rb ├── stateless_test.rb └── test_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | *.swo 3 | .DS_Store 4 | http/tmp 5 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2009 Alex R. Young 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | -------------------------------------------------------------------------------- /README.textile: -------------------------------------------------------------------------------- 1 | !http://github.com/alexyoung/jschat/raw/8c11578439770962d86d6444d0c159cfb2affbcd/http/public/images/jschat.gif! 2 | 3 | JsChat is a chat system. It has an easy to learn JSON protocol, an ncurses client, a web app, and a server. You can try it right now on "jschat.org":http://jschat.org. 4 | 5 | JsChat is similar to IRC, but it's a fundamentally simpler system. 6 | 7 | The *web app* has lots of interesting features: 8 | 9 | * IRC-like commands: /names, /name new_name (/nick works too), /clear, /lastlog 10 | * It's pretty tiny; it's built with "Sinatra":http://www.sinatrarb.com and "Prototype":http://prototypejs.org 11 | * Auto-linking: pasting an image displays it inline, youtube and vimeo videos will appear as well 12 | * Last messages are displayed on join: the last 100 messages are displayed, so you don't feel lost when you join a room 13 | * Tab completion! 14 | 15 | !http://dl.getdropbox.com/u/221414/blogs/jschat.png! 16 | 17 | h3. Installation 18 | 19 | You can install with rubygems: 20 | 21 |
gem install jschat22 | 23 | Then run
jschat-server
and jschat-client
to try out the console client locally.
24 |
25 | To try out the web client, run jschat-web
and visit "http://localhost:4567":http://localhost:4567.
26 |
27 | h3. Ruby Library Requirements
28 |
29 | These gems are required by JsChat:
30 |
31 | * eventmachine
32 | * ncurses (for the client)
33 | * json
34 |
35 | h3. Usage
36 |
37 | * Run the server with ./server.rb
38 | * Connect a client with ./client.rb
39 |
40 | The web app must be run alongside the server. The web app must be started in production mode:
41 |
42 | http/jschat.rb -e production
43 |
44 | The web app currently has no database dependencies, it's a wrapper that links cookies to JsChat server proxies. You can run it on port 80 by configuring Rack or an Apache proxy. I have Apache set up this way on "jschat.org":http://jschat.org.
45 |
46 | h3. Configuration Files
47 |
48 | These are the default locations of the configuration files. You can override them with --config=PATH
:
49 |
50 | * Client: ~/.jschat/config.json
51 | * Server: /etc/jschat/config.json
52 |
53 | The web app will use the same configuration file as the server so it can find out where the server is.
54 |
55 | The file format is JSON, like this:
56 |
57 | 58 | { "port": 3001 } 59 |60 | 61 | h3. Server Configuration Options 62 | 63 |
64 | { 65 | "port": integer, 66 | "ip": "string: IP address to bind to", 67 | "tmp_files": "string: path to tmp files (including PID file)" 68 | } 69 |70 | 71 | h3. Client Commands 72 | 73 | * Change name or identify:
/nick name
74 | * Join a room: /join #room
75 | * Join a room (alias): /j #room
76 |
77 | h3. Protocol Design
78 |
79 | The protocol is designed to be as close to executable JSON as possible, so clients and servers are simple to implement.
80 |
81 | Look at client.rb JsChat::Protocol
to see what I mean.
82 |
83 | h3. Hey, this is like Campfire!
84 |
85 | I love Campfire and I didn't intend for JsChat to compete with it. JsChat is just a fun project, it doesn't offer Campfire's business-friendly interface, file hosting, transcripts and Basecamp integration.
86 |
87 | h3. Credits
88 |
89 | JsChat was created by "Alex Young":http://alexyoung.org for "Helicoid":http://helicoid.net. A growing group of friends are helping out:
90 |
91 | * "nickmartini":http://github.com/nickmartini
92 | * "gabrielg":http://github.com/gabrielg
93 | * "Simon Starr":http://github.com/sstarr
94 | * Kevin Ford
95 | * "sekrett":http://github.com/sekrett
96 |
97 | If you'd like to contribute, send "alexyoung":http://github.com/alexyoung a message on GitHub.
98 |
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | # This deploys by using gem and symlinking jschat to a path suitable for a web server
2 |
3 | deploy_to = 'web2'
4 | app_path = '/var/www/jschat'
5 | remote_gem_command = 'gem'
6 | restart_web_server = '/etc/init.d/apache2 restart'
7 |
8 | task :test do
9 | require 'rake/testtask'
10 | Rake::TestTask.new do |t|
11 | t.libs << 'test'
12 | t.test_files = FileList['test/*_test.rb']
13 | t.verbose = true
14 | end
15 | end
16 |
17 | task :assets do
18 | js = ''
19 | File.unlink('lib/jschat/http/public/javascripts/all.js')
20 |
21 | Dir['lib/jschat/http/public/javascripts/**/*.js'].reverse.each do |file|
22 | js << File.read(file)
23 | end
24 |
25 | File.open('lib/jschat/http/public/javascripts/all.js', 'w+') do |file|
26 | file.write js
27 | end
28 | end
29 |
30 | task :deploy do
31 | `#{remote_gem_command} build jschat.gemspec`
32 | jschat_gem = `ls jschat-*.gem`
33 | `#{remote_gem_command} push jschat-*.gem`
34 | puts "Waiting until gem has updated on rubyforge"
35 | sleep 30
36 | `ssh #{deploy_to} sudo #{remote_gem_command} update jschat`
37 | gem_path = `ssh #{deploy_to} #{remote_gem_command} environment | grep " - INSTALLATION DIRECTORY: " | sed 's/ - INSTALLATION DIRECTORY: //'`
38 | `ssh #{deploy_to} rm #{app_path}`
39 | jschat_gem_path = jschat_gem.sub(/\.gem/, '').strip
40 | `ssh #{deploy_to} ln -s #{gem_path.strip}/#{jschat_gem_path}/lib/jschat/http/ #{app_path}`
41 | `ssh #{deploy_to} sudo #{restart_web_server}`
42 | `rm jschat-*.gem`
43 | end
44 |
--------------------------------------------------------------------------------
/bin/jschat-client:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 |
3 | $:.unshift(File.expand_path(File.dirname(__FILE__) + '/../lib'))
4 |
5 | require 'jschat/client'
6 |
--------------------------------------------------------------------------------
/bin/jschat-server:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 |
3 | $:.unshift(File.expand_path(File.dirname(__FILE__) + '/../lib'))
4 |
5 | require 'logger'
6 | require 'jschat/server'
7 | require 'jschat/server_options'
8 |
9 | def reload!
10 | puts 'Reloading Files'
11 | Gem.clear_paths
12 | spec = Gem.searcher.find('jschat/server')
13 | load File.join(spec.full_gem_path, 'lib', 'jschat', 'server.rb')
14 | load File.join(spec.full_gem_path, 'lib', 'jschat', 'server_options.rb')
15 | end
16 |
17 | trap 'SIGHUP', lambda { reload! }
18 |
19 | JsChat::Server.run!
20 |
--------------------------------------------------------------------------------
/bin/jschat-web:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 |
3 | $:.unshift(File.expand_path(File.dirname(__FILE__) + '/../lib'))
4 |
5 | require 'rubygems'
6 | require 'sinatra'
7 | require 'getoptlong'
8 |
9 | set :environment, :production
10 |
11 | def printusage(error_code)
12 | print "Usage: jschat-web [options]\n\n"
13 | print " -b, --bind=ADDRESS IP address\n"
14 | print " -p, --port=PORT Port number\n"
15 | print " -H, --help This text\n"
16 |
17 | exit(error_code)
18 | end
19 |
20 | opts = GetoptLong.new(
21 | [ "--bind", "-b", GetoptLong::REQUIRED_ARGUMENT ],
22 | [ "--port", "-p", GetoptLong::REQUIRED_ARGUMENT ],
23 | [ "--help", "-H", GetoptLong::NO_ARGUMENT ]
24 | )
25 |
26 | begin
27 | opts.each do |opt, arg|
28 | case opt
29 | when "--bind"
30 | set :bind, arg
31 | when "--port"
32 | set :port, arg
33 | when "--help"
34 | printusage(0)
35 | end
36 | end
37 | end
38 |
39 | require 'jschat/http/jschat'
40 | Sinatra::Application.run!
41 |
42 |
--------------------------------------------------------------------------------
/jschat.gemspec:
--------------------------------------------------------------------------------
1 | require 'rake'
2 |
3 | Gem::Specification.new do |s|
4 | s.name = %q{jschat}
5 | s.version = '0.3.7'
6 |
7 | s.required_rubygems_version = Gem::Requirement.new('>= 0') if s.respond_to? :required_rubygems_version=
8 | s.authors = ['Alex R. Young']
9 | s.date = %q{2012-04-13}
10 | s.description = %q{JsChat is a JSON-based web and console chat app.}
11 | s.email = %q{alex@alexyoung.org}
12 | s.files = FileList['{bin,http,lib,test}/**/*', 'README.textile', 'MIT-LICENSE'].to_a
13 | s.has_rdoc = false
14 | s.bindir = 'bin'
15 | s.executables = %w{jschat-server jschat-client jschat-web}
16 | s.default_executable = 'bin/jschat-server'
17 | s.homepage = %q{http://github.com/alexyoung/jschat}
18 | s.summary = %q{JsChat features a chat server, client and web app.}
19 |
20 | s.add_dependency('sinatra', '>= 0.9.4')
21 | s.add_dependency('json', '>= 1.1.9')
22 | s.add_dependency('eventmachine', '>= 0.12.8')
23 | s.add_dependency('ncurses', '>= 0.9.1')
24 | end
25 |
--------------------------------------------------------------------------------
/lib/jschat/client.rb:
--------------------------------------------------------------------------------
1 | require 'rubygems'
2 | require 'eventmachine'
3 | gem 'json', '>= 1.1.9'
4 | require 'json'
5 | require 'ncurses'
6 | require 'optparse'
7 | require 'time'
8 |
9 | options = {}
10 | default_config_file = '~/.jschat/config.json'
11 | ARGV.clone.options do |opts|
12 | script_name = File.basename($0)
13 | opts.banner = "Usage: #{$0} [options]"
14 |
15 | opts.separator ""
16 |
17 | opts.on("-c", "--config=PATH", String, "Configuration file location (#{default_config_file})") { |o| options['config'] = o }
18 | opts.on("-h", "--hostname=host", String, "JsChat server hostname") { |o| options['hostname'] = o }
19 | opts.on("-p", "--port=port", String, "JsChat server port number") { |o| options['port'] = o }
20 | opts.on("-r", "--room=#room", String, "Channel to auto-join: remember to escape the hash") { |o| options['room'] = o }
21 | opts.on("-n", "--nick=name", String, "Your name") { |o| options['nick'] = o }
22 | opts.on("--help", "-H", "This text") { puts opts; exit 0 }
23 |
24 | opts.parse!
25 | end
26 |
27 | # Command line options will overrides these
28 | def load_options(path)
29 | path = File.expand_path path
30 | if File.exists? path
31 | JSON.parse(File.read path)
32 | else
33 | {}
34 | end
35 | end
36 |
37 | options = load_options(options['config'] || default_config_file).merge options
38 |
39 | ClientConfig = {
40 | :port => options['port'] || '6789',
41 | :ip => options['hostname'] || '0.0.0.0',
42 | :name => options['nick'] || ENV['LOGNAME'],
43 | :auto_join => options['room'] || '#jschat'
44 | }
45 |
46 | module Ncurses
47 | KEY_DELETE = ?\C-h
48 | KEY_TAB = 9
49 | end
50 |
51 | module JsChat
52 | class Protocol
53 | class Response
54 | attr_reader :message, :time
55 | def initialize(message, time)
56 | @message, @time = message, time
57 | end
58 | end
59 |
60 | def initialize(connection)
61 | @connection = connection
62 | end
63 |
64 | def legal_commands
65 | %w(messages message joined quit error join names part identified part_notice quit_notice join_notice)
66 | end
67 |
68 | def legal_change_commands
69 | %w(user)
70 | end
71 |
72 | def legal?(command)
73 | legal_commands.include? command
74 | end
75 |
76 | def legal_change?(command)
77 | legal_change_commands.include? command
78 | end
79 |
80 | def identified? ; @identified ; end
81 |
82 | def change_user(json)
83 | original_name = json['name'].keys().first
84 | new_name = json['name'].values.first
85 | @connection.names.delete original_name
86 | @connection.names << new_name
87 | "* User #{original_name} is now known as #{new_name}"
88 | end
89 |
90 | def message(json)
91 | if json['room']
92 | "[#{json['room']}] <#{json['user']}> #{json['message']}"
93 | else
94 | "PRIVATE <#{json['user']}> #{json['message']}"
95 | end
96 | end
97 |
98 | def messages(messages)
99 | results = []
100 | messages.each do |json|
101 | results << process_message(json)
102 | end
103 | results
104 | end
105 |
106 | def get_command(json)
107 | if json.has_key? 'display' and legal? json['display']
108 | 'display'
109 | elsif json.has_key? 'change' and legal_change? json['change']
110 | 'change'
111 | end
112 | end
113 |
114 | def get_time(json)
115 | if json.kind_of? Hash and json.has_key? 'time'
116 | Time.parse(json['time']).getlocal
117 | else
118 | Time.now.localtime
119 | end
120 | end
121 |
122 | def protocol_method_name(command, method_name)
123 | if command == 'display'
124 | method_name
125 | elsif command == 'change'
126 | "change_#{method_name}"
127 | end
128 | end
129 |
130 | def process_message(json)
131 | command = get_command json
132 |
133 | if command
134 | time = get_time json
135 | response = send(protocol_method_name(command, json[command]), json[json[command]])
136 | case response
137 | when Array
138 | response
139 | when String
140 | Response.new(response, time)
141 | end
142 | end
143 | end
144 |
145 | def join(json)
146 | @connection.send_names json['room']
147 | "* User #{json['user']} joined #{json['room']}"
148 | end
149 |
150 | def join_notice(json)
151 | @connection.names << json['user']
152 | "* User #{json['user']} joined #{json['room']}"
153 | end
154 |
155 | def part(json)
156 | "* You left #{json['room']}"
157 | end
158 |
159 | def part_notice(json)
160 | @connection.names.delete json['user']
161 | "* #{json['user']} left #{json['room']}"
162 | end
163 |
164 | def quit(json)
165 | @connection.names.delete json['user']
166 | "* User #{json['user']} left #{json['room']}"
167 | end
168 |
169 | def names(json)
170 | @connection.names = json.collect { |u| u['name'] }
171 | "* In this room: #{@connection.names.join(', ')}"
172 | end
173 |
174 | def identified(json)
175 | if ClientConfig[:auto_join] and !@auto_joined
176 | @connection.send_join ClientConfig[:auto_join]
177 | @auto_joined = true
178 | end
179 |
180 | @identified = true
181 |
182 | "* You are now known as #{json['name']}"
183 | end
184 |
185 | def error(json)
186 | "* [ERROR] #{json['message']}"
187 | end
188 |
189 | alias_method :quit_notice, :quit
190 | end
191 | end
192 |
193 | module JsClient
194 | class TabComplete
195 | attr_accessor :matched, :index
196 |
197 | def initialize
198 | end
199 |
200 | def run(names, field, form, cursor_x)
201 | form.form_driver Ncurses::Form::REQ_BEG_LINE
202 | text = field.field_buffer(0).dup.strip
203 | if text.size > 0 and cursor_x > 0
204 | if @matched.nil?
205 | source = text.slice(0, cursor_x)
206 | source = text.split(' ').last if text.match(' ')
207 | @index = 0
208 | else
209 | source = @matched
210 | @index += 1
211 | end
212 | names = names.sort.find_all { |name| name.match /^#{source}/i }
213 | @index = 0 if @index >= names.size
214 | name = names[@index]
215 | if name and (@matched or source.size == text.size)
216 | @matched = source
217 | field.set_field_buffer(0, "#{name}: ")
218 | form.form_driver Ncurses::Form::REQ_END_LINE
219 | form.form_driver Ncurses::Form::REQ_NEXT_CHAR
220 | else
221 | @matched = nil
222 | @index = 0
223 | (0..cursor_x - 1).each do
224 | form.form_driver Ncurses::Form::REQ_NEXT_CHAR
225 | end
226 | end
227 | end
228 | end
229 |
230 | def match(input)
231 | end
232 |
233 | def reset
234 | @matched = nil
235 | @index = 0
236 | end
237 | end
238 |
239 | def keyboard=(keyboard)
240 | @keyboard = keyboard
241 | end
242 |
243 | # This should take room into account
244 | def names=(names)
245 | @names = names
246 | end
247 |
248 | def names
249 | @names
250 | end
251 |
252 | module KeyboardInput
253 | def setup_screen
254 | Ncurses.initscr
255 | @windows = {}
256 |
257 | Ncurses.raw
258 | Ncurses.start_color
259 | Ncurses.noecho
260 | Ncurses.use_default_colors
261 | Ncurses.init_pair 2, Ncurses::COLOR_WHITE, Ncurses::COLOR_BLUE
262 | Ncurses.init_pair 3, Ncurses::COLOR_CYAN, -1
263 | Ncurses.init_pair 4, Ncurses::COLOR_YELLOW, -1
264 | Ncurses.init_pair 5, Ncurses::COLOR_BLACK, -1
265 | Ncurses.init_pair 6, Ncurses::COLOR_RED, -1
266 |
267 | @history_position = 0
268 | @history = []
269 | @lastlog = []
270 | @room_name = ''
271 |
272 | setup_windows
273 | end
274 |
275 | def room_name=(room_name)
276 | @room_name = room_name
277 | end
278 |
279 | def setup_windows
280 | Ncurses.refresh
281 |
282 | display_windows
283 |
284 | Thread.new do
285 | loop do
286 | display_time
287 | sleep 60 - Time.now.sec
288 | end
289 | end
290 |
291 | Signal.trap('SIGWINCH') do
292 | resize
293 | end
294 | end
295 |
296 | def remove_windows_and_forms
297 | remove_windows
298 | remove_forms
299 | end
300 |
301 | def remove_windows
302 | @windows.each do |name, window|
303 | window.delete
304 | end
305 | end
306 |
307 | def remove_forms
308 | @input_field.free_field
309 | @input_form.free_form
310 | end
311 |
312 | def update_windows
313 | rows, cols = get_window_size
314 | remove_windows_and_forms
315 | display_windows
316 | display_room_name
317 | end
318 |
319 | def display_windows
320 | rows, cols = get_window_size
321 | @windows[:text] = Ncurses.newwin(rows - 1, cols, 0, 0)
322 | @windows[:info] = Ncurses.newwin(rows - 1, cols, rows - 2, 0)
323 | @windows[:input] = Ncurses.newwin(rows, cols, rows - 1, 0)
324 | @windows[:text].scrollok(true)
325 | @windows[:info].bkgd Ncurses.COLOR_PAIR(2)
326 | @windows[:input].keypad(true)
327 | @windows[:input].nodelay(true)
328 |
329 | @windows[:text].refresh
330 | @windows[:info].refresh
331 | @windows[:input].refresh
332 | display_input
333 | end
334 |
335 | def display_input
336 | offset = @room_name.size > 0 ? @room_name.size + 3 : 0
337 | @input_field = Ncurses::Form::FIELD.new(1, Ncurses.COLS - offset, 0, offset, 0, 0)
338 | Ncurses::Form.field_opts_off(@input_field, Ncurses::Form::O_AUTOSKIP)
339 | Ncurses::Form.field_opts_off(@input_field, Ncurses::Form::O_STATIC)
340 | @input_form = Ncurses::Form::FORM.new([@input_field])
341 | @input_form.set_form_win @windows[:input]
342 | @input_form.post_form
343 | @input_field.set_field_buffer 0, ''
344 | end
345 |
346 | def display_room_name
347 | if @room_name
348 | display_input
349 | @windows[:input].mvprintw(0, 0, "[#{@room_name}] ")
350 | @windows[:input].refresh
351 | end
352 | end
353 |
354 | def display_time
355 | @windows[:info].move 0, 0
356 | @windows[:info].addstr "[#{Time.now.strftime('%H:%M')}]\n"
357 | @windows[:info].refresh
358 | @windows[:input].refresh
359 | end
360 |
361 | def resize
362 | # Save the user input
363 | @input_form.form_driver Ncurses::Form::REQ_BEG_LINE
364 | input = @input_field.field_buffer(0)
365 | input.rstrip!
366 |
367 | Ncurses.def_prog_mode
368 | Ncurses.endwin
369 | Ncurses.reset_prog_mode
370 |
371 | update_windows
372 |
373 | cols = get_window_size[0]
374 | if @lastlog.size > 0
375 | lastlog_start = cols > @lastlog.size ? @lastlog.size : cols
376 | @lastlog[-lastlog_start..-1].each do |message|
377 | display_text message[0], message[1]
378 | end
379 | end
380 |
381 | @input_field.set_field_buffer(0, input)
382 | @windows[:input].addstr input
383 | @input_form.form_driver Ncurses::Form::REQ_END_LINE
384 |
385 | Ncurses.refresh
386 | rescue Exception => exception
387 | puts exception
388 | end
389 |
390 | # FIXME: This doesn't work after resize
391 | # I've tried other ruby ncurses programs and they don't either
392 | def get_window_size
393 | Ncurses.refresh
394 | cols, rows = [], []
395 | Ncurses.stdscr.getmaxyx rows, cols
396 | [rows.first, cols.first]
397 | end
398 |
399 | def get_history_text(offset = 1)
400 | offset_position = @history_position + offset
401 | if offset_position >= 0 and offset_position < @history.size
402 | @history_position = offset_position
403 | @history[@history_position]
404 | else
405 | if @history_position > -1 and @history_position < @history.size
406 | @history_position = offset_position
407 | end
408 | ''
409 | end
410 | end
411 |
412 | def arrow_keys(data)
413 | c = data[0]
414 | if @sequence
415 | @sequence << c
416 | history_text = ''
417 |
418 | if data == 'A'
419 | @sequence = nil
420 | history_text = get_history_text(-1)
421 | elsif data == 'B'
422 | @sequence = nil
423 | history_text = get_history_text
424 | elsif data == 'O'
425 | return true
426 | elsif data == 'D'
427 | # left
428 | @sequence = nil
429 | @input_form.form_driver Ncurses::Form::REQ_PREV_CHAR
430 | @windows[:input].refresh
431 | return true
432 | elsif data == 'C'
433 | # right
434 | @input_form.form_driver Ncurses::Form::REQ_NEXT_CHAR
435 | @windows[:input].refresh
436 | @sequence = nil
437 | return true
438 | else
439 | @sequence = nil
440 | return true
441 | end
442 |
443 | begin
444 | @input_form.form_driver Ncurses::Form::REQ_CLR_FIELD
445 | @input_field.set_field_buffer(0, history_text)
446 | @windows[:input].addstr history_text
447 | @windows[:input].refresh
448 | @input_form.form_driver Ncurses::Form::REQ_END_LINE
449 | rescue Exception => exception
450 | end
451 |
452 | return true
453 | elsif c == 27
454 | @sequence = c
455 | return true
456 | end
457 | end
458 |
459 | def cursor_position
460 | cursor_position = Ncurses.getcurx(@windows[:input]) - 10
461 | end
462 |
463 | def receive_data(data)
464 | @clipboard ||= ''
465 | c = data[0]
466 |
467 | if arrow_keys data
468 | return
469 | end
470 |
471 | if c != Ncurses::KEY_TAB
472 | @tab_completion.reset
473 | end
474 |
475 | case c
476 | when -1
477 | # Return
478 | when Ncurses::KEY_TAB
479 | @tab_completion.run @connection.names, @input_field, @input_form, cursor_position
480 | when Ncurses::KEY_ENTER, ?\n, ?\r
481 | @input_form.form_driver Ncurses::Form::REQ_BEG_LINE
482 | line = @input_field.field_buffer(0)
483 | line.rstrip!
484 |
485 | if !line.empty? and line.length > 0
486 | @history << line.dup
487 | @history_position = @history.size
488 | manage_commands line
489 | end
490 | @input_form.form_driver Ncurses::Form::REQ_CLR_FIELD
491 | when ?\C-l
492 | # Refresh
493 | resize
494 | when Ncurses::KEY_BACKSPACE, ?\C-h, 127
495 | # Backspace
496 | @input_form.form_driver Ncurses::Form::REQ_DEL_PREV
497 | @input_form.form_driver Ncurses::Form::REQ_CLR_EOL
498 | when ?\C-d
499 | @input_form.form_driver Ncurses::Form::REQ_DEL_CHAR
500 | when Ncurses::KEY_LEFT, ?\C-b
501 | @input_form.form_driver Ncurses::Form::REQ_PREV_CHAR
502 | when Ncurses::KEY_RIGHT, ?\C-f
503 | @input_form.form_driver Ncurses::Form::REQ_NEXT_CHAR
504 | when ?\C-a
505 | @input_form.form_driver Ncurses::Form::REQ_BEG_LINE
506 | when ?\C-e
507 | @input_form.form_driver Ncurses::Form::REQ_END_LINE
508 | when ?\C-k
509 | @input_form.form_driver Ncurses::Form::REQ_CLR_EOL
510 | when ?\C-u
511 | @input_form.form_driver Ncurses::Form::REQ_BEG_LINE
512 | @clipboard = @input_field.field_buffer(0)
513 | @input_form.form_driver Ncurses::Form::REQ_CLR_FIELD
514 | when ?\C-y
515 | unless @clipboard.empty?
516 | cursor_position = Ncurses.getcurx(@windows[:input])
517 |
518 | text = @input_field.field_buffer(0).insert(cursor_position - 9, @clipboard)
519 | @input_field.set_field_buffer(0, text)
520 |
521 | @windows[:text].addstr "#{cursor_position}\n #{text}"
522 | @windows[:text].refresh
523 | end
524 | when ?\C-c
525 | quit
526 | when ?\C-w
527 | @input_form.form_driver Ncurses::Form::REQ_PREV_CHAR
528 | @input_form.form_driver Ncurses::Form::REQ_DEL_WORD
529 | else
530 | @input_form.form_driver c
531 | end
532 | @windows[:input].refresh
533 | end
534 |
535 | def show_message(messages, time = Time.now.localtime)
536 | messages.split("\n").each do |message|
537 | @lastlog << [message.dup, time]
538 | @lastlog.shift if @lastlog.size > 250
539 | display_text message, time
540 | end
541 | end
542 |
543 | def display_text(message, time = Time.now.localtime)
544 | message = message.dup
545 | @windows[:text].addstr "#{time.strftime('%H:%M')} "
546 |
547 | if message.match /^\*/
548 | @windows[:text].attrset(Ncurses.COLOR_PAIR(3))
549 | end
550 |
551 | if message.match /^\* \[ERROR\]/
552 | @windows[:text].attrset(Ncurses.COLOR_PAIR(6))
553 | elsif message.match /^\[/
554 | channel_info = message.split(']').first.sub /\[/, ''
555 | message.sub! "[#{channel_info}]", ''
556 |
557 | @windows[:text].attrset(Ncurses.COLOR_PAIR(5))
558 | @windows[:text].addstr "["
559 | @windows[:text].attrset(Ncurses.COLOR_PAIR(4))
560 | @windows[:text].addstr "#{channel_info}"
561 | @windows[:text].attrset(Ncurses.COLOR_PAIR(5))
562 | @windows[:text].addstr "] "
563 |
564 | name = message.split('>').first.sub(/, '').strip
565 | message.sub!("<#{name}> ", '')
566 | @windows[:text].attrset(Ncurses.COLOR_PAIR(5))
567 | @windows[:text].addstr '<'
568 | @windows[:text].attrset(Ncurses.COLOR_PAIR(0))
569 | @windows[:text].addstr "#{name}"
570 | @windows[:text].attrset(Ncurses.COLOR_PAIR(5))
571 | @windows[:text].addstr '>'
572 | @windows[:text].attrset(Ncurses.COLOR_PAIR(0))
573 | end
574 |
575 | @windows[:text].addstr "#{message}\n"
576 | @windows[:text].refresh
577 | @windows[:input].refresh
578 |
579 | display_time
580 |
581 | if message.match /^\*/
582 | @windows[:text].attrset(Ncurses.COLOR_PAIR(0))
583 | end
584 | end
585 |
586 | def quit
587 | Ncurses.endwin
588 | exit
589 | end
590 |
591 | def help_text
592 | <<-TEXT
593 | *** JsChat Help ***
594 | Commands start with a forward slash. Parameters in square brackets are optional.
595 | /name new_name - Change your name. Alias: /nick
596 | /names [room name] - List the people in a room.
597 | /join #room - Join a room. Alias: /j
598 | /switch #room - Speak in a different room. Alias: /s
599 | /part #room - Leave a room. Alias: /p
600 | /message person - Send a private message. Alias: /m
601 | /lastlog - Display the last 100 messages for a room.
602 | /quit - Quit JsChat
603 | *** End Help ***
604 |
605 | TEXT
606 | end
607 |
608 | def manage_commands(line)
609 | operand = strip_command line
610 | case line
611 | when %r{^/switch}, %r{^/s}
612 | @connection.switch_room operand
613 | when %r{^/names}
614 | @connection.send_names operand
615 | when %r{^/name}, %r{^/nick}
616 | @connection.send_change_name operand
617 | when %r{^/quit}
618 | quit
619 | when %r{^/join}, %r{^/j}
620 | @connection.send_join operand
621 | when %r{^/part}, %r{^/p}
622 | @connection.send_part operand
623 | when %r{^/lastlog}
624 | @connection.send_lastlog
625 | when %r{^/clear}
626 | @windows[:text].clear
627 | @windows[:text].refresh
628 | display_time
629 | when %r{^/message}, %r{^/m}
630 | if operand and operand.size > 0
631 | message = operand.match(/([^ ]*)\s+(.*)/)
632 | if message
633 | @connection.send_private_message message[1], message[2]
634 | end
635 | end
636 | when %r{^/help}
637 | help_text.split("\n").each do |message|
638 | display_text message
639 | end
640 | when %r{^/}
641 | display_text '* Command not found. Try using /help'
642 | else
643 | @connection.send_message(line)
644 | end
645 | end
646 |
647 | def strip_command(line)
648 | matches = line.match(%r{/[a-zA-Z][^ ]*(.*)})
649 | if matches
650 | matches[1].strip
651 | else
652 | line
653 | end
654 | end
655 |
656 | def connection=(connection)
657 | @connection = connection
658 | @tab_completion = JsClient::TabComplete.new
659 | end
660 | end
661 |
662 | include EM::Protocols::LineText2
663 |
664 | def receive_line(data)
665 | data.split("\n").each do |line|
666 | json = JSON.parse(line.strip)
667 | # Execute the json
668 | protocol_response = @protocol.process_message json
669 | if protocol_response
670 | if protocol_response.kind_of? Array
671 | protocol_response.each do |response|
672 | @keyboard.show_message response.message, response.time
673 | end
674 | else
675 | @keyboard.show_message protocol_response.message, protocol_response.time
676 | end
677 | elsif json.has_key? 'notice'
678 | @keyboard.show_message "* #{json['notice']}"
679 | else
680 | @keyboard.show_message "* [SERVER] #{line}"
681 | end
682 | end
683 | rescue Exception => exception
684 | @keyboard.show_message "* [CLIENT ERROR] #{exception}"
685 | end
686 |
687 | def send_join(room)
688 | @current_room = room
689 | @keyboard.room_name = room
690 | @keyboard.display_room_name
691 | send_data({ 'join' => room }.to_json + "\n")
692 | end
693 |
694 | def switch_room(room)
695 | @current_room = room
696 | @keyboard.room_name = room
697 | @keyboard.display_room_name
698 | @keyboard.show_message "* Switched room to: #{room}"
699 | end
700 |
701 | def send_part(room = nil)
702 | room = @current_room if room.nil?
703 | send_data({ 'part' => room }.to_json + "\n")
704 | end
705 |
706 | def send_names(room = nil)
707 | room = @current_room if room.nil? or room.strip.empty?
708 | send_data({ 'names' => room }.to_json + "\n")
709 | end
710 |
711 | def send_lastlog(room = nil)
712 | room = @current_room if room.nil? or room.strip.empty?
713 | send_data({ 'lastlog' => room }.to_json + "\n")
714 | end
715 |
716 | def send_message(line)
717 | send_data({ 'to' => @current_room, 'send' => line }.to_json + "\n")
718 | end
719 |
720 | def send_private_message(user, message)
721 | send_data({ 'to' => user, 'send' => message }.to_json + "\n")
722 | end
723 |
724 | def send_identify(username)
725 | send_data({ 'identify' => username }.to_json + "\n")
726 | end
727 |
728 | def send_change_name(username)
729 | if @protocol.identified?
730 | send_data({ 'change' => 'user', 'user' => { 'name' => username } }.to_json + "\n")
731 | else
732 | send_identify username
733 | end
734 | end
735 |
736 | def unbind
737 | Ncurses.endwin
738 | Ncurses.clear
739 | puts "Disconnected from server"
740 | exit
741 | end
742 |
743 | def post_init
744 | # When connected
745 | @protocol = JsChat::Protocol.new self
746 | send_identify ClientConfig[:name]
747 | end
748 | end
749 |
750 | EM.run do
751 | puts "Connecting to: #{ClientConfig[:ip]}"
752 | connection = EM.connect ClientConfig[:ip], ClientConfig[:port], JsClient
753 |
754 | EM.open_keyboard(JsClient::KeyboardInput) do |keyboard|
755 | keyboard.connection = connection
756 | keyboard.setup_screen
757 | connection.keyboard = keyboard
758 | end
759 | end
760 |
--------------------------------------------------------------------------------
/lib/jschat/errors.rb:
--------------------------------------------------------------------------------
1 | module JsChat
2 | class Error < RuntimeError
3 | def initialize(code_key, message)
4 | @message = message
5 | @code = JsChat::Errors::Codes.invert[code_key]
6 | end
7 |
8 | # Note: This shouldn't really include 'display' directives
9 | def to_json(*a)
10 | { 'display' => 'error', 'error' => { 'message' => @message, 'code' => @code } }.to_json(*a)
11 | end
12 | end
13 |
14 | module Errors
15 | class InvalidName < JsChat::Error ; end
16 | class MessageTooLong < JsChat::Error ; end
17 | class InvalidCookie < JsChat::Error ; end
18 |
19 | Codes = {
20 | # 1xx: User errors
21 | 100 => :name_taken,
22 | 101 => :invalid_name,
23 | 104 => :not_online,
24 | 105 => :identity_required,
25 | 106 => :already_identified,
26 | 107 => :invalid_cookie,
27 | # 2xx: Room errors
28 | 200 => :already_joined,
29 | 201 => :invalid_room,
30 | 202 => :not_in_room,
31 | 204 => :room_not_available,
32 | # 3xx: Message errors
33 | 300 => :to_required,
34 | 301 => :message_too_long,
35 | # 5xx: Other errors
36 | 500 => :invalid_request,
37 | 501 => :flooding,
38 | 502 => :ping_out
39 | }
40 | end
41 | end
42 |
--------------------------------------------------------------------------------
/lib/jschat/flood_protection.rb:
--------------------------------------------------------------------------------
1 | module JsChat
2 | module Errors
3 | class Flooding < JsChat::Error ; end
4 | class StillFlooding < Exception ; end
5 | end
6 |
7 | module FloodProtection
8 | def seen!
9 | @activity_log ||= []
10 | @activity_log << Time.now.utc
11 | @activity_log.shift if @activity_log.size > 50
12 | remove_old_activity_logs
13 | detect_flooding
14 |
15 | if flooding?
16 | if @still_flooding
17 | raise JsChat::Errors::StillFlooding
18 | else
19 | @still_flooding = true
20 | raise JsChat::Errors::Flooding.new(:flooding, 'Please wait a few seconds before responding')
21 | end
22 | elsif @still_flooding
23 | @still_flooding = false
24 | end
25 | end
26 |
27 | def detect_flooding
28 | @flooding = @activity_log.size > 10 and @activity_log.sort.inject { |i, sum| sum.to_i - i.to_i } - @activity_log.first.to_i < 0.5
29 | end
30 |
31 | def flooding?
32 | @flooding
33 | end
34 |
35 | def remove_old_activity_logs
36 | @activity_log.delete_if { |l| l + 5 < Time.now.utc }
37 | end
38 | end
39 | end
40 |
--------------------------------------------------------------------------------
/lib/jschat/http/config.ru:
--------------------------------------------------------------------------------
1 | require 'rubygems'
2 | require 'sinatra'
3 |
4 | set :environment, :production
5 |
6 | # You could log like this:
7 | # log = File.new(File.join(File.dirname(__FILE__), 'sinatra.log'), 'a')
8 | # $stdout.reopen(log)
9 | # $stderr.reopen(log)
10 |
11 | require 'jschat.rb'
12 | run Sinatra::Application
13 |
--------------------------------------------------------------------------------
/lib/jschat/http/helpers/url_for.rb:
--------------------------------------------------------------------------------
1 | # From http://github.com/emk/sinatra-url-for/blob/master/lib/sinatra/url_for.rb
2 | module Sinatra
3 | module UrlForHelper
4 | # Construct a link to +url_fragment+, which should be given relative to
5 | # the base of this Sinatra app. The mode should be either
6 | # :path_only
, which will generate an absolute path within
7 | # the current domain (the default), or :full
, which will
8 | # include the site name and port number. (The latter is typically
9 | # necessary for links in RSS feeds.) Example usage:
10 | #
11 | # url_for "/" # Returns "/myapp/"
12 | # url_for "/foo" # Returns "/myapp/foo"
13 | # url_for "/foo", :full # Returns "http://example.com/myapp/foo"
14 | #--
15 | # See README.rdoc for a list of some of the people who helped me clean
16 | # up earlier versions of this code.
17 | def url_for url_fragment, mode=:path_only
18 | case mode
19 | when :path_only
20 | base = request.script_name
21 | when :full
22 | scheme = request.scheme
23 | if (scheme == 'http' && request.port == 80 ||
24 | scheme == 'https' && request.port == 443)
25 | port = ""
26 | else
27 | port = ":#{request.port}"
28 | end
29 | base = "#{scheme}://#{request.host}#{port}#{request.script_name}"
30 | else
31 | raise TypeError, "Unknown url_for mode #{mode}"
32 | end
33 | "#{base}#{url_fragment}"
34 | end
35 | end
36 |
37 | helpers UrlForHelper
38 | end
39 |
--------------------------------------------------------------------------------
/lib/jschat/http/jschat.rb:
--------------------------------------------------------------------------------
1 | require 'rubygems'
2 | require 'sinatra'
3 | require 'sha1'
4 | gem 'json', '>= 1.1.9'
5 | require 'json'
6 | require 'jschat/init'
7 | require 'jschat/http/helpers/url_for'
8 |
9 | set :public, File.join(File.dirname(__FILE__), 'public')
10 | set :views, File.join(File.dirname(__FILE__), 'views')
11 | set :sessions, true
12 |
13 | module JsChat::Auth
14 | end
15 |
16 | module JsChat::Auth::Twitter
17 | def self.template
18 | :twitter
19 | end
20 |
21 | def self.load
22 | require 'twitter_oauth'
23 | @loaded = true
24 | rescue LoadError
25 | puts 'Error: twitter_oauth gem not found'
26 | @loaded = false
27 | end
28 |
29 | def self.loaded?
30 | @loaded
31 | end
32 | end
33 |
34 | module JsChat
35 | class ConnectionError < Exception ; end
36 |
37 | def self.configure_authenticators
38 | if ServerConfig['twitter']
39 | JsChat::Auth::Twitter.load
40 | end
41 | end
42 |
43 | def self.init
44 | configure_authenticators
45 | JsChat.init_storage
46 | end
47 | end
48 |
49 | JsChat.init
50 |
51 | before do
52 | if JsChat::Auth::Twitter.loaded?
53 | @twitter = TwitterOAuth::Client.new(
54 | :consumer_key => ServerConfig['twitter']['key'],
55 | :consumer_secret => ServerConfig['twitter']['secret'],
56 | :token => session[:access_token],
57 | :secret => session[:secret_token]
58 | )
59 |
60 | if twitter_user?
61 | load_twitter_user_and_set_bridge_id
62 |
63 | unless valid_twitter_client_id?
64 | clear_cookies
65 | end
66 | end
67 | end
68 | end
69 |
70 | # todo: can this be async and allow the server to have multiple threads?
71 | class JsChat::Bridge
72 | attr_reader :cookie, :identification_error, :last_error
73 |
74 | def initialize(cookie = nil)
75 | @cookie = cookie
76 | end
77 |
78 | def cookie_set?
79 | !(@cookie.nil? or @cookie.empty?)
80 | end
81 |
82 | def connect
83 | response = send_json({ :protocol => 'stateless' })
84 | @cookie = response['cookie']
85 | end
86 |
87 | def identify(name, ip, session_length = nil)
88 | response = send_json({ :identify => name, :ip => ip, :session_length => session_length })
89 | if response['display'] == 'error'
90 | @identification_error = response
91 | false
92 | else
93 | true
94 | end
95 | end
96 |
97 | def rooms
98 | send_json({ :list => 'rooms' })
99 | end
100 |
101 | def lastlog(room)
102 | response = send_json({ :lastlog => room })
103 | response['messages']
104 | end
105 |
106 | def search(phrase, room)
107 | response = send_json({ :search => phrase, :room => room })
108 | response['messages']
109 | end
110 |
111 | def recent_messages(room)
112 | send_json({ 'since' => room })['messages']
113 | end
114 |
115 | def room_update_times
116 | send_json({ 'times' => 'all' })
117 | end
118 |
119 | def join(room)
120 | send_json({ :join => room }, false)
121 | end
122 |
123 | def part(room)
124 | send_json({ :part => room })
125 | end
126 |
127 | def send_message(message, to)
128 | send_json({ :send => message, :to => to }, false)
129 | end
130 |
131 | def active?
132 | return false unless cookie_set?
133 | response = ping
134 | if response.nil? or response['display'] == 'error'
135 | @last_error = response
136 | false
137 | else
138 | true
139 | end
140 | end
141 |
142 | def ping
143 | send_json({ 'ping' => Time.now.utc })
144 | end
145 |
146 | def change(change_type, data)
147 | send_json({ 'change' => change_type, change_type => data })
148 | end
149 |
150 | def names(room)
151 | send_json({'names' => room})
152 | end
153 |
154 | def send_quit(name)
155 | send_json({'quit' => name })
156 | end
157 |
158 | def send_json(h, get_results = true)
159 | response = nil
160 | h[:cookie] = @cookie if cookie_set?
161 | c = TCPSocket.open(ServerConfig['ip'], ServerConfig['port'])
162 | c.send(h.to_json + "\n", 0)
163 | if get_results
164 | response = c.gets
165 | response = JSON.parse(response)
166 | end
167 | ensure
168 | c.close
169 | response
170 | end
171 | end
172 |
173 | helpers do
174 | include Rack::Utils
175 | alias_method :h, :escape_html
176 |
177 | def escape_json(string)
178 | string.to_s.gsub("&", "&").
179 | gsub("<", "<").
180 | gsub(">", ">")
181 | end
182 |
183 | def detected_layout
184 | if iphone_user_agent?
185 | :iphone
186 | elsif ipad_user_agent?
187 | :ipad
188 | else
189 | :layout
190 | end
191 | end
192 |
193 | def detected_message_form
194 | if iphone_user_agent?
195 | :iphone_message_form
196 | elsif ipad_user_agent?
197 | :iphone_message_form
198 | else
199 | :message_form
200 | end
201 | end
202 |
203 | def iphone_user_agent?
204 | request.env["HTTP_USER_AGENT"] && request.env["HTTP_USER_AGENT"][/(\(iPhone)/]
205 | end
206 |
207 | def ipad_user_agent?
208 | request.env["HTTP_USER_AGENT"] && request.env["HTTP_USER_AGENT"][/(\(iPad)/]
209 | end
210 |
211 | def load_bridge
212 | @bridge = JsChat::Bridge.new session[:jschat_id]
213 | end
214 |
215 | def load_and_connect
216 | @bridge = JsChat::Bridge.new session[:jschat_id]
217 | @bridge.connect
218 | session[:jschat_id] = @bridge.cookie
219 | end
220 |
221 | def cookie_expiration
222 | Time.now.utc + 94608000
223 | end
224 |
225 | def save_last_room(room)
226 | response.set_cookie 'last-room', { :value => room, :path => '/', :expires => cookie_expiration }
227 | end
228 |
229 | def last_room
230 | request.cookies['last-room']
231 | end
232 |
233 | def save_nickname(name)
234 | response.set_cookie 'jschat-name', { :value => name, :path => '/', :expires => cookie_expiration }
235 | end
236 |
237 | def messages_js(messages)
238 | messages ||= []
239 | escape_json messages.to_json
240 | end
241 |
242 | def remove_my_messages(messages)
243 | return if messages.nil?
244 | messages.delete_if { |message| message['message'] and message['message']['user'] == nickname }
245 | end
246 |
247 | def clear_cookies
248 | response.set_cookie 'last-room', { :value => nil, :path => '/' }
249 | session[:jschat_id] = nil
250 | session[:request_token] = nil
251 | session[:request_token_secret] = nil
252 | session[:access_token] = nil
253 | session[:secret_token] = nil
254 | session[:twitter_name] = nil
255 | end
256 |
257 | def twitter_user?
258 | session[:access_token] && session[:secret_token]
259 | end
260 |
261 | def save_twitter_user(options = {})
262 | options = load_twitter_user.merge(options).merge({
263 | 'twitter_name' => session[:twitter_name],
264 | 'access_token' => session[:access_token],
265 | 'secret_token' => session[:secret_token],
266 | 'client_id' => session[:client_id]
267 | })
268 | JsChat::Storage.driver.save_user(options)
269 | end
270 |
271 | def save_twitter_user_rooms
272 | if twitter_user?
273 | rooms = @bridge.rooms
274 | save_twitter_user('rooms' => rooms)
275 | end
276 | end
277 |
278 | def delete_twitter_user
279 | JsChat::Storage.driver.delete_user({ 'twitter_name' => session[:twitter_name] })
280 | end
281 |
282 | def load_twitter_user
283 | JsChat::Storage.driver.find_user({ 'twitter_name' => session[:twitter_name] }) || {}
284 | end
285 |
286 | def valid_twitter_client_id?
287 | session[:client_id] == load_twitter_user['client_id']
288 | end
289 |
290 | def load_twitter_user_and_set_bridge_id
291 | user = load_twitter_user
292 | if user['jschat_id'] and user['jschat_id'].size > 0
293 | session[:jschat_id] = user['jschat_id']
294 | end
295 | end
296 |
297 | def nickname
298 | request.cookies['jschat-name']
299 | end
300 |
301 | def unique_token
302 | chars = ("a".."z").to_a + ("1".."9").to_a
303 | Array.new(8, '').collect { chars[rand(chars.size)] }.join
304 | end
305 | end
306 |
307 | # Identify
308 | get '/' do
309 | load_bridge
310 |
311 | if @bridge.active? and last_room
312 | redirect "/chat/#{last_room}"
313 | else
314 | clear_cookies
315 | erb :index, :layout => detected_layout
316 | end
317 | end
318 |
319 | post '/identify' do
320 | load_and_connect
321 | save_last_room params['room']
322 | save_nickname params['name']
323 | if @bridge.identify params['name'], request.ip
324 | { 'action' => 'redirect', 'to' => "/chat/#{params['room']}" }.to_json
325 | else
326 | @bridge.identification_error.to_json
327 | end
328 | end
329 |
330 | post '/change-name' do
331 | load_bridge
332 | result = @bridge.change('user', { 'name' => params['name'] })
333 | if result['notice']
334 | save_nickname params['name']
335 | save_twitter_user({ :name => params['name'] }) if twitter_user?
336 | end
337 | [result].to_json
338 | end
339 |
340 | get '/messages' do
341 | load_bridge
342 | if @bridge.active?
343 | save_last_room params['room']
344 | messages_js remove_my_messages(@bridge.recent_messages(params['room']))
345 | else
346 | if @bridge.last_error and @bridge.last_error['error']['code'] == 107
347 | error 500, [@bridge.last_error].to_json
348 | elsif @bridge.last_error
349 | [@bridge.last_error].to_json
350 | else
351 | error 500, 'Unknown error'
352 | end
353 | end
354 | end
355 |
356 | get '/room_update_times' do
357 | load_bridge
358 | if @bridge.active?
359 | messages_js @bridge.room_update_times
360 | end
361 | end
362 |
363 | get '/names' do
364 | load_bridge
365 | save_last_room params['room']
366 | [@bridge.names(params['room'])].to_json
367 | end
368 |
369 | get '/lastlog' do
370 | load_bridge
371 | if @bridge.active?
372 | save_last_room params['room']
373 | messages_js @bridge.lastlog(params['room'])
374 | end
375 | end
376 |
377 | get '/search' do
378 | load_bridge
379 | if @bridge.active?
380 | messages_js @bridge.search(params['q'], params['room'])
381 | end
382 | end
383 |
384 | post '/join' do
385 | load_bridge
386 | @bridge.join params['room']
387 | save_last_room params['room']
388 | save_twitter_user_rooms
389 | 'OK'
390 | end
391 |
392 | get '/part' do
393 | load_bridge
394 | @bridge.part params['room']
395 | save_twitter_user_rooms
396 | if @bridge.last_error
397 | error 500, [@bridge.last_error].to_json
398 | else
399 | 'OK'
400 | end
401 | end
402 |
403 | get '/chat/' do
404 | load_bridge
405 | if @bridge and @bridge.active?
406 | erb detected_message_form, :layout => detected_layout
407 | else
408 | erb :index, :layout => detected_layout
409 | end
410 | end
411 |
412 | post '/message' do
413 | load_bridge
414 | save_last_room params['to']
415 | @bridge.send_message params['message'], params['to']
416 | 'OK'
417 | end
418 |
419 | get '/user/name' do
420 | load_bridge
421 | nickname
422 | end
423 |
424 | get '/ping' do
425 | load_bridge
426 | @bridge.ping.to_json
427 | end
428 |
429 | get '/quit' do
430 | load_bridge
431 | @bridge.send_quit nickname
432 | delete_twitter_user if twitter_user?
433 | clear_cookies
434 | redirect '/'
435 | end
436 |
437 | get '/rooms' do
438 | load_bridge
439 | rooms = @bridge.rooms
440 | save_twitter_user('rooms' => rooms) if twitter_user?
441 | rooms.to_json
442 | end
443 |
444 | get '/twitter' do
445 | request_token = @twitter.request_token(
446 | :oauth_callback => url_for('/twitter_auth', :full)
447 | )
448 | session[:request_token] = request_token.token
449 | session[:request_token_secret] = request_token.secret
450 | redirect request_token.authorize_url.gsub('authorize', 'authenticate')
451 | end
452 |
453 | get '/twitter_auth' do
454 | # Exchange the request token for an access token.
455 | begin
456 | @access_token = @twitter.authorize(
457 | session[:request_token],
458 | session[:request_token_secret],
459 | :oauth_verifier => params[:oauth_verifier]
460 | )
461 | rescue OAuth::Unauthorized => exception
462 | puts exception
463 | halt "Unable to login with Twitter: #{exception.class}"
464 | end
465 |
466 | if @twitter.authorized?
467 | session[:access_token] = @access_token.token
468 | session[:secret_token] = @access_token.secret
469 | session[:twitter_name] = @twitter.info['screen_name']
470 |
471 | # TODO: Make this cope if someone has the same name
472 | room = '#jschat'
473 | user = load_twitter_user
474 | name = @twitter.info['screen_name']
475 |
476 | if user['name'] and user['name'].length > 0
477 | name = user['name']
478 | end
479 |
480 | session[:jschat_id] = user['jschat_id'] if user['jschat_id'] and !user['jschat_id'].empty?
481 | session[:client_id] = unique_token
482 | save_nickname name
483 | save_twitter_user('twitter_name' => @twitter.info['screen_name'],
484 | 'jschat_id' => session[:jschat_id],
485 | 'name' => name)
486 | user = load_twitter_user
487 | load_bridge
488 |
489 | if @bridge.active?
490 | if user['rooms'] and user['rooms'].any?
491 | room = user['rooms'].first
492 | end
493 | else
494 | # Reconnect
495 | session[:jschat_id] = nil
496 | load_and_connect
497 | save_twitter_user('jschat_id' => session[:jschat_id])
498 | @bridge.identify(@twitter.info['screen_name'], request.ip, (((60 * 60) * 24) * 7))
499 | if user['rooms']
500 | user['rooms'].each do |room|
501 | @bridge.join room
502 | end
503 | room = user['rooms'].first
504 | else
505 | save_last_room '#jschat'
506 | @bridge.join '#jschat'
507 | end
508 | end
509 |
510 | redirect "/chat/#{room}"
511 | else
512 | redirect '/'
513 | end
514 | end
515 |
516 | # TODO: This doesn't seem to work with twitter oauth right now
517 | post '/tweet' do
518 | if twitter_user? and @twitter.authorized?
519 | @twitter.update(params['tweet'])
520 | else
521 | error 500, 'You are not signed in with Twitter'
522 | end
523 | end
524 |
--------------------------------------------------------------------------------
/lib/jschat/http/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/favicon.ico
--------------------------------------------------------------------------------
/lib/jschat/http/public/iOS-114.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/iOS-114.png
--------------------------------------------------------------------------------
/lib/jschat/http/public/iOS-57.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/iOS-57.png
--------------------------------------------------------------------------------
/lib/jschat/http/public/iOS-72.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/iOS-72.png
--------------------------------------------------------------------------------
/lib/jschat/http/public/images/emoticons/angry.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/angry.gif
--------------------------------------------------------------------------------
/lib/jschat/http/public/images/emoticons/arr.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/arr.gif
--------------------------------------------------------------------------------
/lib/jschat/http/public/images/emoticons/blink.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/blink.gif
--------------------------------------------------------------------------------
/lib/jschat/http/public/images/emoticons/blush.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/blush.gif
--------------------------------------------------------------------------------
/lib/jschat/http/public/images/emoticons/brucelee.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/brucelee.gif
--------------------------------------------------------------------------------
/lib/jschat/http/public/images/emoticons/btw.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/btw.gif
--------------------------------------------------------------------------------
/lib/jschat/http/public/images/emoticons/chuckle.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/chuckle.gif
--------------------------------------------------------------------------------
/lib/jschat/http/public/images/emoticons/clap.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/clap.gif
--------------------------------------------------------------------------------
/lib/jschat/http/public/images/emoticons/cool.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/cool.gif
--------------------------------------------------------------------------------
/lib/jschat/http/public/images/emoticons/drool.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/drool.gif
--------------------------------------------------------------------------------
/lib/jschat/http/public/images/emoticons/drunk.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/drunk.gif
--------------------------------------------------------------------------------
/lib/jschat/http/public/images/emoticons/dry.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/dry.gif
--------------------------------------------------------------------------------
/lib/jschat/http/public/images/emoticons/eek.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/eek.gif
--------------------------------------------------------------------------------
/lib/jschat/http/public/images/emoticons/flex.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/flex.gif
--------------------------------------------------------------------------------
/lib/jschat/http/public/images/emoticons/happy.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/happy.gif
--------------------------------------------------------------------------------
/lib/jschat/http/public/images/emoticons/holmes.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/holmes.gif
--------------------------------------------------------------------------------
/lib/jschat/http/public/images/emoticons/huh.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/huh.gif
--------------------------------------------------------------------------------
/lib/jschat/http/public/images/emoticons/laugh.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/laugh.gif
--------------------------------------------------------------------------------
/lib/jschat/http/public/images/emoticons/lol.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/lol.gif
--------------------------------------------------------------------------------
/lib/jschat/http/public/images/emoticons/mad.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/mad.gif
--------------------------------------------------------------------------------
/lib/jschat/http/public/images/emoticons/mellow.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/mellow.gif
--------------------------------------------------------------------------------
/lib/jschat/http/public/images/emoticons/noclue.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/noclue.gif
--------------------------------------------------------------------------------
/lib/jschat/http/public/images/emoticons/oh.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/oh.gif
--------------------------------------------------------------------------------
/lib/jschat/http/public/images/emoticons/ohmy.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/ohmy.gif
--------------------------------------------------------------------------------
/lib/jschat/http/public/images/emoticons/panic.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/panic.gif
--------------------------------------------------------------------------------
/lib/jschat/http/public/images/emoticons/ph34r.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/ph34r.gif
--------------------------------------------------------------------------------
/lib/jschat/http/public/images/emoticons/pimp.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/pimp.gif
--------------------------------------------------------------------------------
/lib/jschat/http/public/images/emoticons/punch.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/punch.gif
--------------------------------------------------------------------------------
/lib/jschat/http/public/images/emoticons/realmad.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/realmad.gif
--------------------------------------------------------------------------------
/lib/jschat/http/public/images/emoticons/rock.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/rock.gif
--------------------------------------------------------------------------------
/lib/jschat/http/public/images/emoticons/rofl.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/rofl.gif
--------------------------------------------------------------------------------
/lib/jschat/http/public/images/emoticons/rolleyes.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/rolleyes.gif
--------------------------------------------------------------------------------
/lib/jschat/http/public/images/emoticons/sad.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/sad.gif
--------------------------------------------------------------------------------
/lib/jschat/http/public/images/emoticons/scratch.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/scratch.gif
--------------------------------------------------------------------------------
/lib/jschat/http/public/images/emoticons/shifty.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/shifty.gif
--------------------------------------------------------------------------------
/lib/jschat/http/public/images/emoticons/shock.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/shock.gif
--------------------------------------------------------------------------------
/lib/jschat/http/public/images/emoticons/shrug.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/shrug.gif
--------------------------------------------------------------------------------
/lib/jschat/http/public/images/emoticons/sleep.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/sleep.gif
--------------------------------------------------------------------------------
/lib/jschat/http/public/images/emoticons/sleeping.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/sleeping.gif
--------------------------------------------------------------------------------
/lib/jschat/http/public/images/emoticons/smile.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/smile.gif
--------------------------------------------------------------------------------
/lib/jschat/http/public/images/emoticons/suicide.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/suicide.gif
--------------------------------------------------------------------------------
/lib/jschat/http/public/images/emoticons/sweat.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/sweat.gif
--------------------------------------------------------------------------------
/lib/jschat/http/public/images/emoticons/thumbs.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/thumbs.gif
--------------------------------------------------------------------------------
/lib/jschat/http/public/images/emoticons/tongue.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/tongue.gif
--------------------------------------------------------------------------------
/lib/jschat/http/public/images/emoticons/unsure.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/unsure.gif
--------------------------------------------------------------------------------
/lib/jschat/http/public/images/emoticons/w00t.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/w00t.gif
--------------------------------------------------------------------------------
/lib/jschat/http/public/images/emoticons/wacko.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/wacko.gif
--------------------------------------------------------------------------------
/lib/jschat/http/public/images/emoticons/whistling.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/whistling.gif
--------------------------------------------------------------------------------
/lib/jschat/http/public/images/emoticons/wink.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/wink.gif
--------------------------------------------------------------------------------
/lib/jschat/http/public/images/emoticons/worship.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/worship.gif
--------------------------------------------------------------------------------
/lib/jschat/http/public/images/emoticons/yucky.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/yucky.gif
--------------------------------------------------------------------------------
/lib/jschat/http/public/images/jschat.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/jschat.gif
--------------------------------------------------------------------------------
/lib/jschat/http/public/images/shadow.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/shadow.png
--------------------------------------------------------------------------------
/lib/jschat/http/public/javascripts/all.js:
--------------------------------------------------------------------------------
1 | var JsChat = {};
2 |
3 | document.observe('dom:loaded', function() {
4 | JsChat.user = new User();
5 |
6 | if ($('post_message')) {
7 | var chatController = new JsChat.ChatController();
8 | }
9 |
10 | if ($('sign-on')) {
11 | if (JsChat.user.name) {
12 | $('name').value = JsChat.user.name;
13 | }
14 |
15 | if ($('room') && window.location.hash) {
16 | $('room').value = window.location.hash;
17 | }
18 |
19 | var signOnController = new JsChat.SignOnController();
20 | }
21 | });
22 | var History = Class.create({
23 | initialize: function() {
24 | this.messages = [];
25 | this.index = 0;
26 | this.limit = 100;
27 | },
28 |
29 | prev: function() {
30 | this.index = this.index <= 0 ? this.messages.length - 1 : this.index - 1;
31 | },
32 |
33 | next: function() {
34 | this.index = this.index >= this.messages.length - 1 ? 0 : this.index + 1;
35 | },
36 |
37 | reset: function() {
38 | this.index = this.messages.length;
39 | },
40 |
41 | value: function() {
42 | if (this.messages.length == 0) return '';
43 | return this.messages[this.index];
44 | },
45 |
46 | add: function(value) {
47 | if (!value || value.length == 0) return;
48 |
49 | this.messages.push(value);
50 | if (this.messages.length > this.limit) {
51 | this.messages = this.messages.slice(-this.limit);
52 | }
53 | this.index = this.messages.length;
54 | },
55 |
56 | atTop: function() {
57 | return this.index === this.messages.length;
58 | }
59 | });
60 |
61 | var TabCompletion = Class.create({
62 | initialize: function(element) {
63 | this.element = $(element);
64 | this.matches = [];
65 | this.match_offset = 0;
66 | this.cycling = false;
67 | this.has_focus = true;
68 | this.history = new History();
69 |
70 | document.observe('keydown', this.keyboardEvents.bindAsEventListener(this));
71 | this.element.observe('focus', this.onFocus.bindAsEventListener(this));
72 | this.element.observe('blur', this.onBlur.bindAsEventListener(this));
73 | this.element.observe('click', this.onFocus.bindAsEventListener(this));
74 | },
75 |
76 | onBlur: function() {
77 | this.has_focus = false;
78 | this.reset();
79 | },
80 |
81 | onFocus: function() {
82 | this.has_focus = true;
83 | this.reset();
84 | },
85 |
86 | tabSearch: function(input) {
87 | var names = $$('#names li').collect(function(element) { return element.innerHTML }).sort();
88 | return names.findAll(function(name) { return name.toLowerCase().match(input.toLowerCase()) });
89 | },
90 |
91 | textToLeft: function() {
92 | var text = this.element.value;
93 | var caret_position = FormHelpers.getCaretPosition(this.element);
94 | if (caret_position < text.length) {
95 | text = text.slice(0, caret_position);
96 | }
97 |
98 | text = text.split(' ').last();
99 | return text;
100 | },
101 |
102 | elementFocused: function(e) {
103 | if (typeof document.activeElement == 'undefined') {
104 | return this.has_focus;
105 | } else {
106 | return document.activeElement == this.element;
107 | }
108 | },
109 |
110 | keyboardEvents: function(e) {
111 | if (this.elementFocused()) {
112 | switch (e.keyCode) {
113 | case Event.KEY_TAB:
114 | var caret_position = FormHelpers.getCaretPosition(this.element);
115 |
116 | if (this.element.value.length > 0) {
117 | var search_text = '';
118 | var search_result = '';
119 | var replace_inline = false;
120 | var editedText = this.element.value.match(/[^a-z0-9]/i);
121 |
122 | if (this.cycling) {
123 | if (this.element.value == '#{last_result}: '.interpolate({ last_result: this.last_result })) {
124 | editedText = false;
125 | } else {
126 | replace_inline = true;
127 | }
128 | search_text = this.last_result;
129 | } else if (editedText && this.matches.length == 0) {
130 | search_text = this.textToLeft();
131 | replace_inline = true;
132 | } else {
133 | search_text = this.element.value;
134 | }
135 |
136 | if (this.matches.length == 0) {
137 | this.matches = this.tabSearch(search_text);
138 | search_result = this.matches.first();
139 | this.cycling = true;
140 | } else {
141 | this.match_offset++;
142 | if (this.match_offset >= this.matches.length) {
143 | this.match_offset = 0;
144 | }
145 | search_result = this.matches[this.match_offset];
146 | }
147 |
148 | if (search_result && search_result.length > 0) {
149 | if (this.cycling && this.last_result) {
150 | search_text = this.last_result;
151 | }
152 | this.last_result = search_result;
153 |
154 | if (replace_inline) {
155 | var slice_start = caret_position - search_text.length;
156 | if (slice_start > 0) {
157 | this.element.value = this.element.value.substr(0, slice_start) + search_result + this.element.value.substr(caret_position, this.element.value.length);
158 | FormHelpers.setCaretPosition(this.element, slice_start + search_result.length);
159 | }
160 | } else if (!editedText) {
161 | this.element.value = '#{search_result}: '.interpolate({ search_result: search_result });
162 | }
163 | }
164 | }
165 |
166 | Event.stop(e);
167 | return false;
168 | break;
169 |
170 | case Event.KEY_UP:
171 | if (this.history.atTop()) {
172 | this.history.add(this.element.value);
173 | }
174 |
175 | this.history.prev();
176 | this.element.value = this.history.value();
177 | FormHelpers.setCaretPosition(this.element, this.element.value.length + 1);
178 | Event.stop(e);
179 | return false;
180 | break;
181 |
182 | case Event.KEY_DOWN:
183 | this.history.next();
184 | this.element.value = this.history.value();
185 | FormHelpers.setCaretPosition(this.element, this.element.value.length + 1);
186 | Event.stop(e);
187 | return false;
188 | break;
189 |
190 | default:
191 | this.reset();
192 | break;
193 | }
194 | }
195 | },
196 |
197 | reset: function() {
198 | this.matches = [];
199 | this.match_offset = 0;
200 | this.last_result = null;
201 | this.cycling = false;
202 | }
203 | });
204 | var UserCommands = {
205 | '/emotes': function() {
206 | var text = '';
207 | Display.add_message('Available Emotes — Prefix with a : to use', 'help');
208 | Display.add_message(EmoteHelper.legalEmotes.join(', '), 'help');
209 | },
210 |
211 | '/help': function() {
212 | var help = [];
213 | Display.add_message('JsChat Help — Type the following commands into the message field:', 'help')
214 | help.push(['/clear', 'Clears messages']);
215 | help.push(['/join #room_name', 'Joins a room']);
216 | help.push(['/part #room_name', 'Leaves a room. Leave room_name blank for the current room']);
217 | help.push(['/lastlog', 'Shows recent activity']);
218 | help.push(['/search query', 'Searches the logs for this room']);
219 | help.push(['/names', 'Refreshes the names list']);
220 | help.push(['/name new_name', 'Changes your name']);
221 | help.push(['/toggle images', 'Toggles showing of images and videos']);
222 | help.push(['/quit', 'Quit']);
223 | help.push(['/emotes', 'Shows available emotes']);
224 | $A(help).each(function(options) {
225 | var help_text = '#{command}#{text}'.interpolate({ command: options[0], text: options[1]});
226 | Display.add_message(help_text, 'help');
227 | });
228 | },
229 |
230 | '/clear': function() {
231 | $('messages').innerHTML = '';
232 | },
233 |
234 | '/lastlog': function() {
235 | this.pausePollers = true;
236 | $('messages').innerHTML = '';
237 | JsChat.Request.get('/lastlog', function(transport) {
238 | this.displayMessages(transport.responseText);
239 | $('names').innerHTML = '';
240 | this.updateNames();
241 | this.pausePollers = false;
242 | }.bind(this));
243 | },
244 |
245 | '/search\\s+(.*)': function(query) {
246 | query = query[1];
247 | this.pausePollers = true;
248 | $('messages').innerHTML = '';
249 | JsChat.Request.get('/search?q=' + query, function(transport) {
250 | Display.add_message('Search results:', 'server');
251 | this.displayMessages(transport.responseText);
252 | this.pausePollers = false;
253 | }.bind(this));
254 | },
255 |
256 | '/(name|nick)\\s+(.*)': function(name) {
257 | name = name[2];
258 | new Ajax.Request('/change-name', {
259 | method: 'post',
260 | parameters: { name: name },
261 | onSuccess: function(response) {
262 | this.displayMessages(response.responseText);
263 | JsChat.user.setName(name);
264 | this.updateNames();
265 | }.bind(this),
266 | onFailure: function() {
267 | Display.add_message("Server error: couldn't access: #{url}".interpolate({ url: url }), 'server');
268 | }
269 | });
270 | },
271 |
272 | '/names': function() {
273 | this.updateNames();
274 | },
275 |
276 | '/toggle images': function() {
277 | JsChat.user.setHideImages(!JsChat.user.hideImages);
278 | Display.add_message("Hide images set to #{hide}".interpolate({ hide: JsChat.user.hideImages }), 'server');
279 | },
280 |
281 | '/(join)\\s+(.*)': function() {
282 | var room = arguments[0][2];
283 | this.validateAndJoinRoom(room);
284 | },
285 |
286 | '/(part|leave)': function() {
287 | this.partRoom(PageHelper.currentRoom());
288 | },
289 |
290 | '/(part|leave)\\s+(.*)': function() {
291 | var room = arguments[0][2];
292 | this.partRoom(room);
293 | },
294 |
295 | '/tweet\\s+(.*)': function() {
296 | var message = arguments[0][1];
297 | this.sendTweet(message);
298 | },
299 |
300 | '/quit': function() {
301 | window.location = '/quit';
302 | }
303 | };
304 | var Display = {
305 | scrolled: false,
306 |
307 | add_message: function(text, className, time) {
308 | var time_html = '\#{time}'.interpolate({ time: TextHelper.dateText(time) });
309 | $('messages').insert({ bottom: 'JsChat is an open source chat system that uses a simple protocol based on JSON.
4 |To download the code and an irssi-like console client, visit GitHub.
5 |Read more on the JsChat Blog.
6 |JsChat will save your rooms and keep your presence online until you click Quit.
4 |This will persist even if you login on another computer.
5 | 6 | 7 | -------------------------------------------------------------------------------- /lib/jschat/init.rb: -------------------------------------------------------------------------------- 1 | module JsChat ; end 2 | 3 | require 'jschat/server_options' 4 | require 'jschat/storage/init' 5 | 6 | module JsChat 7 | STATELESS_TIMEOUT = 60 8 | LASTLOG_DEFAULT = 100 9 | 10 | def self.init_storage 11 | if JsChat::Storage::MongoDriver.available? 12 | JsChat::Storage.enabled = true 13 | JsChat::Storage.driver = JsChat::Storage::MongoDriver 14 | else 15 | JsChat::Storage.enabled = false 16 | JsChat::Storage.driver = JsChat::Storage::NullDriver 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/jschat/server.rb: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'eventmachine' 3 | gem 'json', '>= 1.1.9' 4 | require 'json' 5 | require 'time' 6 | require 'socket' 7 | 8 | # JsChat libraries 9 | require 'jschat/init' 10 | require 'jschat/errors' 11 | require 'jschat/flood_protection' 12 | 13 | module JsChat 14 | module Server 15 | def self.pid_file_name 16 | File.join(ServerConfig['tmp_files'], 'jschat.pid') 17 | end 18 | 19 | def self.write_pid_file 20 | return unless ServerConfig['use_tmp_files'] 21 | File.open(pid_file_name, 'w') { |f| f << Process.pid } 22 | end 23 | 24 | def self.rm_pid_file 25 | FileUtils.rm pid_file_name 26 | end 27 | 28 | def self.stop 29 | rm_pid_file 30 | end 31 | 32 | def self.run! 33 | write_pid_file 34 | JsChat.init_storage 35 | 36 | at_exit do 37 | stop 38 | end 39 | 40 | EM.run do 41 | EM.start_server ServerConfig['ip'], ServerConfig['port'], JsChat 42 | end 43 | end 44 | end 45 | 46 | class User 47 | include JsChat::FloodProtection 48 | 49 | attr_accessor :name, :connection, :rooms, :last_activity, 50 | :identified, :ip, :last_poll, :session_length 51 | 52 | def initialize(connection) 53 | @name = nil 54 | @connection = connection 55 | @rooms = [] 56 | @last_activity = Time.now.utc 57 | @last_poll = Time.now.utc 58 | @identified = false 59 | @ip = '' 60 | @expires = nil 61 | @session_length = nil 62 | end 63 | 64 | def session_expired? 65 | return true if @expires.nil? 66 | Time.now.utc >= @expires 67 | end 68 | 69 | def update_session_expiration 70 | return if @session_length.nil? 71 | @expires = Time.now.utc + @session_length 72 | end 73 | 74 | def to_json(*a) 75 | { 'name' => @name, 'last_activity' => @last_activity }.to_json(*a) 76 | end 77 | 78 | def name=(name) 79 | if @connection and @connection.name_taken? name 80 | raise JsChat::Errors::InvalidName.new(:name_taken, 'Name taken') 81 | elsif User.valid_name?(name) 82 | @identified = true 83 | @name = name 84 | else 85 | raise JsChat::Errors::InvalidName.new(:invalid_name, 'Invalid name') 86 | end 87 | end 88 | 89 | def self.valid_name?(name) 90 | not name.match /[^[:alnum:]._\-\[\]^C]/ and name.size > 0 91 | end 92 | 93 | def private_message(message) 94 | response = { 'display' => 'message', 'message' => message } 95 | @connection.send_response response 96 | end 97 | 98 | def change(params) 99 | # Valid options for change 100 | ['name'].each do |field| 101 | if params[field] 102 | old_value = send(field) 103 | send "#{field}=", params[field] 104 | @rooms.each do |room| 105 | response = { 'change' => 'user', 106 | 'room' => room.name, 107 | 'user' => { field => { old_value => params[field] } } } 108 | room.change_notice self, response 109 | return [field, params[field]] 110 | end 111 | end 112 | end 113 | end 114 | end 115 | 116 | class Room 117 | attr_accessor :name, :users 118 | 119 | def initialize(name) 120 | @name = name 121 | @users = [] 122 | end 123 | 124 | def self.valid_name?(name) 125 | User.valid_name?(name[1..-1]) and name[0].chr == '#' 126 | end 127 | 128 | def self.find(item) 129 | @@rooms ||= [] 130 | 131 | if item.kind_of? String 132 | @@rooms.find { |room| room.name.downcase == item.downcase if room.name } 133 | elsif item.kind_of? User 134 | @@rooms.find_all { |room| room.users.include? item } 135 | end 136 | end 137 | 138 | def self.find_or_create(room_name) 139 | room = find room_name 140 | if room.nil? 141 | room = new(room_name) 142 | @@rooms << room 143 | end 144 | room 145 | end 146 | 147 | def self.rooms 148 | @@rooms 149 | end 150 | 151 | def lastlog(since = nil) 152 | { 'display' => 'messages', 'messages' => messages_since(since) } 153 | end 154 | 155 | def search(query, limit = 100) 156 | { 'display' => 'messages', 'messages' => message_search(query, limit) } 157 | end 158 | 159 | def last_update_time 160 | message = JsChat::Storage.driver.lastlog(LASTLOG_DEFAULT, name).last 161 | message['time'] if message 162 | end 163 | 164 | def messages_since(since) 165 | messages = JsChat::Storage.driver.lastlog(LASTLOG_DEFAULT, name) 166 | if since.nil? 167 | messages 168 | else 169 | messages.select { |m| m['time'] && m['time'] > since } 170 | end 171 | end 172 | 173 | def message_search(query, limit) 174 | JsChat::Storage.driver.search(query, name, limit) 175 | end 176 | 177 | def add_to_lastlog(message) 178 | if message 179 | message['time'] = Time.now.utc 180 | JsChat::Storage.driver.log message, name 181 | end 182 | end 183 | 184 | def join(user) 185 | if @users.include? user 186 | Error.new(:already_joined, 'Already in that room') 187 | else 188 | @users << user 189 | user.rooms << self 190 | join_notice user 191 | { 'display' => 'join', 'join' => { 'user' => user.name, 'room' => @name } } 192 | end 193 | end 194 | 195 | def part(user) 196 | if not @users.include?(user) 197 | Error.new(:not_in_room, 'Not in that room') 198 | else 199 | user.rooms.delete_if { |r| r == self } 200 | @users.delete_if { |u| u == user } 201 | part_notice user 202 | { 'display' => 'part', 'part' => { 'user' => user.name, 'room' => @name } } 203 | end 204 | end 205 | 206 | def send_message(message) 207 | message['room'] = name 208 | response = { 'display' => 'message', 'message' => message } 209 | 210 | add_to_lastlog response 211 | 212 | @users.each do |user| 213 | user.connection.send_response response 214 | end 215 | end 216 | 217 | def member_names 218 | @users.collect { |user| user.name } 219 | end 220 | 221 | def to_json(*a) 222 | { 'name' => @name, 'members' => member_names }.to_json(*a) 223 | end 224 | 225 | def notice(user, message, all = false) 226 | add_to_lastlog message 227 | 228 | @users.each do |u| 229 | if (u != user and !all) or all 230 | u.connection.send_response(message) 231 | end 232 | end 233 | end 234 | 235 | def change_notice(user, response) 236 | notice(user, response, true) 237 | end 238 | 239 | def join_notice(user) 240 | notice(user, { 'display' => 'join_notice', 'join_notice' => { 'user' => user.name, 'room' => @name } }) 241 | end 242 | 243 | def part_notice(user) 244 | notice(user, { 'display' => 'part_notice', 'part_notice' => { 'user' => user.name, 'room' => @name } }) 245 | end 246 | 247 | def quit_notice(user) 248 | notice(user, { 'display' => 'quit_notice', 'quit_notice' => { 'user' => user.name, 'room' => @name } }) 249 | @users.delete_if { |u| u == user } 250 | end 251 | end 252 | 253 | # User initially has a nil name 254 | def users_with_names 255 | @@users.find_all { |u| u.name } 256 | end 257 | 258 | def name_taken?(name) 259 | users_with_names.find { |user| user.name.downcase == name.downcase } 260 | end 261 | 262 | # {"identify":"alex"} 263 | def identify(name, ip, session_length, options = {}) 264 | if @user and @user.identified 265 | Error.new :already_identified, 'You have already identified' 266 | elsif name_taken? name 267 | Error.new :name_taken, 'Name already taken' 268 | else 269 | @user.name = name 270 | @user.ip = ip 271 | @user.session_length = session_length 272 | @user.update_session_expiration 273 | register_stateless_user if @stateless 274 | { 'display' => 'identified', 'identified' => @user } 275 | end 276 | rescue JsChat::Errors::InvalidName => exception 277 | exception 278 | end 279 | 280 | def lastlog(room, options = {}) 281 | room = Room.find room 282 | if room and room.users.include? @user 283 | room.lastlog 284 | else 285 | Error.new(:not_in_room, "Please join this room first") 286 | end 287 | end 288 | 289 | def search(query, options = {}) 290 | room = Room.find options['room'] 291 | if room and room.users.include? @user 292 | room.search query 293 | else 294 | Error.new(:not_in_room, "Please join this room first") 295 | end 296 | end 297 | 298 | def since(room, options = {}) 299 | room = Room.find room 300 | if room and room.users.include? @user 301 | response = room.lastlog(@user.last_poll) 302 | @user.last_poll = Time.now.utc 303 | response 304 | else 305 | Error.new(:not_in_room, "Please join this room first") 306 | end 307 | end 308 | 309 | def times(message, options = {}) 310 | times = {} 311 | @user.rooms.each do |room| 312 | times[room.name] = room.last_update_time 313 | end 314 | times 315 | end 316 | 317 | def ping(message, options = {}) 318 | if @user and @user.last_poll and Time.now.utc > @user.last_poll 319 | time = Time.now.utc 320 | @user.update_session_expiration 321 | { 'pong' => time } 322 | else 323 | # TODO: HANDLE PING OUTS 324 | Error.new(:ping_out, 'Your connection has been lost') 325 | end 326 | end 327 | 328 | def quit(message, options = {}) 329 | if @user 330 | disconnect_user @user 331 | end 332 | end 333 | 334 | def room_message(message, options) 335 | room = Room.find options['to'] 336 | if room and room.users.include? @user 337 | room.send_message({ 'message' => message, 'user' => @user.name }) 338 | else 339 | send_response Error.new(:not_in_room, "Please join this room first") 340 | end 341 | end 342 | 343 | def private_message(message, options) 344 | user = users_with_names.find { |u| u.name.downcase == options['to'].downcase } 345 | if user 346 | # Return the message to the user, and send it to the other person too 347 | now = Time.now.utc 348 | user.private_message({ 'message' => message, 'user' => @user.name }) 349 | @user.private_message({ 'message' => message, 'user' => @user.name }) 350 | else 351 | Error.new(:not_online, 'User not online') 352 | end 353 | end 354 | 355 | def send_message(message, options) 356 | if options['to'].nil? 357 | send_response Error.new(:to_required, 'Please specify who to send the message to or join a channel') 358 | elsif options['to'][0].chr == '#' 359 | room_message message, options 360 | else 361 | private_message message, options 362 | end 363 | end 364 | 365 | def join(room_name, options = {}) 366 | if Room.valid_name? room_name 367 | room = Room.find_or_create(room_name) 368 | room.join @user 369 | else 370 | Error.new(:invalid_room, 'Invalid room name') 371 | end 372 | end 373 | 374 | def part(room_name, options = {}) 375 | room = @user.rooms.find { |r| r.name == room_name } 376 | if room 377 | room.part @user 378 | else 379 | Error.new(:not_in_room, "You are not in that room") 380 | end 381 | end 382 | 383 | def names(room_name, options = {}) 384 | room = Room.find(room_name) 385 | if room 386 | { 'display' => 'names', 'names' => room.users, 'room' => room.name } 387 | else 388 | Error.new(:room_not_available, 'No such room') 389 | end 390 | end 391 | 392 | def new_cookie 393 | chars = ("a".."z").to_a + ("1".."9").to_a 394 | Array.new(8, '').collect { chars[rand(chars.size)] }.join 395 | end 396 | 397 | def register_stateless_client 398 | @stateless_cookie = new_cookie 399 | user = User.new(self) 400 | @@stateless_cookies << { :cookie => @stateless_cookie, :user => user } 401 | @@users << user 402 | { 'cookie' => @stateless_cookie } 403 | end 404 | 405 | def current_stateless_client 406 | @@stateless_cookies.find { |c| c[:cookie] == @stateless_cookie } 407 | end 408 | 409 | def register_stateless_user 410 | current_stateless_client[:user] = @user 411 | end 412 | 413 | def valid_stateless_user? 414 | current_stateless_client 415 | end 416 | 417 | def load_stateless_user 418 | if client = current_stateless_client 419 | @user = client[:user] 420 | @stateless = true 421 | else 422 | raise JsChat::Errors::InvalidCookie.new(:invalid_cookie, 'Invalid cookie') 423 | end 424 | end 425 | 426 | def disconnect_lagged_users 427 | @@stateless_cookies.delete_if do |cookie| 428 | if cookie[:user].session_expired? 429 | lagged?(cookie[:user].last_poll) ? disconnect_user(cookie[:user]) && true : false 430 | end 431 | end 432 | end 433 | 434 | def lagged?(time) 435 | Time.now.utc - time > STATELESS_TIMEOUT 436 | end 437 | 438 | def unbind 439 | return if @stateless 440 | disconnect_user(@user) 441 | @user = nil 442 | end 443 | 444 | def disconnect_user(user) 445 | log :info, "Removing a connection" 446 | Room.find(user).each do |room| 447 | room.quit_notice user 448 | end 449 | 450 | @@users.delete_if { |u| u == user } 451 | end 452 | 453 | def post_init 454 | @@users ||= [] 455 | @@stateless_cookies ||= [] 456 | @user = User.new(self) 457 | end 458 | 459 | def log(level, message) 460 | if Object.const_defined? :ServerConfig and ServerConfig['logger'] 461 | if @user 462 | message = "#{@user.name} (#{@user.ip}): #{message}" 463 | end 464 | ServerConfig['logger'].send level, message 465 | end 466 | end 467 | 468 | def change(change, options = {}) 469 | if change == 'user' 470 | field, value = @user.send :change, options[change] 471 | { 'display' => 'notice', 'notice' => "Your #{field} has been changed to: #{value}" } 472 | else 473 | Error.new(:invalid_request, 'Invalid change request') 474 | end 475 | rescue JsChat::Errors::InvalidName => exception 476 | exception 477 | end 478 | 479 | def list(list, options = {}) 480 | case list 481 | when 'rooms' 482 | @user.rooms.collect { |room| room.name } 483 | else 484 | Error.new(:invalid_request, 'Invalid list command') 485 | end 486 | end 487 | 488 | def send_response(data) 489 | response = '' 490 | case data 491 | when String 492 | response = data 493 | when Error 494 | response = data.to_json + "\n" 495 | log :error, data.message 496 | else 497 | # Other objects should be safe for to_json 498 | response = data.to_json + "\n" 499 | log :info, response.strip 500 | end 501 | 502 | send_data response 503 | end 504 | 505 | include EM::Protocols::LineText2 506 | 507 | def get_remote_ip 508 | Socket.unpack_sockaddr_in(get_peername)[1] 509 | end 510 | 511 | def receive_line(data) 512 | response = '' 513 | disconnect_lagged_users 514 | 515 | if data and data.size > ServerConfig['max_message_length'] 516 | raise JsChat::Errors::MessageTooLong.new(:message_too_long, 'Message too long') 517 | end 518 | 519 | data.chomp.split("\n").each do |line| 520 | # Receive the identify request 521 | input = JSON.parse line 522 | 523 | @user.seen! 524 | 525 | # Unbind when a stateless connection doesn't match the cookie 526 | if input.has_key?('cookie') 527 | @stateless_cookie = input['cookie'] 528 | load_stateless_user 529 | end 530 | 531 | if input.has_key? 'protocol' 532 | if input['protocol'] == 'stateless' 533 | @stateless = true 534 | response << send_response(register_stateless_client) 535 | end 536 | elsif input.has_key? 'identify' 537 | input['ip'] ||= get_remote_ip 538 | response << send_response(identify(input['identify'], input['ip'], input['session_length'])) 539 | else 540 | %w{search lastlog change send join names part since ping list quit times}.each do |command| 541 | if @user.name.nil? 542 | response << send_response(Error.new(:identity_required, 'Identify first')) 543 | return response 544 | end 545 | 546 | if input.has_key? command 547 | if command == 'send' 548 | @user.last_activity = Time.now.utc 549 | message_result = send('send_message', input[command], input) 550 | response << message_result if message_result.kind_of? String 551 | else 552 | result = send_response(send(command, input[command], input)) 553 | response << result if result.kind_of? String 554 | end 555 | end 556 | end 557 | end 558 | end 559 | 560 | response 561 | rescue JsChat::Errors::StillFlooding 562 | "" 563 | rescue JsChat::Errors::Flooding => exception 564 | send_response exception 565 | rescue JsChat::Errors::MessageTooLong => exception 566 | send_response exception 567 | rescue JsChat::Errors::InvalidCookie => exception 568 | send_response exception 569 | rescue Exception => exception 570 | puts "Data that raised exception: #{exception}" 571 | p data 572 | print_call_stack 573 | end 574 | 575 | def print_call_stack(from = 0, to = 10) 576 | puts "Stack:" 577 | (from..to).each do |index| 578 | puts "\t#{caller[index]}" 579 | end 580 | end 581 | end 582 | -------------------------------------------------------------------------------- /lib/jschat/server_options.rb: -------------------------------------------------------------------------------- 1 | require 'optparse' 2 | require 'tmpdir' 3 | 4 | logger = nil 5 | 6 | if Object.const_defined? :Logger 7 | logger = Logger.new(STDERR) 8 | logger = Logger.new(STDOUT) 9 | end 10 | 11 | ServerConfigDefaults = { 12 | 'port' => 6789, 13 | 'ip' => '0.0.0.0', 14 | 'logger' => logger, 15 | 'max_message_length' => 500, 16 | 'tmp_files' => File.join(Dir::tmpdir, 'jschat'), 17 | 'db_name' => 'jschat', 18 | 'db_host' => 'localhost', 19 | 'db_port' => 27017, 20 | #'db_username' => '', 21 | #'db_password' => '', 22 | # Register your instance of JsChat here: http://twitter.com/apps/create 23 | # 'twitter' => { 'key' => '', 'secret' => '' } 24 | } 25 | 26 | # Command line options will overrides these 27 | def load_options(path) 28 | path = File.expand_path path 29 | if File.exists? path 30 | JSON.parse(File.read path) 31 | else 32 | {} 33 | end 34 | end 35 | 36 | def make_tmp_files 37 | ServerConfig['use_tmp_files'] = false 38 | if File.exists? ServerConfig['tmp_files'] 39 | ServerConfig['use_tmp_files'] = true 40 | else 41 | if Dir.mkdir ServerConfig['tmp_files'] 42 | ServerConfig['use_tmp_files'] = true 43 | end 44 | end 45 | end 46 | 47 | options = {} 48 | default_config_file = '/etc/jschat/config.json' 49 | 50 | ARGV.clone.options do |opts| 51 | script_name = File.basename($0) 52 | opts.banner = "Usage: #{$0} [options]" 53 | 54 | opts.separator "" 55 | 56 | opts.on("-c", "--config=PATH", String, "Configuration file location (#{default_config_file}") { |o| options['config'] = o } 57 | opts.on("-p", "--port=PORT", String, "Port number") { |o| options['port'] = o } 58 | opts.on("-t", "--tmp_files=PATH", String, "Temporary files location (including pid file)") { |o| options['tmp_files'] = o } 59 | opts.on("--help", "-H", "This text") { puts opts; exit 0 } 60 | 61 | opts.parse! 62 | end 63 | 64 | options = load_options(options['config'] || default_config_file).merge options 65 | 66 | ServerConfig = ServerConfigDefaults.merge options 67 | make_tmp_files 68 | -------------------------------------------------------------------------------- /lib/jschat/storage/init.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), 'mongo') 2 | require File.join(File.dirname(__FILE__), 'null') 3 | 4 | module JsChat::Storage 5 | def self.driver=(driver) 6 | @driver = driver 7 | end 8 | 9 | def self.driver ; @driver ; end 10 | 11 | def self.enabled=(enabled) 12 | @enabled = enabled 13 | end 14 | 15 | def self.enabled? 16 | @enabled 17 | end 18 | end 19 | 20 | -------------------------------------------------------------------------------- /lib/jschat/storage/mongo.rb: -------------------------------------------------------------------------------- 1 | begin 2 | require 'mongo' 3 | rescue LoadError 4 | end 5 | 6 | module JsChat::Storage 7 | module MongoDriver 8 | def self.connect! 9 | @db = Mongo::Connection.new(ServerConfig['db_host'], ServerConfig['db_port'], :slave_ok => true).db(ServerConfig['db_name']) 10 | if ServerConfig['db_username'] and ServerConfig['db_password'] 11 | if @db.authenticate(ServerConfig['db_username'], ServerConfig['db_password']) 12 | true 13 | else 14 | raise 'Bad Mongo username or password' 15 | end 16 | else 17 | true 18 | end 19 | end 20 | 21 | def self.log(message, room) 22 | message['room'] = room 23 | @db['events'].insert(message) 24 | end 25 | 26 | def self.lastlog(number, room) 27 | @db['events'].find({ :room => room }, { :limit => number, :sort => ['time', Mongo::DESCENDING] }).to_a.reverse 28 | end 29 | 30 | def self.search(query, room, limit) 31 | query = /\b#{query}\b/i 32 | @db['events'].find({ 'message.message' => query, 'room' => room }, 33 | { :limit => limit, :sort => ['time', Mongo::DESCENDING] } 34 | ).to_a.reverse 35 | end 36 | 37 | # TODO: use twitter oauth for the key 38 | def self.find_user(options) 39 | @db['users'].find_one(options) 40 | end 41 | 42 | def self.save_user(user) 43 | @db['users'].save user 44 | end 45 | 46 | def self.delete_user(user) 47 | @db['users'].remove user 48 | end 49 | 50 | def self.available? 51 | return unless Object.const_defined?(:Mongo) 52 | connect! 53 | rescue 54 | p $! 55 | puts 'Failed to connect to mongo' 56 | false 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/jschat/storage/null.rb: -------------------------------------------------------------------------------- 1 | module JsChat::Storage 2 | MEMORY_MESSAGE_LIMIT = 100 3 | 4 | module NullDriver 5 | def self.log(message, room) 6 | @messages ||= [] 7 | message['room'] = room 8 | @messages.push message 9 | @messages = @messages[-MEMORY_MESSAGE_LIMIT..-1] if @messages.size > MEMORY_MESSAGE_LIMIT 10 | end 11 | 12 | def self.lastlog(number, room) 13 | @messages ||= [] 14 | @messages.select { |m| m['room'] == room }.reverse[0..number].reverse 15 | end 16 | 17 | def self.search(query, room, limit) 18 | @messages ||= [] 19 | @messages.select do |m| 20 | m['message'] and m['message']['message'].match(query) and m['room'] == room 21 | end.reverse[0..limit].reverse 22 | end 23 | 24 | def self.find_user(options) 25 | end 26 | 27 | def self.save_user(user) 28 | end 29 | 30 | def self.delete_user(user) 31 | end 32 | end 33 | end 34 | 35 | -------------------------------------------------------------------------------- /test/server_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class ServerTest < Test::Unit::TestCase 4 | include JsChatHelpers 5 | 6 | def setup 7 | @jschat = JsChatMock.new 8 | @jschat.post_init 9 | end 10 | 11 | def teardown 12 | @jschat.reset 13 | end 14 | 15 | def test_identify 16 | response = JSON.parse @jschat.receive_line({ 'identify' => 'alex' }.to_json) 17 | assert_equal 'identified', response['display'] 18 | end 19 | 20 | def test_invalid_identify 21 | response = JSON.parse @jschat.receive_line({ 'identify' => '@lex' }.to_json) 22 | assert_equal JsChat::Errors::Codes.invert[:invalid_name], response['error']['code'] 23 | end 24 | 25 | def test_identify_twice_fails 26 | identify_as 'alex' 27 | result = JSON.parse identify_as('alex') 28 | assert_equal 106, result['error']['code'] 29 | end 30 | 31 | def test_ensure_nicks_are_longer_than_0 32 | result = JSON.parse identify_as('') 33 | assert result['error'] 34 | end 35 | 36 | def test_ensure_nicks_are_unique 37 | identify_as 'alex' 38 | 39 | # Obvious duplicate 40 | result = identify_as 'alex' 41 | assert result['error'] 42 | 43 | # Case 44 | result = identify_as 'Alex' 45 | assert result['error'] 46 | end 47 | 48 | def test_invalid_room_name 49 | identify_as 'bob' 50 | response = JSON.parse @jschat.receive_line({ 'join' => 'oublinet' }.to_json) 51 | assert_equal 'Invalid room name', response['error']['message'] 52 | end 53 | 54 | def test_join 55 | identify_as 'bob' 56 | expected = { 'display' => 'join', 'join' => { 'user' => 'bob', 'room' => '#oublinet' } }.to_json + "\n" 57 | assert_equal expected, @jschat.receive_line({ 'join' => '#oublinet' }.to_json) 58 | end 59 | 60 | def test_join_without_identifying 61 | response = JSON.parse @jschat.receive_line({ 'join' => '#oublinet' }.to_json) 62 | assert response['error'] 63 | end 64 | 65 | def test_join_more_than_once 66 | identify_as 'bob' 67 | 68 | expected = { 'display' => 'error', 'error' => { 'message' => 'Already in that room' } }.to_json + "\n" 69 | @jschat.receive_line({ 'join' => '#oublinet' }.to_json) 70 | response = JSON.parse @jschat.receive_line({ 'join' => '#oublinet' }.to_json) 71 | assert_equal JsChat::Errors::Codes.invert[:already_joined], response['error']['code'] 72 | end 73 | 74 | def test_names 75 | identify_as 'nick', '#oublinet' 76 | 77 | # Add a user 78 | @jschat.add_user 'alex', '#oublinet' 79 | 80 | response = JSON.parse(@jschat.receive_line({ 'names' => '#oublinet' }.to_json)) 81 | assert response['names'] 82 | end 83 | 84 | def test_valid_names 85 | user = JsChat::User.new nil 86 | ['alex*', "alex\n"].each do |name| 87 | assert_raises JsChat::Errors::InvalidName do 88 | user.name = name 89 | end 90 | end 91 | end 92 | 93 | def test_message_not_in_room 94 | identify_as 'nick', '#oublinet' 95 | @jschat.add_user 'alex', '#oublinet' 96 | response = JSON.parse @jschat.receive_line({ 'send' => 'hello', 'to' => '#merk' }.to_json) 97 | assert_equal 'Please join this room first', response['error']['message'] 98 | end 99 | 100 | def test_message 101 | identify_as 'nick', '#oublinet' 102 | @jschat.add_user 'alex', '#oublinet' 103 | assert @jschat.receive_line({ 'send' => 'hello', 'to' => '#oublinet' }.to_json) 104 | end 105 | 106 | def test_message_ignores_case 107 | identify_as 'nick', '#oublinet' 108 | @jschat.add_user 'alex', '#oublinet' 109 | response = @jschat.receive_line({ 'send' => 'hello', 'to' => '#Oublinet' }.to_json) 110 | assert response 111 | end 112 | 113 | def test_part 114 | identify_as 'nick', '#oublinet' 115 | @jschat.add_user 'alex', '#oublinet' 116 | response = JSON.parse @jschat.receive_line({ 'part' => '#oublinet'}.to_json) 117 | assert_equal '#oublinet', response['part']['room'] 118 | end 119 | 120 | def test_private_message 121 | identify_as 'nick' 122 | @jschat.add_user 'alex', '#oublinet' 123 | response = JSON.parse @jschat.receive_line({ 'send' => 'hello', 'to' => 'alex' }.to_json) 124 | assert_equal 'hello', response['message']['message'] 125 | end 126 | 127 | def test_private_message_ignores_case 128 | identify_as 'nick' 129 | @jschat.add_user 'alex', '#oublinet' 130 | response = JSON.parse @jschat.receive_line({ 'send' => 'hello', 'to' => 'Alex' }.to_json) 131 | assert_equal 'hello', response['message']['message'] 132 | end 133 | 134 | def test_log_request 135 | identify_as 'nick', '#oublinet' 136 | @jschat.receive_line({ 'send' => 'hello', 'to' => '#oublinet' }.to_json) 137 | response = JSON.parse @jschat.receive_line({ 'lastlog' => '#oublinet' }.to_json) 138 | assert_equal 'hello', response['messages'].last['message']['message'] 139 | end 140 | 141 | def test_name_change 142 | identify_as 'nick', '#oublinet' 143 | @jschat.add_user 'alex', '#oublinet' 144 | response = JSON.parse @jschat.receive_line({ 'change' => 'user', 'user' => { 'name' => 'bob' }}.to_json) 145 | assert_equal 'notice', response['display'] 146 | end 147 | 148 | def test_name_change_duplicate 149 | identify_as 'nick', '#oublinet' 150 | @jschat.add_user 'alex', '#oublinet' 151 | response = JSON.parse @jschat.receive_line({ 'change' => 'user', 'user' => { 'name' => 'alex' }}.to_json) 152 | assert_equal 'error', response['display'] 153 | end 154 | 155 | def test_max_message_length 156 | identify_as 'nick', '#oublinet' 157 | response = JSON.parse @jschat.receive_line({ 'send' => 'a' * 1000, 'to' => '#oublinet' }.to_json) 158 | assert response['error'] 159 | end 160 | 161 | def test_flood_protection 162 | identify_as 'nick', '#oublinet' 163 | response = '' 164 | # simulate a flood and extract the error response 165 | (1..50).detect do 166 | response = @jschat.receive_line({ 'send' => 'a' * 10, 'to' => '#oublinet' }.to_json) 167 | response.match /error/ 168 | end 169 | response = JSON.parse response 170 | assert response['error'] 171 | assert_match /wait a few seconds/i, response['error']['message'] 172 | end 173 | 174 | end 175 | 176 | -------------------------------------------------------------------------------- /test/stateless_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class StatelessTest < Test::Unit::TestCase 4 | include JsChatHelpers 5 | 6 | def setup 7 | @jschat = JsChatMock.new 8 | @jschat.post_init 9 | @cookie = get_cookie 10 | end 11 | 12 | def test_identify 13 | response = send_to_jschat({ 'identify' => 'alex', 'cookie' => @cookie }) 14 | assert_equal 'identified', response['display'] 15 | end 16 | 17 | def test_join 18 | response = identify_as 'alex2', '#jschat' 19 | assert JSON.parse(response)['join'] 20 | end 21 | 22 | def test_message 23 | response = identify_as 'nick', '#jschat' 24 | assert send_to_jschat({ 'cookie' => @cookie, 'send' => 'hello', 'to' => '#jschat' }, false) 25 | end 26 | 27 | private 28 | 29 | def get_cookie 30 | JSON.parse(@jschat.receive_line({ 'protocol' => 'stateless' }.to_json))['cookie'] 31 | end 32 | 33 | end 34 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require 'test/unit' 2 | require 'rubygems' 3 | require 'eventmachine' 4 | gem 'json', '>= 1.1.9' 5 | require 'json' 6 | $:.unshift File.join(File.dirname(__FILE__), '..', 'lib') 7 | require File.join(File.dirname(__FILE__), '..', 'lib', 'jschat', 'server.rb') 8 | 9 | ServerConfig['max_message_length'] = 500 10 | 11 | class JsChat::Room 12 | def self.reset 13 | @@rooms = nil 14 | end 15 | end 16 | 17 | module JsChatHelpers 18 | def identify_as(name, channel = nil) 19 | if @cookie 20 | result = @jschat.receive_line({ 'identify' => name, :cookie => @cookie }.to_json) 21 | result = @jschat.receive_line({ 'join' => channel, :cookie => @cookie }.to_json) if channel 22 | else 23 | result = @jschat.receive_line({ 'identify' => name }.to_json) 24 | result = @jschat.receive_line({ 'join' => channel }.to_json) if channel 25 | end 26 | result 27 | end 28 | 29 | def send_to_jschat(h, parse = true) 30 | response = @jschat.receive_line(h.to_json) 31 | parse ? JSON.parse(response) : response 32 | end 33 | end 34 | 35 | class JsChatMock 36 | include JsChat 37 | 38 | def get_remote_ip 39 | '' 40 | end 41 | 42 | def send_data(data) 43 | data 44 | end 45 | 46 | def reset 47 | @@users = nil 48 | @user = nil 49 | Room.reset 50 | end 51 | 52 | # Helper for testing 53 | def add_user(name, room_name) 54 | room = Room.find_or_create room_name 55 | user = User.new self 56 | user.name = name 57 | user.rooms << room 58 | @@users << user 59 | room.users << user 60 | end 61 | end 62 | 63 | JsChat::Storage.enabled = false 64 | JsChat::Storage.driver = JsChat::Storage::NullDriver 65 | 66 | --------------------------------------------------------------------------------