tkellem 0.9.0.beta5 → 0.9.0.beta6

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,47 @@
1
+ class BackwardsFileReader
2
+ def self.scan(stream)
3
+ scanner = new(stream)
4
+ while line = scanner.readline
5
+ break unless yield(line)
6
+ end
7
+ scanner.sync
8
+ end
9
+
10
+ def initialize(stream)
11
+ @stream = stream
12
+ @stream.seek 0, IO::SEEK_END
13
+ @pos = @stream.pos
14
+ @offset = 0
15
+
16
+ @read_size = [4096, @pos].min
17
+ @line_buffer = []
18
+ end
19
+
20
+ def readline
21
+ if @line_buffer.size > 2 || @pos == 0
22
+ line = @line_buffer.pop
23
+ if line
24
+ @offset += line.length
25
+ end
26
+ return line
27
+ end
28
+
29
+ @pos -= @read_size
30
+ @stream.seek(@pos, IO::SEEK_SET)
31
+ @offset = 0
32
+
33
+ @line_buffer[0] = "#{@stream.read(@read_size)}#{@line_buffer[0]}"
34
+ @line_buffer[0] = @line_buffer[0].scan(%r{.*\n})
35
+ @line_buffer.flatten!
36
+
37
+ readline
38
+ end
39
+
40
+ def sync
41
+ if @offset > 0
42
+ @stream.seek(-@offset, IO::SEEK_CUR)
43
+ end
44
+ @offset = 0
45
+ @stream
46
+ end
47
+ end
@@ -78,7 +78,7 @@ module BouncerConnection
78
78
  end
79
79
  else
80
80
  if @user
81
- TkellemBot.run_command(msg.args.join(' '), @user, @bouncer.try(:network_user)) do |response|
81
+ TkellemBot.run_command(msg.args.join(' '), @bouncer, self) do |response|
82
82
  say_as_tkellem(response)
83
83
  end
84
84
  end
@@ -53,7 +53,7 @@ class Tkellem::Daemon
53
53
  when 'admin'
54
54
  admin
55
55
  when nil
56
- puts opts
56
+ puts op
57
57
  else
58
58
  raise("Unknown command: #{command.inspect}")
59
59
  end
@@ -0,0 +1,11 @@
1
+ class AddBacklogPositions < ActiveRecord::Migration
2
+ def self.up
3
+ create_table 'backlog_positions' do |t|
4
+ t.integer :network_user_id
5
+ t.string :context_name
6
+ t.string :device_name
7
+ t.integer :position, :default => 0
8
+ end
9
+ end
10
+ end
11
+
@@ -0,0 +1,3 @@
1
+ class BacklogPosition < ActiveRecord::Base
2
+ belongs_to :network_user
3
+ end
@@ -1,9 +1,12 @@
1
+ require 'backwards_file_reader'
1
2
  require 'fileutils'
3
+ require 'pathname'
2
4
  require 'time'
3
5
 
4
6
  require 'active_support/core_ext/class/attribute_accessors'
5
7
 
6
8
  require 'tkellem/irc_message'
9
+ require 'tkellem/tkellem_bot'
7
10
 
8
11
  module Tkellem
9
12
 
@@ -40,44 +43,94 @@ class Backlog
40
43
  true
41
44
  end
42
45
 
46
+
47
+ #### IMPL
48
+
49
+ class Device < Struct.new(:network_user, :device_name, :positions)
50
+ def initialize(*a)
51
+ super
52
+ self.positions = {}
53
+ end
54
+
55
+ def update_pos(ctx_name, pos)
56
+ # TODO: it'd be a good idea to throttle these updates to once every few seconds per ctx
57
+ # right now we're kind of harsh on the sqlite db
58
+ self.position(ctx_name).first_or_create.update_attribute(:position, pos)
59
+ end
60
+
61
+ def pos(ctx_name, pos_for_new = 0)
62
+ backlog_pos = self.position(ctx_name).first_or_initialize
63
+ if backlog_pos.new_record?
64
+ backlog_pos.position = pos_for_new
65
+ backlog_pos.save
66
+ end
67
+ backlog_pos.position
68
+ end
69
+
70
+ protected
71
+
72
+ def position(ctx_name)
73
+ self.positions[ctx_name] ||=
74
+ BacklogPosition.where(:network_user_id => network_user.id,
75
+ :context_name => ctx_name,
76
+ :device_name => device_name)
77
+ end
78
+ end
79
+
43
80
  def initialize(bouncer)
44
81
  @bouncer = bouncer
82
+ @network_user = bouncer.network_user
45
83
  @devices = {}
46
- @streams = {}
47
- @starting_pos = {}
48
- @dir = File.expand_path("~/.tkellem/logs/#{bouncer.user.username}/#{bouncer.network.name}")
49
- FileUtils.mkdir_p(@dir)
84
+ @dir = Pathname.new(File.expand_path("~/.tkellem/logs/#{bouncer.user.username}/#{bouncer.network.name}"))
85
+ @dir.mkpath()
86
+ end
87
+
88
+ def stream_path(ctx)
89
+ @dir + "#{ctx}.log"
90
+ end
91
+
92
+ def all_existing_ctxs
93
+ @dir.entries.select { |e| e.extname == ".log" }.map { |e| e.basename(".log").to_s }
50
94
  end
51
95
 
52
- def stream_filename(ctx)
53
- File.join(@dir, "#{ctx}.log")
96
+ def get_stream(ctx, for_reading = false)
97
+ mode = for_reading ? 'rb' : 'ab'
98
+ stream_path(ctx).open(mode) do |stream|
99
+ if !for_reading
100
+ stream.seek(0, IO::SEEK_END)
101
+ end
102
+ yield stream
103
+ end
54
104
  end
55
105
 
56
- def get_stream(ctx)
57
- # open stream in append-only mode
58
- return @streams[ctx] if @streams[ctx]
59
- stream = @streams[ctx] = File.open(stream_filename(ctx), 'ab')
60
- stream.seek(0, IO::SEEK_END)
61
- @starting_pos[ctx] = stream.pos
62
- stream
106
+ def stream_size(ctx)
107
+ stream_path(ctx).size
63
108
  end
64
109
 
65
110
  def get_device(conn)
66
- @devices[conn.device_name] ||= Hash.new { |h,k| h[k] = @starting_pos[k] }
111
+ @devices[conn.device_name] ||= Device.new(@network_user, conn.device_name)
67
112
  end
68
113
 
69
114
  def client_connected(conn)
70
115
  device = get_device(conn)
71
- if @streams.any? { |ctx_name, stream| device[ctx_name] < stream.pos }
72
- # this device has missed messages, replay all the backlogs
73
- send_backlog(conn, device)
116
+ behind = all_existing_ctxs.select do |ctx_name|
117
+ eof = stream_size(ctx_name)
118
+ # default to the end of file, rather than the beginning, for new devices
119
+ # that way they don't get flooded the first time they connect
120
+ device.pos(ctx_name, eof) < eof
121
+ end
122
+ if !behind.empty?
123
+ # this device has missed messages, replay all the relevant backlogs
124
+ send_connect_backlogs(conn, device, behind)
74
125
  end
75
126
  end
76
127
 
77
128
  def update_pos(ctx_name, pos)
129
+ # don't just iterate @devices here, because that may contain devices that
130
+ # have since been disconnected
78
131
  @bouncer.active_conns.each do |conn|
79
132
  device = get_device(conn)
80
- device[ctx_name] = pos
133
+ device.update_pos(ctx_name, pos)
81
134
  end
82
135
  end
83
136
 
@@ -97,9 +150,7 @@ class Backlog
97
150
  # incoming pm, fake ctx to be the sender's nick
98
151
  ctx = msg.prefix.split(/[!~@]/, 2).first
99
152
  end
100
- stream = get_stream(ctx)
101
- stream.puts(Time.now.strftime("%d-%m-%Y %H:%M:%S") + " < #{'* ' if msg.action?}#{msg.prefix}: #{msg.args.last}")
102
- update_pos(ctx, stream.pos)
153
+ write_msg(ctx, Time.now.strftime("%d-%m-%Y %H:%M:%S") + " < #{'* ' if msg.action?}#{msg.prefix}: #{msg.args.last}")
103
154
  end
104
155
  end
105
156
 
@@ -108,46 +159,67 @@ class Backlog
108
159
  when 'PRIVMSG'
109
160
  return if msg.ctcp? && !msg.action?
110
161
  ctx = msg.args.first
111
- stream = get_stream(ctx)
112
- stream.puts(Time.now.strftime("%d-%m-%Y %H:%M:%S") + " > #{'* ' if msg.action?}#{msg.args.last}")
162
+ write_msg(ctx, Time.now.strftime("%d-%m-%Y %H:%M:%S") + " > #{'* ' if msg.action?}#{msg.args.last}")
163
+ end
164
+ end
165
+
166
+ def write_msg(ctx, processed_msg)
167
+ get_stream(ctx) do |stream|
168
+ stream.puts(processed_msg)
113
169
  update_pos(ctx, stream.pos)
114
170
  end
115
171
  end
116
172
 
117
- def send_backlog(conn, device)
118
- device.each do |ctx_name, pos|
119
- stream = File.open(stream_filename(ctx_name), 'rb')
120
- stream.seek(pos)
173
+ def send_connect_backlogs(conn, device, contexts)
174
+ contexts.each do |ctx_name|
175
+ start_pos = device.pos(ctx_name)
176
+ get_stream(ctx_name, true) do |stream|
177
+ stream.seek(start_pos)
178
+ send_backlog(conn, ctx_name, stream)
179
+ device.update_pos(ctx_name, stream.pos)
180
+ end
181
+ end
182
+ end
183
+
184
+ def send_backlog_since(conn, start_time, contexts)
185
+ debug "scanning for backlog from #{start_time.inspect}"
186
+ contexts.each do |ctx_name|
187
+ get_stream(ctx_name, true) do |stream|
188
+ last_line_len = 0
189
+ BackwardsFileReader.scan(stream) { |line| last_line_len = line.length; Time.parse(line[0,19]) >= start_time }
190
+ stream.seek(last_line_len, IO::SEEK_CUR)
191
+ send_backlog(conn, ctx_name, stream)
192
+ end
193
+ end
194
+ end
121
195
 
122
- while line = stream.gets
123
- timestamp, msg = parse_line(line, ctx_name)
124
- next unless msg
125
- privmsg = msg.args.first[0] != '#'[0]
126
- if msg.prefix
127
- # to this user
128
- if privmsg
129
- msg.args[0] = @bouncer.nick
130
- else
131
- # do nothing, it's good to send
132
- end
196
+ def send_backlog(conn, ctx_name, stream)
197
+ while line = stream.gets
198
+ timestamp, msg = parse_line(line, ctx_name)
199
+ next unless msg
200
+ privmsg = msg.args.first[0] != '#'[0]
201
+ if msg.prefix
202
+ # to this user
203
+ if privmsg
204
+ msg.args[0] = @bouncer.nick
133
205
  else
134
- # from this user
135
- if privmsg
136
- # a one-on-one chat -- every client i've seen doesn't know how to
137
- # display messages from themselves here, so we fake it by just
138
- # adding an arrow and pretending the other user said it. shame.
139
- msg.prefix = msg.args.first
140
- msg.args[0] = @bouncer.nick
141
- msg.args[-1] = "-> #{msg.args.last}"
142
- else
143
- # it's a room, we can just replay
144
- msg.prefix = @bouncer.nick
145
- end
206
+ # do nothing, it's good to send
207
+ end
208
+ else
209
+ # from this user
210
+ if privmsg
211
+ # a one-on-one chat -- every client i've seen doesn't know how to
212
+ # display messages from themselves here, so we fake it by just
213
+ # adding an arrow and pretending the other user said it. shame.
214
+ msg.prefix = msg.args.first
215
+ msg.args[0] = @bouncer.nick
216
+ msg.args[-1] = "-> #{msg.args.last}"
217
+ else
218
+ # it's a room, we can just replay
219
+ msg.prefix = @bouncer.nick
146
220
  end
147
- conn.send_msg(msg.with_timestamp(timestamp))
148
221
  end
149
-
150
- device[ctx_name] = get_stream(ctx_name).pos
222
+ conn.send_msg(msg.with_timestamp(timestamp))
151
223
  end
152
224
  end
153
225
 
@@ -172,4 +244,24 @@ class Backlog
172
244
  end
173
245
  end
174
246
 
247
+ class BacklogCommand < TkellemBot::Command
248
+ register 'backlog'
249
+
250
+ def self.admin_only?
251
+ false
252
+ end
253
+
254
+ def execute
255
+ hours = args.pop.to_f
256
+ hours = 1 if hours <= 0 || hours >= (24*31)
257
+ cutoff = hours.hours.ago
258
+ backlog = Backlog.get_instance(bouncer)
259
+ rooms = [args.pop].compact
260
+ if rooms.empty?
261
+ rooms = backlog.all_existing_ctxs
262
+ end
263
+ backlog.send_backlog_since(conn, cutoff, rooms)
264
+ end
265
+ end
266
+
175
267
  end
@@ -4,9 +4,9 @@ require 'yaml'
4
4
  module Tkellem
5
5
 
6
6
  class TkellemBot
7
- # careful here -- if no user is given, it's assumed the command is running as
7
+ # careful here -- if no bouncer is given, it's assumed the command is running as
8
8
  # an admin
9
- def self.run_command(line, user, network_user, &block)
9
+ def self.run_command(line, bouncer, conn, &block)
10
10
  args = Shellwords.shellwords(line)
11
11
  command_name = args.shift.upcase
12
12
  command = commands[command_name]
@@ -16,11 +16,11 @@ class TkellemBot
16
16
  return
17
17
  end
18
18
 
19
- command.run(args, user, network_user, block)
19
+ command.run(args, bouncer, conn, block)
20
20
  end
21
21
 
22
22
  class Command
23
- attr_accessor :args, :user, :network_user, :opts, :options
23
+ attr_accessor :args, :bouncer, :conn, :opts, :options
24
24
 
25
25
  def self.option(name, *args)
26
26
  @options ||= {}
@@ -62,18 +62,18 @@ class TkellemBot
62
62
  end
63
63
  end
64
64
 
65
- def self.run(args_arr, user, network_user, block)
66
- if admin_only? && !admin_user?(user)
65
+ def self.run(args_arr, bouncer, conn, block)
66
+ if admin_only? && !admin_user?(bouncer.try(:user))
67
67
  block.call "You can only run #{name} as an admin."
68
68
  return
69
69
  end
70
70
  cmd = self.new(block)
71
71
 
72
72
  cmd.args = args_arr
73
- cmd.user = user
74
- cmd.network_user = network_user
73
+ cmd.bouncer = bouncer
74
+ cmd.conn = conn
75
75
 
76
- cmd.options = build_options(user, cmd)
76
+ cmd.options = build_options(bouncer.try(:user), cmd)
77
77
  cmd.options.parse!(args_arr)
78
78
 
79
79
  cmd.execute
@@ -86,6 +86,10 @@ class TkellemBot
86
86
  @opts = {}
87
87
  end
88
88
 
89
+ def user
90
+ bouncer.try(:user)
91
+ end
92
+
89
93
  def show_help
90
94
  respond(options)
91
95
  end
@@ -156,7 +160,7 @@ class TkellemBot
156
160
  end
157
161
 
158
162
  def modify
159
- instance = model.first(:conditions => find_attributes)
163
+ instance = model.where(find_attributes).first
160
164
  new_record = false
161
165
  if instance
162
166
  instance.attributes = attributes
@@ -181,7 +185,7 @@ class TkellemBot
181
185
  end
182
186
 
183
187
  def remove
184
- instance = model.first(:conditions => find_attributes)
188
+ instance = model.where(find_attributes).first
185
189
  if instance
186
190
  instance.destroy
187
191
  respond "Removed #{show(instance)}"
@@ -258,7 +262,7 @@ class TkellemBot
258
262
 
259
263
  if opts['username']
260
264
  if Command.admin_user?(user)
261
- user = User.first(:conditions => { :username => opts['username'] })
265
+ user = User.where({ :username => opts['username'] }).first
262
266
  else
263
267
  raise Command::ArgumentError, "Only admins can change other passwords"
264
268
  end
@@ -303,9 +307,9 @@ class TkellemBot
303
307
 
304
308
  def execute
305
309
  if opts['network'].present? # only settable by admins
306
- target = Network.first(:conditions => ["name = ? AND user_id IS NULL", opts['network'].downcase])
310
+ target = Network.where(["name = ? AND user_id IS NULL", opts['network'].downcase]).first
307
311
  else
308
- target = network_user
312
+ target = bouncer.try(:network_user)
309
313
  end
310
314
  raise(Command::ArgumentError, "No network found") unless target
311
315
 
@@ -339,7 +343,7 @@ class TkellemBot
339
343
  admin_option('public', '--public', "Create new public network. Once created, public/private status can't be modified.")
340
344
 
341
345
  def list
342
- public_networks = Network.all(:conditions => 'user_id IS NULL')
346
+ public_networks = Network.where('user_id IS NULL').to_a
343
347
  user_networks = user.try(:reload).try(:networks) || []
344
348
  if user_networks.present? && public_networks.present?
345
349
  r "Public networks are prefixed with [P], user-specific networks with [U]."
@@ -362,10 +366,10 @@ class TkellemBot
362
366
  end
363
367
 
364
368
  if opts['network'].present?
365
- target = Network.first(:conditions => ["name = ? AND user_id = ?", opts['network'].downcase, user.try(:id)])
366
- target ||= Network.first(:conditions => ["name = ? AND user_id IS NULL", opts['network'].downcase]) if self.class.admin_user?(user)
369
+ target = Network.where(["name = ? AND user_id = ?", opts['network'].downcase, user.try(:id)]).first
370
+ target ||= Network.where(["name = ? AND user_id IS NULL", opts['network'].downcase]).first if self.class.admin_user?(user)
367
371
  else
368
- target = network_user.try(:network)
372
+ target = bouncer.try(:network)
369
373
  if target && target.public? && !self.class.admin_user?(user)
370
374
  raise(Command::ArgumentError, "Only admins can modify public networks")
371
375
  end
@@ -379,7 +383,7 @@ class TkellemBot
379
383
  raise(Command::ArgumentError, "No network found") unless target
380
384
  raise(Command::ArgumentError, "You must explicitly specify the network to remove") unless opts['network']
381
385
  if uri
382
- target.hosts.first(:conditions => addr_args).try(:destroy)
386
+ target.hosts.where(addr_args).first.try(:destroy)
383
387
  respond " #{show(target)}"
384
388
  else
385
389
  target.destroy
@@ -5,6 +5,7 @@ require 'rails/observers/activerecord/active_record'
5
5
  require 'tkellem/bouncer_connection'
6
6
  require 'tkellem/bouncer'
7
7
 
8
+ require 'tkellem/models/backlog_position'
8
9
  require 'tkellem/models/host'
9
10
  require 'tkellem/models/listen_address'
10
11
  require 'tkellem/models/network'
@@ -1,3 +1,3 @@
1
1
  module Tkellem
2
- VERSION = "0.9.0.beta5"
2
+ VERSION = "0.9.0.beta6"
3
3
  end
@@ -65,3 +65,16 @@ SETTING:
65
65
  Examples:
66
66
  SETTING
67
67
  SETTING user_registration open
68
+
69
+ BACKLOG:
70
+ banner: "BACKLOG [<room or user>] <hours>"
71
+ help: |+
72
+
73
+ View backlog for a given room (or all rooms).
74
+
75
+ Examples:
76
+ view 3 hours of backlog for all rooms:
77
+ BACKLOG 3
78
+ view 2 hours of backlog for the #friends room:
79
+ BACKLOG #friends 2
80
+
data/tkellem.gemspec CHANGED
@@ -20,7 +20,7 @@ Gem::Specification.new do |s|
20
20
  s.add_dependency "eventmachine", "~> 1.0.3"
21
21
  s.add_dependency "activerecord", "~> 4.0.0.rc2"
22
22
  s.add_dependency "sqlite3", "~> 1.3.3"
23
- s.add_dependency "rails-observers"
23
+ s.add_dependency "rails-observers", "~> 0.1.1"
24
24
 
25
25
  s.add_development_dependency "rspec", "~> 2.5"
26
26
  s.add_development_dependency "simplecov"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tkellem
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.0.beta5
4
+ version: 0.9.0.beta6
5
5
  prerelease: 6
6
6
  platform: ruby
7
7
  authors:
@@ -64,17 +64,17 @@ dependencies:
64
64
  requirement: !ruby/object:Gem::Requirement
65
65
  none: false
66
66
  requirements:
67
- - - ! '>='
67
+ - - ~>
68
68
  - !ruby/object:Gem::Version
69
- version: '0'
69
+ version: 0.1.1
70
70
  type: :runtime
71
71
  prerelease: false
72
72
  version_requirements: !ruby/object:Gem::Requirement
73
73
  none: false
74
74
  requirements:
75
- - - ! '>='
75
+ - - ~>
76
76
  - !ruby/object:Gem::Version
77
- version: '0'
77
+ version: 0.1.1
78
78
  - !ruby/object:Gem::Dependency
79
79
  name: rspec
80
80
  requirement: !ruby/object:Gem::Requirement
@@ -133,6 +133,7 @@ files:
133
133
  - debian/source/format
134
134
  - debian/tkellem.1
135
135
  - examples/config.yml
136
+ - lib/backwards_file_reader.rb
136
137
  - lib/tkellem.rb
137
138
  - lib/tkellem/bouncer.rb
138
139
  - lib/tkellem/bouncer_connection.rb
@@ -142,6 +143,8 @@ files:
142
143
  - lib/tkellem/migrations/001_init_db.rb
143
144
  - lib/tkellem/migrations/002_at_connect_columns.rb
144
145
  - lib/tkellem/migrations/003_settings.rb
146
+ - lib/tkellem/migrations/004_add_backlog_positions.rb
147
+ - lib/tkellem/models/backlog_position.rb
145
148
  - lib/tkellem/models/host.rb
146
149
  - lib/tkellem/models/listen_address.rb
147
150
  - lib/tkellem/models/network.rb
@@ -176,7 +179,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
176
179
  version: '0'
177
180
  segments:
178
181
  - 0
179
- hash: 1423623197128885287
182
+ hash: -3227479256214669237
180
183
  required_rubygems_version: !ruby/object:Gem::Requirement
181
184
  none: false
182
185
  requirements: