slack_socket_mode_bot 0.9.2 → 0.9.3

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ecdae100a7f27d51751fe1fd77c772ca8a88cfab323266c874b996ffaed909c8
4
- data.tar.gz: 92b4827386e87485f3adf26e93de16e910a2c4f9d320ba4cc683768d067b130f
3
+ metadata.gz: a296df6a07779baa7df57823973e3d928a82859b4cc325066b7d2ece2a6a66e9
4
+ data.tar.gz: f6403d418f85da1a14b3f2c696d93bb8c09f67723e096038445b424e3460d249
5
5
  SHA512:
6
- metadata.gz: 497006f3d3854a431e29f12f6463c57ed35290e5d722de7fe7bab24a886f667483821553da14ecc904d64b6de45c3d48ae446f90ba4424c3fbcbf7c59362ec38
7
- data.tar.gz: a0e94a4812aaf022a7e62e02f0a56718ecb38cfd49002fc0dbdc7e9833fcb6520dfe799ff3d2279d9239520a615215240337261a4116c4c4ab1b7496d4239af5
6
+ metadata.gz: 441c1d5466ec9bdd9032e6761e49fd18cf56764e4b5409e021a1d6b174d8d2b7ea9b70e59f2d6aa659185146c2b49f8568ec09890c3d234b0d5107a107598efd
7
+ data.tar.gz: 0b8115dbaf7727617df6b440e824496116146850f6d554db454c23426cadec48b4efc77a0f3b72fece57fc6850f3ea7224c603b16546f17817857dcd6decea97
data/README.md CHANGED
@@ -17,11 +17,11 @@ require "logger"
17
17
 
18
18
  # Slack's Bot User OAuth Token
19
19
  # You can create this token with: https://api.slack.com/apps/ - "OAuth & Permissions" - "OAuth Tokens for Your Workspace"
20
- SLACK_BOT_TOKEN = "xoxb-..."
20
+ SLACK_BOT_TOKEN = ENV.fetch("SLACK_BOT_TOKEN") # "xoxb-...", or "test" for test mode
21
21
 
22
22
  # Slack's App-Level Token
23
23
  # You can create this token with: https://api.slack.com/apps/ - "Basic Information" - "App-Level Tokens"
24
- SLACK_APP_TOKEN = "xapp-..."
24
+ SLACK_APP_TOKEN = ENV.fetch("SLACK_APP_TOKEN") # "xapp-..."
25
25
 
26
26
  logger = Logger.new(STDOUT, level: Logger::Severity::INFO)
27
27
 
@@ -60,11 +60,39 @@ rescue Exception
60
60
  end
61
61
 
62
62
  # Start the communication
63
- bot.run
63
+ bot.run unless bot.test_mode?
64
+
65
+ # Test
66
+ if bot.test_mode?
67
+ # Stub chat.postMessage
68
+ posted = []
69
+ bot.test_mode.stub_web_api("chat.postMessage") {|data| posted << data }
70
+
71
+ # Simulate receiving an event from Slack
72
+ data = {
73
+ type: "events_api",
74
+ payload: {
75
+ event: {
76
+ type: "app_mention",
77
+ text: "hello",
78
+ channel: "C123",
79
+ }
80
+ }
81
+ }
82
+ bot.test_mode.simulate_envelope(data)
83
+
84
+ # OK if chat.postMessage was sent
85
+ ok = posted == [{ channel: "C123", text: "echo:hello" }]
86
+
87
+ puts "test: #{ ok ? "OK" : "NG" }"
88
+ end
64
89
  ```
65
90
 
66
91
  ```
67
- $ ruby examples/echo_bot.rb
92
+ $ SLACK_BOT_TOKEN=test SLACK_APP_TOKEN=test ruby examples/echo_bot.rb
93
+ test: OK
94
+
95
+ $ SLACK_BOT_TOKEN=xoxb-... SLACK_APP_TOKEN=xapp-... ruby examples/echo_bot.rb
68
96
  I, [20XX-XX-XXTXX:XX:XX.XXXXXX #XXXXXX] INFO -- : [ws:2560] websocket open
69
97
  I, [20XX-XX-XXTXX:XX:XX.XXXXXX #XXXXXX] INFO -- : [ws:2600] websocket open
70
98
  I, [20XX-XX-XXTXX:XX:XX.XXXXXX #XXXXXX] INFO -- : [ws:2640] websocket open
@@ -85,7 +113,7 @@ I, [20XX-XX-XXTXX:XX:XX.XXXXXX #XXXXXX] INFO -- : [ws:2680] events_api app_ment
85
113
 
86
114
  Connects to Slack with Socket Mode.
87
115
 
88
- * `token`: Slack's Bot User OAuth token (starting with `xoxb-`)
116
+ * `token`: Slack's Bot User OAuth token (starting with `xoxb-`), or `"test"` to build a test-mode bot (see below)
89
117
  * `app_token`: Slack's App-Level token (starting with `xapp-`)
90
118
  * `logger`: A Logger instance (optional)
91
119
  * block: Handles events received from Slack
@@ -129,3 +157,42 @@ end
129
157
 
130
158
  This method allows you to manage the main loop yourself.
131
159
  If you don't need it, you can just use `SlackSocketModeBot#run`.
160
+
161
+ ## Test mode
162
+
163
+ Passing `token: "test"` (`SlackSocketModeBot.new(token: "test")`) puts the bot in test mode.
164
+ In this mode, the bot never connects to Slack.
165
+ Instead of opening a socket, you feed it events and stub the Web API yourself.
166
+ So you can test your handler offline.
167
+
168
+ See `examples/test_echo_bot.rb` for complete examples.
169
+
170
+ ### `SlackSocketModeBot#test_mode?`
171
+
172
+ Returns whether the bot is in test mode, i.e. whether it was built with `token: "test"`.
173
+
174
+ ### `SlackSocketModeBot#test_mode`
175
+
176
+ Returns the bot's `SlackSocketModeBot::TestMode` instance in test mode, or `nil` otherwise.
177
+ All of the methods below are called on it (e.g. `bot.test_mode.simulate_envelope(...)`).
178
+
179
+ ### `SlackSocketModeBot::TestMode#stub_web_api(method, &block)`
180
+
181
+ Stubs a Web API method's response.
182
+ `bot.call(method, data)` then runs the block instead of making a real HTTP request.
183
+
184
+ An unstubbed call returns `{ ok: true }`.
185
+ When `$VERBOSE` is set, it also prints a warning.
186
+
187
+ ### `SlackSocketModeBot::TestMode#simulate_envelope(envelope)`
188
+
189
+ Delivers an envelope to the bot as if it had arrived over the socket from Slack.
190
+ You can use it to run and test your event handler synchronously.
191
+
192
+ `envelope` is a Hash (string or symbol keys) shaped like the JSON Slack sends.
193
+ `:envelope_id` is filled in automatically if omitted.
194
+
195
+ ### `SlackSocketModeBot::TestMode#reset`
196
+
197
+ Clears all registered stubs and the event-deduplication state.
198
+ Call it between test cases (for example, `setup` or `before` hooks).
@@ -1,4 +1,7 @@
1
1
  class SlackSocketModeBot::SimpleWebSocket
2
+ # Seconds to wait for the server's TCP close before dropping the socket ourselves.
3
+ CLOSE_TIMEOUT = 10
4
+
2
5
  def initialize(url)
3
6
  uri = URI.parse(url)
4
7
 
@@ -25,9 +28,9 @@ class SlackSocketModeBot::SimpleWebSocket
25
28
  end
26
29
 
27
30
  @version = nil
31
+ @close_deadline = nil # nil while open; a Time once closing
28
32
 
29
33
  @fib = Fiber.new do
30
- closed = false
31
34
  begin
32
35
  handshake = WebSocket::Handshake::Client.new(url: url)
33
36
  @write_buff = handshake.to_s.dup
@@ -42,8 +45,9 @@ class SlackSocketModeBot::SimpleWebSocket
42
45
  while msg = frame.next
43
46
  case msg.type
44
47
  when :close
45
- yield :close unless closed
46
- closed = true
48
+ was_closing = closing?
49
+ close
50
+ yield :close unless was_closing
47
51
  when :ping
48
52
  send(msg.data, type: :pong)
49
53
  when :pong
@@ -57,14 +61,15 @@ class SlackSocketModeBot::SimpleWebSocket
57
61
  end
58
62
  rescue EOFError
59
63
  ensure
60
- yield :close unless closed
61
- @io.close
64
+ yield :close unless closing?
62
65
  end
63
66
  end
64
67
 
65
68
  @fib.resume
66
69
  end
67
70
 
71
+ def closing? = !@close_deadline.nil?
72
+
68
73
  def send(data, type: :text, code: nil)
69
74
  raise "not opened yet" unless @version
70
75
  frame = WebSocket::Frame::Outgoing::Client.new(version: @version, data: data, type: type, code: code)
@@ -72,22 +77,19 @@ class SlackSocketModeBot::SimpleWebSocket
72
77
  end
73
78
 
74
79
  def close(code: 1000, reason: "")
75
- send(reason, type: :close, code: code) unless @io.closed?
80
+ return if closing? || @io.closed?
81
+ send(reason, type: :close, code: code)
82
+ @close_deadline = Time.now + CLOSE_TIMEOUT
76
83
  end
77
84
 
78
85
  def step(read_ios, write_ios)
79
- wait_readable = wait_writable = false
80
-
81
- unless @write_buff.empty?
82
- len = @io.write_nonblock(@write_buff, exception: false)
83
- case len
84
- when :wait_readable then wait_readable = true
85
- when :wait_writable then wait_writable = true
86
- else
87
- @write_buff.clear
88
- end
86
+ if @close_deadline && Time.now >= @close_deadline
87
+ @io.close unless @io.closed?
88
+ return false
89
89
  end
90
90
 
91
+ wait_readable = wait_writable = false
92
+
91
93
  while true
92
94
  read_buff = @io.read_nonblock(4096, exception: false)
93
95
  case read_buff
@@ -100,6 +102,16 @@ class SlackSocketModeBot::SimpleWebSocket
100
102
  end
101
103
  end
102
104
 
105
+ unless @write_buff.empty?
106
+ len = @io.write_nonblock(@write_buff, exception: false)
107
+ case len
108
+ when :wait_readable then wait_readable = true
109
+ when :wait_writable then wait_writable = true
110
+ else
111
+ @write_buff.clear
112
+ end
113
+ end
114
+
103
115
  read_ios << @io if wait_readable
104
116
  write_ios << @io if wait_writable
105
117
  return true
@@ -109,6 +121,7 @@ class SlackSocketModeBot::SimpleWebSocket
109
121
  @fib.raise(EOFError)
110
122
  rescue FiberError
111
123
  end
112
- return false
124
+ @io.close unless @io.closed?
125
+ false
113
126
  end
114
127
  end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ class SlackSocketModeBot
4
+ # The offline test double exposed as bot.test_mode (bot built with token: "test").
5
+ # It drives the real dispatch without touching the network, and stubs the Web API.
6
+ class TestMode
7
+ def initialize(bot)
8
+ @bot = bot
9
+ @web_api_stubs = {}
10
+ @seq = 0
11
+ end
12
+
13
+ # Forget stubs and dedup state. Returns self.
14
+ def reset
15
+ @web_api_stubs.clear
16
+ @bot.instance_variable_get(:@events).clear
17
+ self
18
+ end
19
+
20
+ # Stub the Web API response for a method; the block receives the call data.
21
+ def stub_web_api(method, &block)
22
+ @web_api_stubs[method] = block
23
+ self
24
+ end
25
+
26
+ # Called in place of the real Web API request; returns the stub's value.
27
+ def call(method, data)
28
+ stub = @web_api_stubs[method]
29
+ return stub.call(data) if stub
30
+ warn "unstubbed Web API call: #{ method }" if $VERBOSE
31
+ { ok: true }
32
+ end
33
+
34
+ # Deliver an envelope as if it arrived over the socket; returns the ACK frames.
35
+ def simulate_envelope(envelope)
36
+ envelope = JSON.parse(JSON.generate(envelope), symbolize_names: true)
37
+ envelope[:envelope_id] ||= "test-envelope-#{ @seq += 1 }"
38
+ produced = []
39
+ @bot.send(
40
+ :handle_message,
41
+ "test",
42
+ JSON.generate(envelope),
43
+ send: ->(text) { produced << JSON.parse(text, symbolize_names: true) },
44
+ close: -> {},
45
+ )
46
+ produced
47
+ end
48
+ end
49
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class SlackSocketModeBot
4
- VERSION = "0.9.2"
4
+ VERSION = "0.9.3"
5
5
  end
@@ -27,6 +27,9 @@ class SlackSocketModeBot
27
27
  # Max seconds to sleep on a 429 Retry-After; #call may run in the event loop.
28
28
  RETRY_AFTER_CAP = 60
29
29
 
30
+ # #run wakes at least this often so an empty watch set never blocks forever.
31
+ SELECT_TIMEOUT = 30
32
+
30
33
  #: (token: String, ?app_token: String, ?num_of_connections: Integer, ?debug: boolean, ?logger: Logger) { (untyped) -> untyped } -> void
31
34
  def initialize(token:, app_token: nil, num_of_connections: 4, debug: false, logger: nil, &callback)
32
35
  @token = token
@@ -36,13 +39,32 @@ class SlackSocketModeBot
36
39
  @logger = logger
37
40
  @events = {}
38
41
  @callback = callback
42
+ @test_mode = nil
43
+ if token == "test"
44
+ # Offline test double; #call is stubbed and dispatch is driven without connecting.
45
+ require_relative "slack_socket_mode_bot/test_mode"
46
+ @test_mode = TestMode.new(self)
47
+ end
48
+ # app_token and the handler come as a pair, else the bot hangs or crashes on the first event.
49
+ raise ArgumentError, "app_token is required on Socket Mode" if callback && !app_token
50
+ raise ArgumentError, "block is required on Socket Mode" if app_token && !callback
39
51
  # No app token: Web API calls only, no Socket Mode connections.
40
52
  @num_of_connections = app_token ? num_of_connections : 0
41
- replenish_connections
53
+ replenish_connections unless test_mode?
54
+ end
55
+
56
+ # The offline test double when built with token: "test", otherwise nil.
57
+ attr_reader :test_mode
58
+
59
+ #: -> bool
60
+ def test_mode?
61
+ !!@test_mode
42
62
  end
43
63
 
44
64
  #: (String method, untyped data, ?token: String) -> untyped
45
65
  def call(method, data, token: @token)
66
+ return @test_mode.call(method, data) if @test_mode
67
+
46
68
  url = URI(API_BASE + method)
47
69
  body = JSON.generate(data)
48
70
  headers = {
@@ -82,10 +104,9 @@ class SlackSocketModeBot
82
104
  end
83
105
 
84
106
  private def replenish_connections
85
- # Reopen from the main loop, tolerating a single failure; #step retries.
86
- while @conns.size < @num_of_connections
107
+ while @conns.count {|ws| !ws.closing? } < @num_of_connections
87
108
  begin
88
- add_connection(@callback)
109
+ add_connection
89
110
  rescue => e
90
111
  @logger.warn("[reconnect] failed: #{ e.message }") if @logger
91
112
  break
@@ -95,7 +116,7 @@ class SlackSocketModeBot
95
116
  raise Error, "all socket connections lost" if @num_of_connections > 0 && @conns.empty?
96
117
  end
97
118
 
98
- private def add_connection(callback)
119
+ private def add_connection
99
120
  json = call("apps.connections.open", {}, token: @app_token)
100
121
 
101
122
  url = json[:url]
@@ -108,64 +129,69 @@ class SlackSocketModeBot
108
129
  # #step drops the dead connection; #replenish_connections reopens it.
109
130
  @logger.info("[ws:#{ ws.object_id }] websocket closed") if @logger
110
131
  when :message
111
- begin
112
- json = JSON.parse(data, symbolize_names: true)
113
- rescue JSON::ParserError
114
- # A stray non-JSON frame: skip it, don't open a spurious connection.
115
- @logger.warn("[ws:#{ ws.object_id }] received a non-JSON message; ignored") if @logger
116
- next
117
- end
118
-
119
- if @logger
120
- @logger.debug("[ws:#{ ws.object_id }] slack message: #{ JSON.generate(json) }")
121
- end
122
-
123
- case json[:type]
124
- when "hello"
125
- @logger.info("[ws:#{ ws.object_id }] hello (active connections: #{ @conns.size })") if @logger
126
- when "disconnect"
127
- ws.close
128
- @logger.info("[ws:#{ ws.object_id }] disconnect (active connections: #{ @conns.size })") if @logger
129
- else
130
- payload = json[:payload]
131
- if @logger
132
- # Log a per-type identifier; event_id/retry only apply to events_api.
133
- detail =
134
- case json[:type]
135
- when "events_api" then [payload.dig(:event, :type), payload[:event_id]].compact.join(" ")
136
- when "slash_commands" then payload[:command]
137
- else payload[:type]
138
- end
139
- retry_n = json[:retry_attempt].to_i
140
- line = "[ws:#{ ws.object_id }] #{ json[:type] }"
141
- line += " #{ detail }" if detail && !detail.empty?
142
- line += " (retry ##{ retry_n })" if retry_n > 0
143
- @logger.info(line)
144
- end
145
- # Only events_api has an event_id; dedup just those (others have none).
146
- event_id = payload[:event_id]
147
- expired = Time.now.to_i - 600
148
- @events.reject! {|_, timestamp| timestamp < expired }
149
-
150
- # ACK every message; only skip the handler for a duplicate, else Slack resends.
151
- duplicate = event_id && @events[event_id]
152
- @events[event_id] = payload[:event_time] if event_id
153
-
154
- response = { envelope_id: json[:envelope_id] }
155
- unless duplicate
156
- result = callback.call(json)
157
- response[:payload] = result if json[:accepts_response_payload]
158
- end
159
- ws.send(JSON.generate(response))
160
- end
132
+ # `send` delivers a reply frame; `close` tears the socket down.
133
+ handle_message(ws.object_id, data, send: ->(text) { ws.send(text) }, close: -> { ws.close })
161
134
  end
162
135
  end
163
136
 
164
137
  @conns << ws
165
138
  end
166
139
 
140
+ # Dispatch one frame (log, dedup, run callback, ACK); send/close are injected so tests can drive it.
141
+ private def handle_message(id, data, send:, close:)
142
+ begin
143
+ json = JSON.parse(data, symbolize_names: true)
144
+ rescue JSON::ParserError
145
+ # A stray non-JSON frame: skip it, don't open a spurious connection.
146
+ @logger.warn("[ws:#{ id }] received a non-JSON message; ignored") if @logger
147
+ return
148
+ end
149
+
150
+ @logger.debug("[ws:#{ id }] slack message: #{ JSON.generate(json) }") if @logger
151
+
152
+ case json[:type]
153
+ when "hello"
154
+ @logger.info("[ws:#{ id }] hello (active connections: #{ @conns.size })") if @logger
155
+ when "disconnect"
156
+ close.call
157
+ @logger.info("[ws:#{ id }] disconnect (active connections: #{ @conns.size })") if @logger
158
+ else
159
+ payload = json[:payload]
160
+ if @logger
161
+ # Log a per-type identifier; event_id/retry only apply to events_api.
162
+ detail =
163
+ case json[:type]
164
+ when "events_api" then [payload.dig(:event, :type), payload[:event_id]].compact.join(" ")
165
+ when "slash_commands" then payload[:command]
166
+ else payload[:type]
167
+ end
168
+ retry_n = json[:retry_attempt].to_i
169
+ line = "[ws:#{ id }] #{ json[:type] }"
170
+ line += " #{ detail }" if detail && !detail.empty?
171
+ line += " (retry ##{ retry_n })" if retry_n > 0
172
+ @logger.info(line)
173
+ end
174
+ # Only events_api has an event_id; dedup just those (others have none).
175
+ event_id = payload[:event_id]
176
+ expired = Time.now.to_i - 600
177
+ @events.reject! {|_, timestamp| timestamp < expired }
178
+
179
+ # ACK every message; only skip the handler for a duplicate, else Slack resends.
180
+ duplicate = event_id && @events[event_id]
181
+ @events[event_id] = payload[:event_time] if event_id
182
+
183
+ response = { envelope_id: json[:envelope_id] }
184
+ unless duplicate
185
+ result = @callback.call(json)
186
+ response[:payload] = result if json[:accepts_response_payload]
187
+ end
188
+ send.call(JSON.generate(response))
189
+ end
190
+ end
191
+
167
192
  #: -> [Array[IO], Array[IO]]
168
193
  def step
194
+ raise Error, "not supported in test mode" if @test_mode
169
195
  read_ios, write_ios = [], []
170
196
  @conns.select! {|ws| ws.step(read_ios, write_ios) }
171
197
  replenish_connections
@@ -176,7 +202,7 @@ class SlackSocketModeBot
176
202
  def run
177
203
  while true
178
204
  read_ios, write_ios = step
179
- IO.select(read_ios, write_ios)
205
+ IO.select(read_ios, write_ios, nil, SELECT_TIMEOUT)
180
206
  end
181
207
  end
182
208
  end
@@ -11,4 +11,16 @@ class SlackSocketModeBot
11
11
  def step: () -> [ Array[IO], Array[IO] ]
12
12
 
13
13
  def run: () -> void
14
+
15
+ def test_mode?: () -> bool
16
+
17
+ def test_mode: () -> TestMode?
18
+
19
+ class TestMode
20
+ def stub_web_api: (String method) { (untyped) -> untyped } -> self
21
+
22
+ def simulate_envelope: (untyped envelope) -> Array[untyped]
23
+
24
+ def reset: () -> self
25
+ end
14
26
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: slack_socket_mode_bot
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.2
4
+ version: 0.9.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yusuke Endoh
@@ -36,8 +36,9 @@ files:
36
36
  - Rakefile
37
37
  - lib/slack_socket_mode_bot.rb
38
38
  - lib/slack_socket_mode_bot/simple_web_socket.rb
39
+ - lib/slack_socket_mode_bot/test_mode.rb
39
40
  - lib/slack_socket_mode_bot/version.rb
40
- - sig/slack_socket_mode.rbs
41
+ - sig/slack_socket_mode_bot.rbs
41
42
  homepage: "https://github.com/mame/slack_socket_mode_bot"
42
43
  licenses:
43
44
  - MIT