tkellem 0.9.0.beta5 → 0.9.0.beta6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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: