newton 0.0.1

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.
data/lib/newton/irc.rb ADDED
@@ -0,0 +1,261 @@
1
+ module Newton
2
+ class IRC
3
+ # @return [ISupport]
4
+ attr_reader :isupport
5
+ def initialize(bot, config)
6
+ @bot, @config = bot, config
7
+ @isupport = ISupport.new
8
+ end
9
+
10
+ # Establish a connection.
11
+ #
12
+ # @return [void]
13
+ def connect
14
+ @registration = []
15
+
16
+ @whois_updates = Hash.new {|h, k| h[k] = {}}
17
+ @in_lists = Set.new
18
+
19
+ tcp_socket = TCPSocket.open(@config.server, @config.port)
20
+
21
+ if @config.ssl
22
+ require 'openssl'
23
+
24
+ ssl_context = OpenSSL::SSL::SSLContext.new
25
+ ssl_context.verify_mode = OpenSSL::SSL::VERIFY_NONE
26
+
27
+ FormattedLogger.debug "Using SSL with #{@config.server}:#{@config.port}"
28
+
29
+ @socket = OpenSSL::SSL::SSLSocket.new(tcp_socket, ssl_context)
30
+ @socket.sync = true
31
+ @socket.connect
32
+ else
33
+ @socket = tcp_socket
34
+ end
35
+
36
+ @queue = MessageQueue.new(@socket, @bot)
37
+ message "PASS #{@config.password}" if @config.password
38
+ message "NICK #{@config.nick}"
39
+ message "USER #{@config.nick} 0 * :#{@config.realname}"
40
+
41
+ Thread.new do
42
+ while line = @socket.gets
43
+ parse line
44
+ end
45
+
46
+ @bot.dispatch(:disconnect)
47
+ end
48
+
49
+ @queue.process!
50
+ end
51
+
52
+ # @api private
53
+ # @return [void]
54
+ def parse(input)
55
+ FormattedLogger.log(input, :incoming) if @bot.config.verbose
56
+ msg = Message.new(input, @bot)
57
+ events = []
58
+ dispatch_msg = nil
59
+
60
+ if msg.command == ERR_NICKNAMEINUSE.to_s
61
+ @bot.nick = msg.params[1] + "_"
62
+ end
63
+
64
+ if ("001".."004").include? msg.command
65
+ @registration << msg.command
66
+ if registered?
67
+ events << :connect
68
+ end
69
+ elsif msg.command == "PRIVMSG"
70
+ events.concat(if msg.ctcp?
71
+ [:ctcp]
72
+ elsif msg.channel?
73
+ [:message, :channel]
74
+ else
75
+ [:message, :private]
76
+ end)
77
+
78
+ dispatch_msg = msg
79
+ elsif msg.command == "PING"
80
+ events << :ping
81
+ message "PONG :#{msg.params.first}"
82
+ else
83
+ if msg.command == "005"
84
+ require 'pp'
85
+ @isupport.parse(*msg.params[1..-2].map {|v| v.split(" ")}.flatten)
86
+ elsif [RPL_TOPIC.to_s, RPL_NOTOPIC.to_s, "TOPIC"].include?(msg.command)
87
+ topic = case msg.command
88
+ when RPL_TOPIC.to_s
89
+ msg.params[2]
90
+ when "TOPIC"
91
+ msg.params[1]
92
+ else
93
+ ""
94
+ end
95
+ msg.channel.sync(:topic, topic)
96
+ elsif msg.command == "JOIN"
97
+ if msg.user == @bot
98
+ msg.channel.sync_modes
99
+ end
100
+ msg.channel.add_user(msg.user)
101
+ elsif msg.command == "PART"
102
+ msg.channel.remove_user(msg.user)
103
+ elsif msg.command == "KICK"
104
+ msg.channel.remove_user(User.find_ensured(msg.params[1], @bot))
105
+ elsif msg.command == "KILL"
106
+ user = User.find_ensured(msg.params[1], @bot)
107
+ Channel.all.each do |channel|
108
+ channel.remove_user(user)
109
+ end
110
+ elsif msg.command == "QUIT"
111
+ Channel.all.each do |channel|
112
+ channel.remove_user(msg.user)
113
+ end
114
+ msg.user.synced = false
115
+ elsif msg.command == "NICK"
116
+ if msg.user == @bot
117
+ @bot.config.nick = msg.params.last
118
+ end
119
+
120
+ msg.user.nick = msg.params.last
121
+ elsif msg.command == "MODE"
122
+ msg.channel.sync_modes if msg.channel?
123
+ elsif msg.command == RPL_CHANNELMODEIS.to_s
124
+ modes = {}
125
+ arguments = msg.params[3..-1]
126
+ msg.params[2][1..-1].split("").each do |mode|
127
+ if (@isupport["CHANMODES"]["B"] + @isupport["CHANMODES"]["C"]).include?(mode)
128
+ modes[mode] = arguments.shift
129
+ else
130
+ modes[mode] = true
131
+ end
132
+ end
133
+
134
+ msg.channel.sync(:modes, modes, false)
135
+ elsif msg.command == RPL_NAMEREPLY.to_s
136
+ unless @in_lists.include?(:names)
137
+ msg.channel.clear_users
138
+ end
139
+ @in_lists << :names
140
+
141
+ msg.params[3].split(" ").each do |user|
142
+ if @isupport["PREFIX"].values.include?(user[0..0])
143
+ prefix = user[0..0]
144
+ nick = user[1..-1]
145
+ else
146
+ nick = user
147
+ prefix = nil
148
+ end
149
+ user = User.find_ensured(nick, @bot)
150
+ msg.channel.add_user(user, prefix)
151
+ end
152
+
153
+ elsif msg.command == RPL_WHOISUSER.to_s
154
+ user = User.find_ensured(msg.params[1], @bot)
155
+ @whois_updates[user].merge!({
156
+ :user => msg.params[2],
157
+ :host => msg.params[3],
158
+ :realname => msg.params[5],
159
+ })
160
+ elsif msg.command == RPL_WHOISACCOUNT.to_s
161
+ user = User.find_ensured(msg.params[1], @bot)
162
+ authname = msg.params[2]
163
+ @whois_updates[user].merge!({:authname => authname})
164
+ elsif msg.command == RPL_WHOISCHANNELS.to_s
165
+ user = User.find_ensured(msg.params[1], @bot)
166
+ channels = msg.params[2].scan(/#{@isupport["CHANTYPES"].join}[^ ]+/o).map {|c| Channel.find_ensured(c, @bot) }
167
+ user.sync(:channels, channels, true)
168
+ elsif msg.command == RPL_WHOISIDLE.to_s
169
+ user = User.find_ensured(msg.params[1], @bot)
170
+ @whois_updates[user].merge!({
171
+ :idle => msg.params[2].to_i,
172
+ :signed_on_at => Time.at(msg.params[3].to_i),
173
+ })
174
+ elsif msg.command == "671"
175
+ user = User.find_ensured(msg.params[1], @bot)
176
+ @whois_updates[user].merge!({:secure? => true})
177
+ elsif msg.command == ERR_NOSUCHSERVER.to_s
178
+ if user = User.find(msg.params[1]) # not _ensured, we only want a user that already exists
179
+ user.sync(:unknown?, true, true)
180
+ @whois_updates.delete user
181
+ # TODO freenode specific, test on other IRCd
182
+ end
183
+ elsif msg.command == ERR_NOSUCHNICK.to_s
184
+ user = User.find_ensured(msg.params[1], @bot)
185
+ user.sync(:unknown?, true, true)
186
+ elsif msg.command == RPL_ENDOFWHOIS.to_s
187
+ user = User.find_ensured(msg.params[1], @bot)
188
+ if @whois_updates[user].empty? && !user.attr(:unknown?, true, true)
189
+ # for some reason, we did not receive user information. one
190
+ # reason is freenode throttling WHOIS
191
+ Thread.new do
192
+ sleep 2
193
+ user.whois
194
+ end
195
+ else
196
+ {
197
+ :authname => nil,
198
+ :idle => 0,
199
+ :secure? => false,
200
+ }.merge(@whois_updates[user]).each do |attr, value|
201
+ user.sync(attr, value, true)
202
+ end
203
+
204
+ user.sync(:unknown?, false, true)
205
+ user.synced = true
206
+ @whois_updates.delete user
207
+ end
208
+ elsif msg.command == RPL_ENDOFNAMES.to_s
209
+ @in_lists.delete :names
210
+ msg.channel.mark_as_synced(:users)
211
+ elsif msg.command == RPL_BANLIST.to_s
212
+ unless @in_lists.include?(:bans)
213
+ msg.channel.bans_unsynced.clear
214
+ end
215
+ @in_lists << :bans
216
+
217
+ mask = msg.params[2]
218
+ by = User.find_ensured(msg.params[3].split("!").first, @bot)
219
+ at = Time.at(msg.params[4].to_i)
220
+
221
+ ban = Ban.new(mask, by, at)
222
+ msg.channel.bans_unsynced << ban
223
+ elsif msg.command == RPL_ENDOFBANLIST.to_s
224
+ if @in_lists.include?(:bans)
225
+ @in_lists.delete :bans
226
+ else
227
+ # we never received a ban, yet an end of list => no bans
228
+ msg.channel.bans_unsynced.clear
229
+ end
230
+
231
+ msg.channel.mark_as_synced(:bans)
232
+ end
233
+
234
+ # TODO work with strings/constants, too
235
+
236
+ if msg.error?
237
+ events << :error
238
+ else
239
+ events << msg.command.downcase.to_sym
240
+ end
241
+
242
+ dispatch_msg = msg
243
+ end
244
+
245
+ events.each do |event|
246
+ @bot.dispatch(event, dispatch_msg)
247
+ end
248
+ end
249
+
250
+ # @return [Boolean] true if we successfully registered yet
251
+ def registered?
252
+ (("001".."004").to_a - @registration).empty?
253
+ end
254
+
255
+ # Send a message.
256
+ # @return [void]
257
+ def message(msg)
258
+ @queue.queue(msg)
259
+ end
260
+ end
261
+ end
@@ -0,0 +1,96 @@
1
+ module Newton
2
+ class ISupport < Hash
3
+ @@mappings = {
4
+ %w[PREFIX] => lambda {|v|
5
+ modes, prefixes = v.match(/^\((.+)\)(.+)$/)[1..2]
6
+ h = {}
7
+ modes.split("").each_with_index do |c, i|
8
+ h[c] = prefixes[i]
9
+ end
10
+ h
11
+ },
12
+
13
+ %w[CHANTYPES] => lambda {|v| v.split("")},
14
+ %w[CHANMODES] => lambda {|v|
15
+ h = {}
16
+ h["A"], h["B"], h["C"], h["D"] = v.split(",").map {|l| l.split("")}
17
+ h
18
+ },
19
+
20
+ %w[MODES MAXCHANNELS NICKLEN MAXBANS TOPICLEN
21
+ KICKLEN CHANNELLEN CHIDLEN SILENCE AWAYLEN
22
+ MAXTARGETS WATCH] => lambda {|v| v.to_i},
23
+
24
+ %w[CHANLIMIT MAXLIST IDCHAN] => lambda {|v|
25
+ h = {}
26
+ v.split(",").each do |pair|
27
+ args, num = pair.split(":")
28
+ args.split("").each do |arg|
29
+ h[arg] = num.to_i
30
+ end
31
+ end
32
+ h
33
+ },
34
+
35
+ %w[TARGMAX] => lambda {|v|
36
+ h = {}
37
+ v.split(",").each do |pair|
38
+ name, value = pair.split(":")
39
+ h[name] = value.to_i
40
+ end
41
+ h
42
+ },
43
+
44
+ %w[NETWORK] => lambda {|v| v},
45
+ %w[STATUSMSG] => lambda {|v| v.split("")},
46
+ %w[CASEMAPPING] => lambda {|v| v.to_sym},
47
+ %w[ELIST] => lambda {|v| v.split("")},
48
+ # TODO STD
49
+ }
50
+
51
+ def initialize(*args)
52
+ super
53
+ # by setting most numeric values to "Infinity", we let the
54
+ # server truncate messages and lists while at the same time
55
+ # allowing the use of strictness=:strict for servers that don't
56
+ # support ISUPPORT (hopefully none, anyway)
57
+
58
+ self["PREFIX"] = {"o" => "@", "v" => "+"}
59
+ self["CHANTYPES"] = ["#"]
60
+ self["CHANMODES"] = {
61
+ "A" => ["b"],
62
+ "B" => ["k"],
63
+ "C" => ["l"],
64
+ "D" => %w[i m n p s t r]
65
+ }
66
+ self["MODES"] = 1
67
+ self["NICKLEN"] = Infinity
68
+ self["MAXBANS"] = Infinity
69
+ self["TOPICLEN"] = Infinity
70
+ self["KICKLEN"] = Infinity
71
+ self["CHANNELLEN"] = Infinity
72
+ self["CHIDLEN"] = 5
73
+ self["AWAYLEN"] = Infinity
74
+ self["MAXTARGETS"] = 1
75
+ self["MAXCHANNELS"] = Infinity # deprecated
76
+ self["CHANLIMIT"] = {"#" => Infinity}
77
+ self["STATUSMSG"] = ["@", "+"]
78
+ self["CASEMAPPING"] = :rfc1459
79
+ self["ELIST"] = []
80
+ end
81
+
82
+ # @api private
83
+ # @return [void]
84
+ def parse(*options)
85
+ options.each do |option|
86
+ name, value = option.split("=")
87
+ if value
88
+ proc = @@mappings.find {|key, value| key.include?(name)}
89
+ self[name] = (proc && proc[1].call(value)) || value
90
+ else
91
+ self[name] = true
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,46 @@
1
+ module Newton
2
+ class Mask
3
+ # @return [String]
4
+ attr_reader :nick
5
+ # @return [String]
6
+ attr_reader :user
7
+ # @return [String]
8
+ attr_reader :host
9
+ # @return [String]
10
+ attr_reader :mask
11
+ def initialize(mask)
12
+ @mask = mask
13
+ @nick, @user, @host = mask.match(/(.+)!(.+)@(.+)/)[1..1]
14
+ @regexp = Regexp.new(Regexp.escape(mask).gsub("\\*", ".*"))
15
+ end
16
+
17
+ # @return [Boolean]
18
+ def match(user)
19
+ mask = "%s!%s@%s" % [nick, user, host]
20
+ return mask =~ @regexp
21
+
22
+ # TODO support CIDR (freenode)
23
+ end
24
+ alias_method :=~, :match
25
+
26
+ # @return [String]
27
+ def to_s
28
+ @mask.dup
29
+ end
30
+
31
+ # @param [Ban, Mask, User, String]
32
+ # @return [Mask]
33
+ def self.from(target)
34
+ case target
35
+ when User, Ban
36
+ target.mask
37
+ when String
38
+ Mask.new(target)
39
+ when Mask
40
+ target
41
+ else
42
+ raise ArgumentError
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,162 @@
1
+ # -*- coding: utf-8 -*-
2
+ module Newton
3
+ class Message
4
+ # @return [String]
5
+ attr_accessor :raw
6
+ # @return [String]
7
+ attr_accessor :prefix
8
+ # @return [String]
9
+ attr_accessor :command
10
+ # @return [Array<String>]
11
+ attr_accessor :params
12
+
13
+
14
+ def initialize(msg, bot)
15
+ @raw = msg
16
+ @bot = bot
17
+ @matches = {:ctcp => {}, :other => {}}
18
+ parse if msg
19
+ end
20
+
21
+ # @return [Boolean] true if the message is an numeric reply (as
22
+ # opposed to a command)
23
+ def numeric_reply?
24
+ !!(@numeric_reply ||= @command.match(/^\d{3}$/))
25
+ end
26
+
27
+ # @api private
28
+ # @return [void]
29
+ def parse
30
+ match = @raw.match(/(^:(\S+) )?(\S+)(.*)/)
31
+ _, @prefix, @command, raw_params = match.captures
32
+
33
+ raw_params.strip!
34
+ if match = raw_params.match(/(?:^:| :)(.*)$/)
35
+ @params = match.pre_match.split(" ")
36
+ @params << match[1]
37
+ else
38
+ @params = raw_params.split(" ")
39
+ end
40
+ end
41
+
42
+ # @return [User] The user who sent this message
43
+ def user
44
+ return unless @prefix
45
+ nick = @prefix[/^(\S+)!/, 1]
46
+ user = @prefix[/^\S+!(\S+)@/, 1]
47
+ host = @prefix[/@(\S+)$/, 1]
48
+
49
+ return nil if nick.nil?
50
+ @user ||= User.find_ensured(user, nick, host, @bot)
51
+ end
52
+
53
+ # @return [String, nil]
54
+ def server
55
+ return unless @prefix
56
+ return if @prefix.match(/[@!]/)
57
+ @server ||= @prefix[/^(\S+)/, 1]
58
+ end
59
+
60
+ # @return [Boolean] true if the message describes an error
61
+ def error?
62
+ !!error
63
+ end
64
+
65
+ # @return [Number, nil] the numeric error code, if any
66
+ def error
67
+ @error ||= (command.to_i if numeric_reply? && command[/[45]\d\d/])
68
+ end
69
+
70
+ # @return [Boolean] true if this message was sent in a channel
71
+ def channel?
72
+ !!channel
73
+ end
74
+
75
+ # @return [Boolean] true if the message is an CTCP message
76
+ def ctcp?
77
+ params.last =~ /\001.+\001/
78
+ end
79
+
80
+ # @return [String, nil] the command part of an CTCP message
81
+ def ctcp_command
82
+ return unless ctcp?
83
+ ctcp_message.split(" ").first
84
+ end
85
+
86
+ # @return [Channel] The channel in which this message was sent
87
+ def channel
88
+ @channel ||= begin
89
+ case command
90
+ when "INVITE", RPL_CHANNELMODEIS.to_s, RPL_BANLIST.to_s
91
+ Channel.find_ensured(params[1], @bot)
92
+ when RPL_NAMEREPLY.to_s
93
+ Channel.find_ensured(params[2], @bot)
94
+ else
95
+ if params.first.start_with?("#")
96
+ Channel.find_ensured(params.first, @bot)
97
+ elsif numeric_reply? and params[1].start_with?("#")
98
+ Channel.find_ensured(params[1], @bot)
99
+ end
100
+ end
101
+ end
102
+ end
103
+
104
+ # @api private
105
+ # @return [MatchData]
106
+ def match(regexp, type)
107
+ if type == :ctcp
108
+ @matches[:ctcp][regexp] ||= ctcp_message.match(regexp)
109
+ else
110
+ @matches[:other][regexp] ||= message.match(regexp)
111
+ end
112
+ end
113
+
114
+ # @return [String, nil] the CTCP message, without \001 control characters
115
+ def ctcp_message
116
+ return unless ctcp?
117
+ params.last =~ /\001(.+)\001/
118
+ $1
119
+ end
120
+
121
+ # @return [String, nil]
122
+ def message
123
+ @message ||= begin
124
+ if error?
125
+ error.to_s
126
+ elsif regular_command?
127
+ params.last
128
+ end
129
+ end
130
+ end
131
+
132
+ # Replies to a message, automatically determining if it was a
133
+ # channel or a private message.
134
+ #
135
+ # @param [String] text the message
136
+ # @param [Boolean] prefix if prefix is true and the message was in
137
+ # a channel, the reply will be prefixed by the nickname of whoever
138
+ # send the mesage
139
+ # @return [void]
140
+ def reply(text, prefix = false)
141
+ text = text.to_s
142
+ if channel && prefix
143
+ text = text.split("\n").map {|l| "#{user.nick}: #{l}"}.join("\n")
144
+ end
145
+
146
+ (channel || user).send(text)
147
+ end
148
+
149
+ # Reply to a CTCP message
150
+ #
151
+ # @return [void]
152
+ def ctcp_reply(answer)
153
+ return unless ctcp?
154
+ @bot.raw "NOTICE #{user.nick} :\001#{ctcp_command} #{answer}\001"
155
+ end
156
+
157
+ private
158
+ def regular_command?
159
+ !numeric_reply? # a command can only be numeric or "regular"…
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,62 @@
1
+ # -*- coding: utf-8 -*-
2
+ require "thread"
3
+
4
+ module Newton
5
+ # @api private
6
+ class MessageQueue
7
+ def initialize(socket, bot)
8
+ @socket = socket
9
+ @queue = Queue.new
10
+ @time_since_last_send = nil
11
+ @bot = bot
12
+
13
+ @log = []
14
+ end
15
+
16
+ # @return [void]
17
+ def queue(message)
18
+ command = message.split(" ").first
19
+
20
+ if command == "PING"
21
+ @queue.unshift(message)
22
+ else
23
+ @queue << message
24
+ end
25
+ end
26
+
27
+ # @return [void]
28
+ def process!
29
+ while true
30
+ mps = @bot.config.messages_per_second
31
+ max_queue_size = @bot.config.server_queue_size
32
+
33
+ if @log.size > 1
34
+ time_passed = 0
35
+
36
+ @log.each_with_index do |one, index|
37
+ second = @log[index+1]
38
+ time_passed += second - one
39
+ break if index == @log.size - 2
40
+ end
41
+
42
+ messages_processed = (time_passed * mps).floor
43
+ effective_size = @log.size - messages_processed
44
+
45
+ if effective_size <= 0
46
+ @log.clear
47
+ elsif effective_size >= max_queue_size
48
+ sleep 1.0/mps
49
+ end
50
+ end
51
+
52
+ message = @queue.pop.to_s.chomp
53
+
54
+ @log << Time.now
55
+ FormattedLogger.log(message, :outgoing) if @bot.config.verbose
56
+
57
+ @time_since_last_send = Time.now
58
+ @socket.print message + "\r\n"
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1 @@
1
+ Infinity = 1.0/0.0
@@ -0,0 +1,18 @@
1
+ class Module
2
+ # @api private
3
+ def synced_attr_reader(attribute)
4
+ define_method(attribute) do
5
+ attr(attribute)
6
+ end
7
+
8
+ define_method("#{attribute}_unsynced") do
9
+ attr(attribute, false, true)
10
+ end
11
+ end
12
+
13
+ # @api private
14
+ def synced_attr_accessor(attr)
15
+ synced_attr_reader(attr)
16
+ attr_accessor(attr)
17
+ end
18
+ end
@@ -0,0 +1,19 @@
1
+ require "thread"
2
+ class Queue
3
+ def unshift(obj)
4
+ Thread.critical = true
5
+ @que.unshift obj
6
+ begin
7
+ t = @waiting.shift
8
+ t.wakeup if t
9
+ rescue ThreadError
10
+ retry
11
+ ensure
12
+ Thread.critical = false
13
+ end
14
+ begin
15
+ t.run if t
16
+ rescue ThreadError
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,24 @@
1
+ class String
2
+ def irc_downcase(mapping)
3
+ case mapping
4
+ when :rfc1459
5
+ self.tr("A-Z[]\\^", "a-z{}|~")
6
+ when :"strict-rfc1459"
7
+ self.tr("A-Z[]\\", "a-z{}|")
8
+ else
9
+ # when :ascii or unknown/nil
10
+ self.tr("A-Z", "a-z")
11
+ end
12
+ end
13
+
14
+ def irc_upcase(mapping)
15
+ case mapping
16
+ when :ascii
17
+ self.tr("a-z", "A-Z")
18
+ when :rfc1459
19
+ self.tr("a-z{}|~", "A-Z[]\\^")
20
+ when :strict-rfc1459
21
+ self.tr("a-z{}|", "A-Z[]\\")
22
+ end
23
+ end
24
+ end