twitch-bot 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.
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Twitch
4
+ module Bot
5
+ # This class parses the tags portion in an IRC message.
6
+ # rubocop:disable Layout/LineLength
7
+ # <message> ::= ['@' <tags> <SPACE>] [':' <prefix> <SPACE> ] <command> <params> <crlf>
8
+ # <tags> ::= <tag> [';' <tag>]*
9
+ # <tag> ::= <key> ['=' <escaped_value>]
10
+ # <key> ::= [ <client_prefix> ] [ <vendor> '/' ] <key_name>
11
+ # <client_prefix> ::= '+'
12
+ # <key_name> ::= <non-empty sequence of ascii letters, digits, hyphens ('-')>
13
+ # <escaped_value> ::= <sequence of zero or more utf8 characters except NUL, CR, LF, semicolon (`;`) and SPACE>
14
+ # <vendor> ::= <host>
15
+ # rubocop:enable Layout/LineLength
16
+ class IrcMessageTags
17
+ attr_reader :tags
18
+
19
+ def initialize(raw_tags)
20
+ @raw_tags = raw_tags
21
+ @tags = parse
22
+ end
23
+
24
+ def [](key)
25
+ @tags[key]
26
+ end
27
+
28
+ def include?(key)
29
+ @tags.key?(key)
30
+ end
31
+
32
+ def numeric_state(key, name, off_value:)
33
+ return unless tags.key?(key)
34
+
35
+ case tags[key]
36
+ when off_value
37
+ "#{name}_off".to_sym
38
+ else
39
+ name.to_sym
40
+ end
41
+ end
42
+
43
+ def boolean_state(key, name)
44
+ return unless tags.key?(key)
45
+
46
+ case tags[key]
47
+ when "1"
48
+ name
49
+ when "0"
50
+ "#{name}_off".to_sym
51
+ else
52
+ raise "Unsupported value of '#{key}'"
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ attr_reader :raw_tags
59
+
60
+ def parse
61
+ return unless raw_tags
62
+
63
+ raw_tags.
64
+ split(";").
65
+ map { |key_value| key_value.split("=", 2) }.
66
+ to_h
67
+ end
68
+ end
69
+
70
+ # This class parses the params portion of an IRC message.
71
+ class IrcMessageParams
72
+ attr_reader :params
73
+
74
+ def initialize(raw_params)
75
+ @raw_params = raw_params.strip
76
+ @params = parse
77
+ end
78
+
79
+ private
80
+
81
+ attr_reader :raw_params
82
+
83
+ def parse
84
+ if (match = raw_params.match(/(?:^:| :)(.*)$/))
85
+ params = match.pre_match.split(" ")
86
+ params << match[1]
87
+ else
88
+ raw_params.split(" ")
89
+ end
90
+ end
91
+ end
92
+
93
+ # This class splits an IRC message into its basic parts.
94
+ # see https://ircv3.net/specs/extensions/message-tags.html#format
95
+ #
96
+ # rubocop:disable Layout/LineLength
97
+ # <message> ::= ['@' <tags> <SPACE>] [':' <prefix> <SPACE> ] <command> <params> <crlf>
98
+ # rubocop:enable Layout/LineLength
99
+ class IrcMessage
100
+ attr_reader :tags, :prefix, :command, :params
101
+
102
+ def initialize(msg)
103
+ raw_tags, @prefix, @command, raw_params = msg.match(
104
+ /^(?:@(\S+) )?(?::(\S+) )?(\S+)(.*)/,
105
+ ).captures
106
+
107
+ @tags = IrcMessageTags.new(raw_tags)
108
+ @params = IrcMessageParams.new(raw_params).params
109
+ end
110
+
111
+ def error
112
+ command[/[45]\d\d/] ? command.to_i : 0
113
+ end
114
+
115
+ def error?
116
+ error.positive?
117
+ end
118
+
119
+ def target
120
+ channel || user
121
+ end
122
+
123
+ def text
124
+ if error?
125
+ error.to_s
126
+ else
127
+ params.last
128
+ end
129
+ end
130
+
131
+ def user
132
+ return unless prefix
133
+
134
+ prefix[/^(\S+)!/, 1]
135
+ end
136
+
137
+ # Not really a :reek:NilCheck
138
+ def channel
139
+ params.detect { |param| param.start_with?("#") }&.delete("#")
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Twitch
4
+ module Bot
5
+ # This class calls the parser related to the IRC command we received.
6
+ class MessageParser
7
+ def initialize(irc_message)
8
+ @irc_message = irc_message
9
+ end
10
+
11
+ def message
12
+ parse_command
13
+ end
14
+
15
+ private
16
+
17
+ attr_reader :irc_message
18
+
19
+ def parse_command
20
+ command_parser = {
21
+ "MODE" => ModeCommandParser,
22
+ "PING" => PingCommandParser,
23
+ "372" => AuthenticatedCommandParser,
24
+ "366" => JoinCommandParser,
25
+ "PRIVMSG" => PrivMsgCommandParser,
26
+ "ROOMSTATE" => RoomStateCommandParser,
27
+ "NOTICE" => NoticeCommandParser,
28
+ }
29
+ parser = command_parser[irc_message.command]
30
+ if parser
31
+ parser.new(irc_message).call
32
+ else
33
+ NotSupportedMessage.new(irc_message)
34
+ end
35
+ end
36
+ end
37
+
38
+ # Abstract base class for a parser for a specific IRC command.
39
+ class CommandParser
40
+ attr_reader :message
41
+
42
+ def initialize(message)
43
+ @message = message
44
+ end
45
+ end
46
+
47
+ # Parses a PING IRC command
48
+ class PingCommandParser < CommandParser
49
+ def call
50
+ PingMessage.new(message)
51
+ end
52
+ end
53
+
54
+ # Parses a PRIVMSG IRC command
55
+ class PrivMsgCommandParser < CommandParser
56
+ def call
57
+ if message.user == "twitchnotify"
58
+ if message.text.match?(/just subscribed!/)
59
+ SubscriptionMessage.new(message)
60
+ else
61
+ NotSupportedMessage.new(message)
62
+ end
63
+ else
64
+ ChatMessageMessage.new(message)
65
+ end
66
+ end
67
+ end
68
+
69
+ # Parses a MODE IRC command
70
+ class ModeCommandParser < CommandParser
71
+ def call
72
+ ModeMessage.new(message)
73
+ end
74
+ end
75
+
76
+ # Parses a 372 IRC status code/command.
77
+ class AuthenticatedCommandParser < CommandParser
78
+ def call
79
+ AuthenticatedMessage.new(message)
80
+ end
81
+ end
82
+
83
+ # Parses a 366 IRC status code/command.
84
+ class JoinCommandParser < CommandParser
85
+ def call
86
+ JoinMessage.new(message)
87
+ end
88
+ end
89
+
90
+ # Parses a ROOMSTATE IRC command.
91
+ class RoomStateCommandParser < CommandParser
92
+ def call
93
+ roomstate_tags = {
94
+ "slow" => SlowModeMessage,
95
+ "followers-only" => FollowersOnlyModeMessage,
96
+ "subs-only" => SubsOnlyModeMessage,
97
+ "r9k" => R9kModeMessage,
98
+ }
99
+
100
+ roomstate_tags.each do |tag, event|
101
+ if message.tags.include?(tag)
102
+ return event.new(message)
103
+ end
104
+ end
105
+
106
+ NotSupportedMessage.new(message)
107
+ end
108
+ end
109
+
110
+ # Parses a NOTICE IRC command.
111
+ class NoticeCommandParser < CommandParser
112
+ def call
113
+ if message.params.last.match?(/Login authentication failed/)
114
+ LoginFailedMessage.new(message)
115
+ else
116
+ NotSupportedMessage.new(message)
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ # TODO: Use namespace
4
+
5
+ module Twitch
6
+ module Bot
7
+ # This class is the abstract base class for IRC events.
8
+ class TwitchMessage < Twitch::Bot::Event
9
+ def initialize(_message)
10
+ @type = :unknown
11
+ end
12
+ end
13
+
14
+ # This class represents an event that is not supported.
15
+ class NotSupportedMessage < TwitchMessage
16
+ attr_reader :message
17
+
18
+ def initialize(message)
19
+ @message = message
20
+ @type = :not_supported
21
+ end
22
+ end
23
+
24
+ # This class stores the details of a Ping event.
25
+ class PingMessage < TwitchMessage
26
+ attr_reader :hostname, :user
27
+
28
+ def initialize(message)
29
+ @user = message.user
30
+ @hostname = message.params.last
31
+ @type = :ping
32
+ end
33
+ end
34
+
35
+ # This class stores the details of a Mode event.
36
+ class ModeMessage < TwitchMessage
37
+ attr_reader :user, :mode
38
+
39
+ MODE_CHANGE = {
40
+ "+o" => :add_moderator,
41
+ "-o" => :remove_moderator,
42
+ }.freeze
43
+ def initialize(message)
44
+ params = message.params
45
+ @user = params.last
46
+ @mode = MODE_CHANGE[params[1]]
47
+ @type = :mode
48
+ end
49
+ end
50
+
51
+ # This class stores the details of an Authenticated event.
52
+ class AuthenticatedMessage < TwitchMessage
53
+ def initialize(_message)
54
+ @type = :authenticated
55
+ end
56
+ end
57
+
58
+ # This class stores the details of a Join event.
59
+ class JoinMessage < TwitchMessage
60
+ def initialize(_message)
61
+ @type = :join
62
+ end
63
+ end
64
+
65
+ # This class stores the details of a Subscription event.
66
+ class SubscriptionMessage < TwitchMessage
67
+ attr_reader :user
68
+
69
+ def initialize(message)
70
+ @user = message.params.last.split(" ").first
71
+ @type = :subscription
72
+ end
73
+ end
74
+
75
+ # This class stores the details of a ChatMessage event.
76
+ class ChatMessageMessage < TwitchMessage
77
+ attr_reader :text, :user
78
+
79
+ def initialize(message)
80
+ @text = message.text
81
+ @user = message.user
82
+ @type = :chat_message
83
+ end
84
+
85
+ def bot_command?(command)
86
+ text.split(/\s+/).first.match?(/^!#{command}/)
87
+ end
88
+ end
89
+
90
+ # This class stores the details of a LoginFailed event.
91
+ class LoginFailedMessage < TwitchMessage
92
+ attr_reader :user
93
+
94
+ def initialize(message)
95
+ @user = message.user
96
+ @type = :login_failed
97
+ end
98
+ end
99
+
100
+ # This class stores the details of a SlowMode event.
101
+ class SlowModeMessage < TwitchMessage
102
+ attr_reader :status, :channel
103
+
104
+ def initialize(message)
105
+ @status = message.tags["slow"]
106
+ @channel = message.channel
107
+ @type = :slow_mode
108
+ end
109
+
110
+ def enabled?
111
+ status.to_i.positive?
112
+ end
113
+ end
114
+
115
+ # This class stores the details of a FollowersOnlyMode event.
116
+ class FollowersOnlyModeMessage < TwitchMessage
117
+ attr_reader :status
118
+
119
+ def initialize(message)
120
+ @status = message.tags["followers-only"]
121
+ @type = :followers_only_mode
122
+ end
123
+ end
124
+
125
+ # This class stores the details of a SubsOnlyMode event.
126
+ class SubsOnlyModeMessage < TwitchMessage
127
+ attr_reader :status, :channel
128
+
129
+ def initialize(message)
130
+ @status = message.tags["subs-only"]
131
+ @channel = message.channel
132
+ @type = :subs_only_mode
133
+ end
134
+ end
135
+
136
+ # This class stores the details of a R9kMode event.
137
+ class R9kModeMessage < TwitchMessage
138
+ attr_reader :status, :channel
139
+
140
+ def initialize(message)
141
+ @status = message.tags["r9k"]
142
+ @channel = message.channel
143
+ @type = :r9k_mode
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Twitch
4
+ module Bot
5
+ VERSION = "1.0.0"
6
+ end
7
+ end
data/lib/twitch/bot.rb ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "bot/version"
4
+ require_relative "bot/event"
5
+ require_relative "bot/event_handler"
6
+ require_relative "bot/twitch_message"
7
+ require_relative "bot/irc_message"
8
+ require_relative "bot/message_parser"
9
+ require_relative "bot/channel"
10
+ require_relative "bot/connection"
11
+ require_relative "bot/client"
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pry-byebug"
4
+ require_relative "../lib/twitch/bot"
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ describe Twitch::Bot::Client do
4
+ let!(:client) do
5
+ connection = Twitch::Bot::Connection.new(
6
+ nickname: "test", password: "test",
7
+ )
8
+ described_class.new(connection: connection)
9
+ end
10
+
11
+ describe "#trigger" do
12
+ it "responds to a Ping message" do
13
+ ping_message_fake = Struct.new(:type, :hostname, :user)
14
+ message = ping_message_fake.new(:ping, "test.twitch", nil)
15
+ allow(client).to receive(:send_data)
16
+
17
+ client.trigger(message)
18
+
19
+ expect(client).to have_received(:send_data)
20
+ end
21
+
22
+ it "responds to an Authenticate message" do
23
+ fake_message_class = Struct.new(:type)
24
+ message = fake_message_class.new(:authenticated)
25
+ allow(client).to receive(:join_default_channel)
26
+
27
+ client.trigger(message)
28
+
29
+ expect(client).to have_received(:join_default_channel)
30
+ end
31
+
32
+ it "responds to a +o message" do
33
+ fake_message_class = Struct.new(:type, :user, :mode)
34
+ message = fake_message_class.new(:mode, "Test", :add_moderator)
35
+ allow(client).to receive(:add_moderator)
36
+
37
+ client.trigger(message)
38
+
39
+ expect(client).to have_received(:add_moderator)
40
+ end
41
+
42
+ it "responds to a -o message" do
43
+ fake_message_class = Struct.new(:type, :user, :mode)
44
+ message = fake_message_class.new(:mode, "Test", :remove_moderator)
45
+ allow(client).to receive(:remove_moderator)
46
+
47
+ client.trigger(message)
48
+
49
+ expect(client).to have_received(:remove_moderator)
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ describe Twitch::Bot::MessageParser do
4
+ context "when we receive a PRIVMSG message" do
5
+ it "parses a chat message event" do
6
+ irc_message = Twitch::Bot::IrcMessage.new(<<~RAW)
7
+ :enotpoloskun!enotpoloskun@enotpoloskun.tmi.twitch.tv PRIVMSG #enotpoloskun :BibleThump
8
+ RAW
9
+
10
+ event = described_class.new(irc_message).message
11
+
12
+ expect(event.type).to eq :chat_message
13
+ expect(event.text).to eq "BibleThump"
14
+ expect(event.user).to eq "enotpoloskun"
15
+ end
16
+
17
+ it "parses a slow_mode enable event" do
18
+ irc_message = Twitch::Bot::IrcMessage.new(<<~RAW)
19
+ @room-id=117474239;slow=10 :tmi.twitch.tv ROOMSTATE #alexwayfer
20
+ RAW
21
+
22
+ event = described_class.new(irc_message).message
23
+
24
+ expect(event.type).to eq :slow_mode
25
+ expect(event.status).to eq "10"
26
+ expect(event.channel).to eq "alexwayfer"
27
+ end
28
+
29
+ it "parses a slow mode disable event" do
30
+ irc_message = Twitch::Bot::IrcMessage.new(<<~RAW)
31
+ @room-id=117474239;slow=0 :tmi.twitch.tv ROOMSTATE #alexwayfer
32
+ RAW
33
+
34
+ event = described_class.new(irc_message).message
35
+
36
+ expect(event.type).to eq :slow_mode
37
+ expect(event.status).to eq "0"
38
+ expect(event.channel).to eq "alexwayfer"
39
+ end
40
+
41
+ it "parses an r9k mode enable event" do
42
+ irc_message = Twitch::Bot::IrcMessage.new(<<~RAW)
43
+ @r9k=1;room-id=117474239 :tmi.twitch.tv ROOMSTATE #alexwayfer
44
+ RAW
45
+
46
+ event = described_class.new(irc_message).message
47
+
48
+ expect(event.type).to eq :r9k_mode
49
+ expect(event.status).to eq "1"
50
+ expect(event.channel).to eq "alexwayfer"
51
+ end
52
+
53
+ it "parses an r9k mode disable event" do
54
+ irc_message = Twitch::Bot::IrcMessage.new(<<~RAW)
55
+ @r9k=0;room-id=117474239 :tmi.twitch.tv ROOMSTATE #alexwayfer
56
+ RAW
57
+
58
+ event = described_class.new(irc_message).message
59
+
60
+ expect(event.type).to eq :r9k_mode
61
+ expect(event.status).to eq "0"
62
+ expect(event.channel).to eq "alexwayfer"
63
+ end
64
+
65
+ it "parses a subs-only mode enable event" do
66
+ irc_message = Twitch::Bot::IrcMessage.new(<<~RAW)
67
+ @room-id=128644134;subs-only=1 :tmi.twitch.tv ROOMSTATE #sad_satont
68
+ RAW
69
+
70
+ event = described_class.new(irc_message).message
71
+
72
+ expect(event.type).to eq :subs_only_mode
73
+ expect(event.channel).to eq "sad_satont"
74
+ end
75
+
76
+ it "parses a subs-only mode disable event" do
77
+ irc_message = Twitch::Bot::IrcMessage.new(<<~RAW)
78
+ @room-id=128644134;subs-only=0 :tmi.twitch.tv ROOMSTATE #sad_satont
79
+ RAW
80
+
81
+ event = described_class.new(irc_message).message
82
+
83
+ expect(event.type).to eq :subs_only_mode
84
+ expect(event.channel).to eq "sad_satont"
85
+ end
86
+
87
+ it "handles a subscription event" do
88
+ irc_message = Twitch::Bot::IrcMessage.new(<<~RAW)
89
+ :twitchnotify!twitchnotify@twitchnotify.tmi.twitch.tv PRIVMSG #enotpoloskun :enotpoloskun just subscribed!
90
+ RAW
91
+
92
+ event = described_class.new(irc_message).message
93
+
94
+ expect(event.type).to eq :subscription
95
+ expect(event.user).to eq "enotpoloskun"
96
+ end
97
+ end
98
+
99
+ it "handles a MODE event" do
100
+ irc_message = Twitch::Bot::IrcMessage.new(<<~RAW)
101
+ :jtv MODE #enotpoloskun +o enotpoloskun
102
+ RAW
103
+
104
+ event = described_class.new(irc_message).message
105
+
106
+ expect(event.user).to eq "enotpoloskun"
107
+ expect(event.type).to eq :mode
108
+ end
109
+
110
+ it "parses a PING event" do
111
+ host = "tmi.twitch.tv"
112
+ irc_message = Twitch::Bot::IrcMessage.new(<<~RAW)
113
+ PING :#{host}
114
+ RAW
115
+
116
+ event = described_class.new(irc_message).message
117
+
118
+ expect(event.user).to eq nil
119
+ expect(event.type).to eq :ping
120
+ expect(event.hostname).to eq host
121
+ end
122
+
123
+ it "parses a NOTIFY event" do
124
+ irc_message = Twitch::Bot::IrcMessage.new(<<~RAW)
125
+ :tmi.twitch.tv NOTICE * :Login authentication failed
126
+ RAW
127
+
128
+ event = described_class.new(irc_message).message
129
+
130
+ expect(event.user).to eq nil
131
+ expect(event.type).to eq :login_failed
132
+ end
133
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/twitch/bot/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "twitch-bot"
7
+ spec.version = Twitch::Bot::VERSION
8
+ spec.authors = ["Jochen Lillich"]
9
+ spec.email = ["contact@geewiz.dev"]
10
+ spec.summary = <<~SUMMARY
11
+ twitch-bot is a Twitch chat client that uses Twitch IRC that can be used as a Twitch chat bot engine.
12
+ SUMMARY
13
+ spec.description = <<~DESC
14
+ twitch-bot is a Twitch chat client that uses Twitch IRC.
15
+ With the help of this library you can connect to any Twitch channel and handle chat events.
16
+ DESC
17
+ spec.homepage = "https://github.com/geewiz/twitch-bot"
18
+ spec.license = "MIT"
19
+
20
+ spec.files = `git ls-files -z`.split("\x0")
21
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
22
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
23
+ spec.require_paths = ["lib"]
24
+
25
+ spec.add_development_dependency "bundler", "~> 2.0"
26
+ spec.add_development_dependency "freistil-rubocop"
27
+ spec.add_development_dependency "guard", "~> 2.16"
28
+ spec.add_development_dependency "guard-rspec", "~> 4.7"
29
+ spec.add_development_dependency "pry-byebug", "~> 3.0"
30
+ spec.add_development_dependency "rake", "~> 13.0"
31
+ spec.add_development_dependency "reek"
32
+ spec.add_development_dependency "rspec", "~> 3.0"
33
+ spec.add_development_dependency "rubocop"
34
+ spec.add_development_dependency "solargraph"
35
+ spec.add_development_dependency "terminal-notifier-guard"
36
+ end