twitch-bot 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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