nakiircbot 0.2.0 → 1.0.0

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.
Files changed (4) hide show
  1. checksums.yaml +4 -4
  2. data/lib/nakiircbot.rb +216 -70
  3. data/nakiircbot.gemspec +1 -1
  4. metadata +8 -8
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 92edbf539aabbbac1e6b60cd752b5cb8051eac5cac7fb1a7f66d62fc3d150f89
4
- data.tar.gz: 2d8b504a2f9fa52e2648aaa14bac414b1d21c62072367b4f21de8fdbfbb53754
3
+ metadata.gz: 96c17ebf4d920d49e111bcc3f2d5eae3cbf5dd067df1945e32e85b088d49be84
4
+ data.tar.gz: a77837bbcd9a0d7ecc1904afa56864e232ae7d25d391e0b74a79d38bbc4a8926
5
5
  SHA512:
6
- metadata.gz: 8601bfe95fa4066b256489cf129e7371f5f879bae8959a6ae494fd1c8cf014edc03df16df9449f434053893873ee52b7d92fffc8f8c018e6ac3c5227757ea348
7
- data.tar.gz: c902841c4e88f71122ba2d0d5b32e204e337cf9d227d35fbaf2771ba450932e9ec2558ecd2f73443b8af3e7d81d6b0de9cf29272789f25872fd28a98853bd286
6
+ metadata.gz: 744091bd4b6fa48763d2f762536c012ac743997afa1705f378b77bb49cb87c64e55a05408d2c76ebb3b3a705a56127625e45d8103280a072b98cfa1fd8603438
7
+ data.tar.gz: 70f8088b001c1b23d407d47fdcbf73b4ac4d37ba230ae7b80b59a09332c4aa518c89eb5b34278d5469bd505b843426026e763aca61c8bebdc48b995cb10f1fec
data/lib/nakiircbot.rb CHANGED
@@ -1,16 +1,15 @@
1
1
  module NakiIRCBot
2
- class << self
3
- attr_accessor :queue
4
- end
5
- self.queue = []
2
+ require "base64"
3
+
4
+ @queue = Queue.new
6
5
  def self.start server, port, bot_name, master_name, welcome001, *channels, identity: nil, password: nil, masterword: nil, processors: [], tags: false
7
6
  # @@channels.replace channels.dup
8
7
 
9
8
  abort "matching bot_name and master_name may cause infinite recursion" if bot_name == master_name
10
- require "base64"
11
9
  require "fileutils"
12
10
  FileUtils.mkdir_p "logs"
13
11
  require "logger"
12
+ # TODO: check how I've implemented the logger in trovobot
14
13
  original_formatter = Logger::Formatter.new
15
14
  logger = Logger.new "logs/txt", "daily",
16
15
  progname: bot_name, datetime_format: "%y%m%d %H%M%S",
@@ -22,44 +21,82 @@ module NakiIRCBot
22
21
  logger.level = ENV["LOGLEVEL_#{name}"].to_sym if ENV.include? "LOGLEVEL_#{name}"
23
22
  puts "#{name} logger.level = #{logger.level}"
24
23
 
25
- # https://en.wikipedia.org/wiki/List_of_Internet_Relay_Chat_commands
26
- loop do
27
- logger.info "reconnect"
28
- require "socket"
29
- socket = TCPSocket.new server, port
24
+ # https://stackoverflow.com/a/49476047/322020
30
25
 
31
- # https://stackoverflow.com/a/49476047/322020
32
- socket_send = lambda do |str|
33
- logger.info "> #{str}"
26
+ require "socket"
27
+ # require "io/wait"
28
+ socket = Module.new do
29
+ @logger = logger
30
+ @server = server
31
+ @port = port
32
+
33
+ @socket = nil
34
+ def self.rescue_socket
35
+ yield
36
+ rescue SocketError, Errno::ENETDOWN, Errno::ENETUNREACH
37
+ @socket = nil
38
+ @logger.warn "exception: #{$!}, waiting 5 sec"
39
+ sleep 5
40
+ retry
41
+ end
42
+ private_class_method :rescue_socket
43
+ def self.socket
44
+ @socket ||= rescue_socket do
45
+ @logger.warn "reconnect"
46
+ TCPSocket.new(@server, @port)#.tap{ queue_thread.run }
47
+ end
48
+ end
49
+ private_class_method :socket
50
+ @buffer = ""
51
+ def self.read
52
+ until i = @buffer.index(?\n)
53
+ @buffer.concat( rescue_socket do
54
+ return unless select [socket], nil, nil, 1
55
+ @socket.read(@socket.nread).tap{ |_| raise SocketError if _.empty? }
56
+ end )
57
+ end
58
+ @buffer.slice!(0..i).chomp
59
+ end
60
+ def self.write str
34
61
  socket.send str + "\n", 0
35
62
  end
36
- # socket.send "PASS #{password.strip}\n", 0 if twitch
37
- socket_send.call "CAP REQ :sasl" if password
38
- socket_send.call "NICK #{bot_name}"
39
- socket_send.call "USER #{bot_name} #{bot_name} #{bot_name} #{bot_name}" #unless twitch
63
+ def self.log str
64
+ @logger.warn "> #{str}"
65
+ write str
66
+ end
67
+ end
68
+ prev_privmsg_time = Time.now
69
+ queue_thread = Thread.new do
70
+ Thread.current.abort_on_exception = true
71
+ # Thread.stop
72
+ loop do
73
+ sleep [prev_privmsg_time + 5 - Time.now, 0].max
74
+ addr, msg = @queue.pop
75
+ fail "I should not PRIVMSG myself" if bot_name == addr = addr.codepoints.pack("U*").tr("\x00\x0A\x0D", "")
76
+ privmsg = "PRIVMSG #{addr} :#{msg.to_s.codepoints.pack("U*").chomp[/^(\x01*)(.*)/m,2].gsub("\x00", "[NUL]").gsub("\x0A", "[LF]").gsub("\x0D", "[CR]")}"
77
+ privmsg[-4..-1] = "..." until privmsg.bytesize <= 475
78
+ prev_privmsg_time = Time.now
79
+ socket.log privmsg
80
+ end
81
+ end
40
82
 
41
- self.queue = []
83
+ # https://en.wikipedia.org/wiki/List_of_Internet_Relay_Chat_commands
84
+ loop do
85
+ # socket_log.call "CAP LS"
86
+ # https://ircv3.net/specs/extensions/sasl-3.1.html
87
+ socket.log "CAP REQ :sasl" if password
88
+ socket.write "PASS #{password.strip}" # https://dev.twitch.tv/docs/irc/authenticate-bot/
89
+ socket.log "NICK #{bot_name}"
90
+ socket.log "USER #{bot_name} #{bot_name} #{bot_name} #{bot_name}" #unless twitch
91
+
92
+ @queue.clear
42
93
  prev_socket_time = prev_privmsg_time = Time.now
43
94
  loop do
44
- begin
45
- addr, msg = self.queue.shift
46
- next unless addr && msg # TODO: how is it possible to have only one of them?
47
- addr = addr.codepoints.pack("U*").tr("\x00\x0A\x0D", "")
48
- fail "I should not PRIVMSG myself" if addr == bot_name
49
- msg = msg.to_s.codepoints.pack("U*").chomp[/^(\x01*)(.*)/m,2].gsub("\x00", "[NUL]").gsub("\x0A", "[LF]").gsub("\x0D", "[CR]")
50
- privmsg = "PRIVMSG #{addr} :#{msg}"
51
- privmsg[-4..-1] = "..." until privmsg.bytesize <= 475
52
- prev_socket_time = prev_privmsg_time = Time.now
53
- socket_send.call privmsg
54
- break
55
- end until self.queue.empty? if prev_privmsg_time + 5 < Time.now || server == "localhost"
56
-
57
- unless _ = Kernel::select([socket], nil, nil, 1)
95
+ unless socket_str = socket.read
58
96
  break if Time.now - prev_socket_time > 300
59
97
  next
60
98
  end
61
99
  prev_socket_time = Time.now
62
- socket_str = _[0][0].gets chomp: true
63
100
  break unless socket_str
64
101
  str = socket_str.force_encoding("utf-8").scrub
65
102
  if /\A:\S+ 372 /.match? str # MOTD
@@ -72,7 +109,7 @@ module NakiIRCBot
72
109
  break if /\AERROR :Closing Link: /.match? str
73
110
 
74
111
  # if str[/^:\S+ 433 * #{Regexp.escape bot_name} :Nickname is already in use\.$/]
75
- # socket_send.call "NICK #{bot_name + "_"}"
112
+ # socket_log.call "NICK #{bot_name + "_"}"
76
113
  # next
77
114
  # end
78
115
 
@@ -81,66 +118,175 @@ module NakiIRCBot
81
118
  when /\A:[a-z.]+ 001 #{Regexp.escape bot_name} :Welcome to the #{Regexp.escape welcome001} #{Regexp.escape bot_name}\z/
82
119
  # we join only when we are sure we are on the correct server
83
120
  # TODO: maybe abort if the server is wrong?
84
- next socket_send.call "JOIN #{channels.join ","}"
121
+ next socket.log "JOIN #{channels.join ","}"
85
122
 
86
123
  when /\A:tmi.twitch.tv 001 #{Regexp.escape bot_name} :Welcome, GLHF!\z/
87
- socket_send.call "JOIN #{channels.join ","}"
88
- socket_send.call "CAP REQ :twitch.tv/membership twitch.tv/tags twitch.tv/commands"
124
+ socket.log "JOIN #{channels.join ","}"
125
+ socket.log "CAP REQ :twitch.tv/membership twitch.tv/tags twitch.tv/commands"
126
+ tags = true
89
127
  next
90
128
  when /\A:NickServ!NickServ@services\. NOTICE #{Regexp.escape bot_name} :This nickname is registered. Please choose a different nickname, or identify via \x02\/msg NickServ identify <password>\x02\.\z/,
91
129
  /\A:NickServ!NickServ@services\.libera\.chat NOTICE #{Regexp.escape bot_name} :This nickname is registered. Please choose a different nickname, or identify via \x02\/msg NickServ IDENTIFY #{Regexp.escape bot_name} <password>\x02\z/
92
130
  abort "no password" unless password
93
- logger.info "password"
94
- next socket.send "PRIVMSG NickServ :identify #{bot_name} #{password.strip}\n", 0
131
+ logger.warn "password"
132
+ next socket.write "PRIVMSG NickServ :identify #{bot_name} #{password.strip}"
95
133
  # when /\A:[a-z]+\.libera\.chat CAP \* LS :/
96
- # next socket_send "CAP REQ :sasl" if $'.split.include? "sasl"
134
+ # next socket_log "CAP REQ :sasl" if $'.split.include? "sasl"
97
135
  when /\A:[a-z]+\.libera\.chat CAP \* ACK :sasl\z/
98
- next socket_send.call "AUTHENTICATE PLAIN"
136
+ next socket.log "AUTHENTICATE PLAIN"
99
137
  when /\AAUTHENTICATE \+\z/
100
- logger.info "password"
101
- next socket.send "AUTHENTICATE #{Base64.strict_encode64 "\0#{identity || bot_name}\0#{password}"}\n", 0
138
+ logger.warn "password"
139
+ next socket.write "AUTHENTICATE #{Base64.strict_encode64 "\0#{identity || bot_name}\0#{password}"}"
102
140
  when /\A:[a-z]+\.libera\.chat 903 #{bot_name} :SASL authentication successful\z/
103
- next socket_send.call "CAP END"
141
+ next socket.log "CAP END"
104
142
 
105
143
  when /\APING :/
106
- next socket.send "PONG :#{$'}\n", 0 # Quakenet uses timestamp, Freenode and Twitch use server name
144
+ next socket.write "PONG :#{$'}" # Quakenet uses timestamp, Freenode and Twitch use server name
107
145
  when /\A:([^!]+)!\S+ PRIVMSG #{Regexp.escape bot_name} :\x01VERSION\x01\z/
108
- next socket_send.call "NOTICE #{$1} :\x01VERSION name 0.0.0\x01"
146
+ next socket.log "NOTICE #{$1} :\x01VERSION name 0.0.0\x01"
109
147
  # when /^:([^!]+)!\S+ PRIVMSG #{Regexp.escape bot_name} :\001PING (\d+)\001$/
110
- # socket_send.call "NOTICE",$1,"\001PING #{rand 10000000000}\001"
148
+ # socket_log.call "NOTICE",$1,"\001PING #{rand 10000000000}\001"
111
149
  # when /^:([^!]+)!\S+ PRIVMSG #{Regexp.escape bot_name} :\001TIME\001$/
112
- # socket_send.call "NOTICE",$1,"\001TIME 6:06:06, 6 Jun 06\001"
113
- when /\A#{'\S+ ' if tags}:(?<who>[^!]+)!\S+ PRIVMSG (?<where>\S+) :(?<what>.+)/
114
- next( if processors.empty?
115
- self.queue.push [master_name, "nothing to reload"]
116
- else
117
- processors.each do |processor|
118
- self.queue.push [master_name, "reloading #{processor}"]
119
- load File.absolute_path processor
120
- end
121
- end ) if $~.named_captures == {"who"=>master_name, "where"=>bot_name, "what"=>"#{masterword.strip} reload"}
150
+ # socket_log.call "NOTICE",$1,"\001TIME 6:06:06, 6 Jun 06\001"
122
151
  end
123
152
 
124
153
  begin
125
- yield str, ->(where, what){ self.queue.push [where, what] }
126
- rescue => e
127
- puts e.full_message
128
- self.queue.push [master_name, "yield error: #{e}"]
154
+ yield str,
155
+ ->(where, what){ @queue.push [where, what] },
156
+ ->(new_password){ password.replace new_password; socket.instance_variable_set :@socket, nil },
157
+ */\A#{'\S+ ' if tags}:(?<who>[^!]+)!\S+ PRIVMSG (?<where>\S+) :(?<what>.+)/.match(str).to_a.drop(1).tap{ |who, where, what|
158
+ logger.warn "#{where} <#{who}> #{what}" if what
159
+ }
160
+ rescue
161
+ puts $!.full_message
162
+ @queue.push ["##{bot_name}", "error: #{$!}, #{$!.backtrace.first}"]
129
163
  end
130
164
 
131
- rescue => e
132
- puts e.full_message
133
- case e
134
- when Errno::ECONNRESET, Errno::ECONNABORTED, Errno::ETIMEDOUT, Errno::EPIPE
165
+ rescue
166
+ puts $!.full_message
135
167
  sleep 5
136
- break
137
- else
138
- self.queue.push [master_name, "unhandled error: #{e}"]
139
- sleep 5
140
- end
168
+ raise
141
169
  end
142
170
 
143
171
  end
144
172
 
145
173
  end
174
+
175
+ module Common
176
+ def self.ping add_to_queue, what
177
+ return add_to_queue.call what[1..-1].tr "iI", "oO" if "\\ping" == what.downcase
178
+ return add_to_queue.call what[1..-1].tr "иИ", "оO" if "\\пинг" == what.downcase
179
+ end
180
+ end
181
+
182
+ def self.parse_log path, bot_name
183
+ require "time"
184
+ get_tags = lambda do |str|
185
+ str[1..-1].split(?;).map do |pair|
186
+ (a, b) = pair.split ?=
187
+ fail if a.empty?
188
+ [a, b]
189
+ end.to_h
190
+ end
191
+ File.new(path).each(chomp: true).drop(1).map do |line|
192
+ case line
193
+ when /\AD, /
194
+ when /\A[IW], \[(\S+) #\d+\] (?:INFO|WARN) -- #{bot_name}: (.+)\z/
195
+ _ = Base64.decode64($2).force_encoding "utf-8"
196
+ [
197
+ DateTime.parse($1).to_time,
198
+ *case _
199
+ when /\A> /,
200
+ "< :tmi.twitch.tv 002 #{bot_name} :Your host is tmi.twitch.tv",
201
+ "< :tmi.twitch.tv 003 #{bot_name} :This server is rather new",
202
+ "< :tmi.twitch.tv 004 #{bot_name} :-",
203
+ "< :tmi.twitch.tv 375 #{bot_name} :-",
204
+ "< :tmi.twitch.tv 376 #{bot_name} :>",
205
+ /\A< :#{bot_name}!#{bot_name}@#{bot_name}\.tmi\.twitch\.tv JOIN #[a-z\d_]+\z/,
206
+ /\A< :#{bot_name}\.tmi\.twitch\.tv 353 #{bot_name} /,
207
+ /\A< :#{bot_name}\.tmi\.twitch\.tv 366 #{bot_name} /,
208
+ "< :tmi.twitch.tv CAP * ACK :twitch.tv/membership twitch.tv/tags twitch.tv/commands",
209
+ "< :tmi.twitch.tv CAP * NAK :sasl",
210
+ "< :tmi.twitch.tv NOTICE * :Improperly formatted auth",
211
+ "< :tmi.twitch.tv RECONNECT"
212
+ when /\A< (\S+) :tmi\.twitch\.tv USERSTATE ##{bot_name}\z/ # wtf?
213
+ when "reconnect",
214
+ "< :tmi.twitch.tv 001 #{bot_name} :Welcome, GLHF!"
215
+ [nil, "RECONNECT"]
216
+ when /\A< :([^\s!]+)!\1@\1\.tmi\.twitch\.tv (JOIN|PART) #([a-z\d_]+)\z/
217
+ [$3, $2, $1]
218
+ when /\A< (?:\S+ )?:([^\s!]+)!\1@\1\.tmi\.twitch\.tv PRIVMSG #([a-z\d_]+) :((?:\S.*)?\S)\z/
219
+ [$2, "PRIVMSG", $1, $3]
220
+ when /\A< (\S+) :tmi\.twitch\.tv CLEARMSG #([a-z\d_]+) :((?:\S.*)?\S)\z/
221
+ [$2, "CLEARMSG", get_tags[$1].fetch("login"), $3]
222
+ when /\A< (\S+) :tmi\.twitch\.tv CLEARCHAT #([a-z\d_]+) :([^\s!]+)\z/
223
+ [$2, "CLEARCHAT", $3, get_tags[$1].fetch("target-user-id")]
224
+ when /\A< @emote-only=0;room-id=\d+ :tmi\.twitch\.tv ROOMSTATE #([a-z\d_]+)\z/
225
+ [$1, "ROOMSTATE EMOTEONLY 0"]
226
+ when /\A< @emote-only=1;room-id=\d+ :tmi\.twitch\.tv ROOMSTATE #([a-z\d_]+)\z/
227
+ [$1, "ROOMSTATE EMOTEONLY 1"]
228
+ when /\A< @msg-id=emote_only_off :tmi\.twitch\.tv NOTICE #([a-z\d_]+) :This room is no longer in emote-only mode\.\z/
229
+ [$1, "EMOTE_ONLY_OFF"]
230
+ when /\A< @msg-id=emote_only_on :tmi\.twitch\.tv NOTICE #([a-z\d_]+) :This room is now in emote-only mode\.\z/
231
+ [$1, "EMOTE_ONLY_ON"]
232
+ when /\A< @followers-only=-1;room-id=\d+ :tmi\.twitch\.tv ROOMSTATE #([a-z\d_]+)\z/
233
+ [$1, "ROOMSTATE FOLLOWERSONLY 0"]
234
+ when /\A< @msg-id=followers_off :tmi\.twitch\.tv NOTICE #([a-z\d_]+) :This room is no longer in followers-only mode\.\z/
235
+ [$1, "FOLLOWERS_ONLY_OFF"]
236
+ when /\A< :tmi\.twitch\.tv HOSTTARGET #([a-z\d_]+) :(\S+) (\d+)\z/
237
+ next if "-" == $2 # wtf?
238
+ fail unless $2 == $2.downcase
239
+ [$1, "HOST", $2, $3.to_i]
240
+ when /\A< @msg-id=host_target_went_offline :tmi\.twitch\.tv NOTICE #([a-z\d_]+) :(\S+) has gone offline\. Exiting host mode\.\z/
241
+ fail unless $2 == $2.downcase
242
+ [$1, "HOST_TARGET_WENT_OFFLINE", $2]
243
+ when /\A< @msg-id=host_on :tmi\.twitch\.tv NOTICE #([a-z\d_]+) :Now hosting (\S+)\.\z/
244
+ [$1, "NOTICE HOST", $2]
245
+ when /\A< (\S+) :tmi\.twitch\.tv USERNOTICE #([a-z\d_]+)(?: :((?:\S.*)?\S))?\z/
246
+ tags = get_tags[$1]
247
+ fail unless tags.fetch("display-name").downcase == tags.fetch("login")
248
+ [
249
+ $2,
250
+ tags["msg-id"].upcase,
251
+ *case tags.fetch "msg-id"
252
+ when "raid"
253
+ fail if $3
254
+ [tags.fetch("display-name"), tags.fetch("msg-param-viewerCount").to_i.tap{ |_| fail unless _ > 0 }]
255
+ when "resub"
256
+ [tags.fetch("display-name"), *$3]
257
+ when "sub"
258
+ fail if $3
259
+ [tags.fetch("display-name")]
260
+ when "submysterygift"
261
+ # fail unless tags["msg-param-mass-gift-count"] == "1"
262
+ # fail unless tags["msg-param-sender-count"] == "1"
263
+ fail if $3
264
+ [tags.fetch("display-name"), tags.fetch("msg-param-mass-gift-count")]
265
+ when "subgift"
266
+ fail unless "1" == tags.fetch("msg-param-gift-months")
267
+ # fail unless tags["msg-param-months"] == "1"
268
+ fail if $3
269
+ [tags.fetch("display-name")]
270
+ when "bitsbadgetier"
271
+ fail unless $3
272
+ [tags.fetch("display-name")]
273
+ when "primepaidupgrade"
274
+ fail if $3
275
+ [tags.fetch("display-name")]
276
+ when "viewermilestone"
277
+ fail if $3
278
+ [tags.fetch("display-name")]
279
+ else
280
+ fail "unknown USERNOTICE: #{[tags["msg-id"], _, $3].inspect}"
281
+ end
282
+ ]
283
+ else
284
+ fail "bad log line: #{_.inspect}"
285
+ end
286
+ ]
287
+ else
288
+ fail line.inspect
289
+ end
290
+ end.compact.tap{ |_| fail unless 1 == _.map(&:first).map(&:day).uniq.size }
291
+ end
146
292
  end
data/nakiircbot.gemspec CHANGED
@@ -1,6 +1,6 @@
1
1
  Gem::Specification.new do |spec|
2
2
  spec.name = "nakiircbot"
3
- spec.version = "0.2.0"
3
+ spec.version = "1.0.0"
4
4
  spec.summary = "IRC bot framework"
5
5
 
6
6
  spec.author = "Victor Maslov aka Nakilon"
metadata CHANGED
@@ -1,16 +1,16 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: nakiircbot
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Victor Maslov aka Nakilon
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-03-26 00:00:00.000000000 Z
11
+ date: 2023-05-07 00:00:00.000000000 Z
12
12
  dependencies: []
13
- description:
13
+ description:
14
14
  email: nakilon@gmail.com
15
15
  executables: []
16
16
  extensions: []
@@ -19,12 +19,12 @@ files:
19
19
  - LICENSE
20
20
  - lib/nakiircbot.rb
21
21
  - nakiircbot.gemspec
22
- homepage:
22
+ homepage:
23
23
  licenses:
24
24
  - MIT
25
25
  metadata:
26
26
  source_code_uri: https://github.com/nakilon/nakiircbot
27
- post_install_message:
27
+ post_install_message:
28
28
  rdoc_options: []
29
29
  require_paths:
30
30
  - lib
@@ -39,8 +39,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
39
39
  - !ruby/object:Gem::Version
40
40
  version: '0'
41
41
  requirements: []
42
- rubygems_version: 3.2.22
43
- signing_key:
42
+ rubygems_version: 3.1.4
43
+ signing_key:
44
44
  specification_version: 4
45
45
  summary: IRC bot framework
46
46
  test_files: []