slack_socket_mode_bot 0.9.0 → 0.9.2

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: ea84a2e36d8b349ef867322ad4e932703dc5fa7eea1397f693268977aecfb001
4
- data.tar.gz: 1802e47527f56357fa5a3d564f61f91a6a3f3741bb107227dad18be8beedb08f
3
+ metadata.gz: ecdae100a7f27d51751fe1fd77c772ca8a88cfab323266c874b996ffaed909c8
4
+ data.tar.gz: 92b4827386e87485f3adf26e93de16e910a2c4f9d320ba4cc683768d067b130f
5
5
  SHA512:
6
- metadata.gz: 4b5cbb57859b8f34a2d45c8869d2f197ef84f24d7554c12027e331a783edc510357dd6d7cd1b199db98870b17abb2a27e2ca1f70fc4fce0151fa82392db4456a
7
- data.tar.gz: e84f97c7a0dbe89e249315f5142b7c275de3b7cfb4f90127727d7d9e943f14c1eb493994a56554d8af22c7a2248e9e5a9c873a2a08e3f21bd7614265d5fa383b
6
+ metadata.gz: 497006f3d3854a431e29f12f6463c57ed35290e5d722de7fe7bab24a886f667483821553da14ecc904d64b6de45c3d48ae446f90ba4424c3fbcbf7c59362ec38
7
+ data.tar.gz: a0e94a4812aaf022a7e62e02f0a56718ecb38cfd49002fc0dbdc7e9833fcb6520dfe799ff3d2279d9239520a615215240337261a4116c4c4ab1b7496d4239af5
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Yusuke Endoh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md CHANGED
@@ -64,22 +64,18 @@ bot.run
64
64
  ```
65
65
 
66
66
  ```
67
- $ ruby example/echo_bot.rb
67
+ $ ruby examples/echo_bot.rb
68
68
  I, [20XX-XX-XXTXX:XX:XX.XXXXXX #XXXXXX] INFO -- : [ws:2560] websocket open
69
69
  I, [20XX-XX-XXTXX:XX:XX.XXXXXX #XXXXXX] INFO -- : [ws:2600] websocket open
70
- I, [20XX-XX-XXTXX:XX:XX.XXXXXX #XXXXXX] INFO -- : [ws:2560] slack hello
71
- I, [20XX-XX-XXTXX:XX:XX.XXXXXX #XXXXXX] INFO -- : [ws:2560] active connection count: 4
72
- I, [20XX-XX-XXTXX:XX:XX.XXXXXX #XXXXXX] INFO -- : [ws:2600] slack hello
73
- I, [20XX-XX-XXTXX:XX:XX.XXXXXX #XXXXXX] INFO -- : [ws:2600] active connection count: 4
74
70
  I, [20XX-XX-XXTXX:XX:XX.XXXXXX #XXXXXX] INFO -- : [ws:2640] websocket open
75
- I, [20XX-XX-XXTXX:XX:XX.XXXXXX #XXXXXX] INFO -- : [ws:2640] slack hello
76
- I, [20XX-XX-XXTXX:XX:XX.XXXXXX #XXXXXX] INFO -- : [ws:2640] active connection count: 4
77
71
  I, [20XX-XX-XXTXX:XX:XX.XXXXXX #XXXXXX] INFO -- : [ws:2680] websocket open
78
- I, [20XX-XX-XXTXX:XX:XX.XXXXXX #XXXXXX] INFO -- : [ws:2680] slack hello
79
- I, [20XX-XX-XXTXX:XX:XX.XXXXXX #XXXXXX] INFO -- : [ws:2680] active connection count: 4
80
- I, [20XX-XX-XXTXX:XX:XX.XXXXXX #XXXXXX] INFO -- : [ws:2680] slack events_api (event_callback)
81
- I, [20XX-XX-XXTXX:XX:XX.XXXXXX #XXXXXX] INFO -- : [ws:2680] slack events_api (event_callback)
82
- I, [20XX-XX-XXTXX:XX:XX.XXXXXX #XXXXXX] INFO -- : [ws:2680] slack events_api (event_callback)
72
+ I, [20XX-XX-XXTXX:XX:XX.XXXXXX #XXXXXX] INFO -- : [ws:2560] hello (active connections: 4)
73
+ I, [20XX-XX-XXTXX:XX:XX.XXXXXX #XXXXXX] INFO -- : [ws:2600] hello (active connections: 4)
74
+ I, [20XX-XX-XXTXX:XX:XX.XXXXXX #XXXXXX] INFO -- : [ws:2640] hello (active connections: 4)
75
+ I, [20XX-XX-XXTXX:XX:XX.XXXXXX #XXXXXX] INFO -- : [ws:2680] hello (active connections: 4)
76
+ I, [20XX-XX-XXTXX:XX:XX.XXXXXX #XXXXXX] INFO -- : [ws:2680] events_api app_mention Ev08H3RABCDE
77
+ I, [20XX-XX-XXTXX:XX:XX.XXXXXX #XXXXXX] INFO -- : [ws:2680] events_api app_mention Ev08H3RFGHIJ
78
+ I, [20XX-XX-XXTXX:XX:XX.XXXXXX #XXXXXX] INFO -- : [ws:2680] events_api app_mention Ev08H3RKLMNO
83
79
  ...
84
80
  ```
85
81
 
@@ -94,6 +90,9 @@ Connects to Slack with Socket Mode.
94
90
  * `logger`: A Logger instance (optional)
95
91
  * block: Handles events received from Slack
96
92
 
93
+ Note: The block must return as soon as possible. Otherwise, the Slack server will re-send the event.
94
+ If you want to do a time-consuming process, it is recommended that you do it in a sub thread.
95
+
97
96
  ### `SlackSocketModeBot#call(method, data, token:)`
98
97
 
99
98
  Calls Slack's [Web API](https://api.slack.com/methods), such as [chat.postMessage](https://api.slack.com/methods/chat.postMessage).
@@ -107,6 +106,11 @@ This method returns the response as a JSON data.
107
106
 
108
107
  Starts the main loop of communication with Slack. This method does not return.
109
108
 
109
+ On an unrecoverable error (for example, losing every connection and being unable
110
+ to reopen any), this method raises an exception instead of silently continuing in
111
+ a degraded state. Running your bot under a process supervisor such as systemd is
112
+ recommended so that it is restarted when this happens.
113
+
110
114
  ### `SlackSocketModeBot#step`
111
115
 
112
116
  Proceeds with the communication one step.
data/Rakefile CHANGED
@@ -1,4 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "bundler/gem_tasks"
4
- task default: %i[]
4
+ require "rake/testtask"
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << "lib" << "test"
8
+ t.test_files = FileList["test/test_*.rb"]
9
+ t.warning = false
10
+ end
11
+
12
+ task default: %i[test]
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class SlackSocketModeBot
4
- VERSION = "0.9.0"
4
+ VERSION = "0.9.2"
5
5
  end
@@ -12,6 +12,21 @@ require_relative "slack_socket_mode_bot/simple_web_socket"
12
12
  class SlackSocketModeBot
13
13
  class Error < StandardError; end
14
14
 
15
+ API_BASE = "https://slack.com/api/"
16
+
17
+ # Internal signal: retry after `wait` seconds. Always caught in #call.
18
+ class Retry < StandardError
19
+ attr_reader :wait
20
+ def initialize(wait)
21
+ @wait = wait
22
+ super("retry after #{ wait }s")
23
+ end
24
+ end
25
+ private_constant :Retry
26
+
27
+ # Max seconds to sleep on a 429 Retry-After; #call may run in the event loop.
28
+ RETRY_AFTER_CAP = 60
29
+
15
30
  #: (token: String, ?app_token: String, ?num_of_connections: Integer, ?debug: boolean, ?logger: Logger) { (untyped) -> untyped } -> void
16
31
  def initialize(token:, app_token: nil, num_of_connections: 4, debug: false, logger: nil, &callback)
17
32
  @token = token
@@ -19,30 +34,67 @@ class SlackSocketModeBot
19
34
  @conns = []
20
35
  @debug = debug
21
36
  @logger = logger
22
- num_of_connections.times { add_connection(callback) } if app_token
37
+ @events = {}
38
+ @callback = callback
39
+ # No app token: Web API calls only, no Socket Mode connections.
40
+ @num_of_connections = app_token ? num_of_connections : 0
41
+ replenish_connections
23
42
  end
24
43
 
25
44
  #: (String method, untyped data, ?token: String) -> untyped
26
45
  def call(method, data, token: @token)
27
- count = 0
46
+ url = URI(API_BASE + method)
47
+ body = JSON.generate(data)
48
+ headers = {
49
+ "Content-type" => "application/json; charset=utf-8",
50
+ "Authorization" => "Bearer " + token,
51
+ }
52
+
53
+ retries = 0
28
54
  begin
29
- url = URI("https://slack.com/api/" + method)
30
- res = Net::HTTP.post(
31
- url, JSON.generate(data),
32
- "Content-type" => "application/json; charset=utf-8",
33
- "Authorization" => "Bearer " + token,
34
- )
35
- json = JSON.parse(res.body, symbolize_names: true)
36
- raise Error, json[:error] unless json[:ok]
37
- json
38
- rescue Socket::ResolutionError
55
+ res = Net::HTTP.post(url, body, headers)
56
+
57
+ case res
58
+ when Net::HTTPSuccess
59
+ json = JSON.parse(res.body, symbolize_names: true)
60
+ raise Error, json[:error] unless json[:ok]
61
+ json
62
+ when Net::HTTPTooManyRequests
63
+ # Rejected, not processed: safe to retry after Retry-After seconds.
64
+ raise Retry, Integer(res["retry-after"] || retries + 1)
65
+ else
66
+ # 5xx etc.: may already be processed, so don't retry; just don't crash.
67
+ raise Error, "HTTP #{ res.code } #{ res.message }"
68
+ end
69
+ rescue Socket::ResolutionError, Net::OpenTimeout
70
+ # Never sent (DNS / connect failure): safe to retry.
71
+ retries += 1
72
+ raise if retries >= 3
39
73
  sleep 1
40
- count += 1
41
- retry if count < 3
42
- raise
74
+ retry
75
+ rescue Retry => e
76
+ # Don't block the event loop on an absurdly long wait.
77
+ retries += 1
78
+ raise Error, "rate limited (retry-after: #{ e.wait }s)" if retries >= 3 || e.wait > RETRY_AFTER_CAP
79
+ sleep e.wait
80
+ retry
43
81
  end
44
82
  end
45
83
 
84
+ private def replenish_connections
85
+ # Reopen from the main loop, tolerating a single failure; #step retries.
86
+ while @conns.size < @num_of_connections
87
+ begin
88
+ add_connection(@callback)
89
+ rescue => e
90
+ @logger.warn("[reconnect] failed: #{ e.message }") if @logger
91
+ break
92
+ end
93
+ end
94
+ # Fail loud rather than degrade silently once every connection is gone.
95
+ raise Error, "all socket connections lost" if @num_of_connections > 0 && @conns.empty?
96
+ end
97
+
46
98
  private def add_connection(callback)
47
99
  json = call("apps.connections.open", {}, token: @app_token)
48
100
 
@@ -53,35 +105,56 @@ class SlackSocketModeBot
53
105
  when :open
54
106
  @logger.info("[ws:#{ ws.object_id }] websocket open") if @logger
55
107
  when :close
108
+ # #step drops the dead connection; #replenish_connections reopens it.
56
109
  @logger.info("[ws:#{ ws.object_id }] websocket closed") if @logger
57
- add_connection(callback)
58
110
  when :message
59
111
  begin
60
112
  json = JSON.parse(data, symbolize_names: true)
61
113
  rescue JSON::ParserError
62
- add_connection(callback)
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
63
116
  next
64
117
  end
65
118
 
66
119
  if @logger
67
120
  @logger.debug("[ws:#{ ws.object_id }] slack message: #{ JSON.generate(json) }")
68
- msg = "slack #{ json[:type] }"
69
- payload_type = json.dig(:payload, :type)
70
- msg += " (#{ payload_type })" if payload_type
71
- @logger.info("[ws:#{ ws.object_id }] " + msg)
72
121
  end
73
122
 
74
123
  case json[:type]
75
124
  when "hello"
76
- @logger.info("[ws:#{ ws.object_id }] active connection count: #{ @conns.size }") if @logger
125
+ @logger.info("[ws:#{ ws.object_id }] hello (active connections: #{ @conns.size })") if @logger
77
126
  when "disconnect"
78
127
  ws.close
128
+ @logger.info("[ws:#{ ws.object_id }] disconnect (active connections: #{ @conns.size })") if @logger
79
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
+
80
154
  response = { envelope_id: json[:envelope_id] }
81
- if json[:accepts_response_payload]
82
- response[:payload] = callback.call(json)
83
- else
84
- callback.call(json)
155
+ unless duplicate
156
+ result = callback.call(json)
157
+ response[:payload] = result if json[:accepts_response_payload]
85
158
  end
86
159
  ws.send(JSON.generate(response))
87
160
  end
@@ -95,6 +168,7 @@ class SlackSocketModeBot
95
168
  def step
96
169
  read_ios, write_ios = [], []
97
170
  @conns.select! {|ws| ws.step(read_ios, write_ios) }
171
+ replenish_connections
98
172
  return read_ios, write_ios
99
173
  end
100
174
 
@@ -1,13 +1,14 @@
1
- module Slack
2
- class SocketMode
3
- VERSION: String
4
-
5
- def initialize: (token: String, ?app_token: String, ?num_of_connections: Integer, ?debug: boolean, ?logger: Logger) { (untyped) -> untyped } -> void
6
-
7
- def call: (String method, untyped data, ?token: String) -> untyped
8
-
9
- def step: -> [Array[IO], Array[IO]]
10
-
11
- def run: -> bot
1
+ class SlackSocketModeBot
2
+ VERSION: String
3
+
4
+ class Error < StandardError
12
5
  end
6
+
7
+ def initialize: (token: String, ?app_token: String?, ?num_of_connections: Integer, ?debug: bool, ?logger: Logger?) { (untyped) -> untyped } -> void
8
+
9
+ def call: (String method, untyped data, ?token: String) -> untyped
10
+
11
+ def step: () -> [ Array[IO], Array[IO] ]
12
+
13
+ def run: () -> void
13
14
  end
metadata CHANGED
@@ -1,60 +1,66 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: slack_socket_mode_bot
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.0
4
+ version: 0.9.2
5
5
  platform: ruby
6
6
  authors:
7
- - Yusuke Endoh
7
+ - Yusuke Endoh
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2024-06-21 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
- - !ruby/object:Gem::Dependency
13
- name: websocket
14
- requirement: !ruby/object:Gem::Requirement
15
- requirements:
16
- - - "~>"
17
- - !ruby/object:Gem::Version
18
- version: '1.2'
19
- type: :runtime
20
- prerelease: false
21
- version_requirements: !ruby/object:Gem::Requirement
22
- requirements:
23
- - - "~>"
24
- - !ruby/object:Gem::Version
25
- version: '1.2'
12
+ - !ruby/object:Gem::Dependency
13
+ name: websocket
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ -
17
+ - ~>
18
+ - !ruby/object:Gem::Version
19
+ version: "1.2"
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ -
25
+ - ~>
26
+ - !ruby/object:Gem::Version
27
+ version: "1.2"
26
28
  email:
27
- - mame@ruby-lang.org
29
+ - mame@ruby-lang.org
28
30
  executables: []
29
31
  extensions: []
30
32
  extra_rdoc_files: []
31
33
  files:
32
- - README.md
33
- - Rakefile
34
- - lib/slack_socket_mode_bot.rb
35
- - lib/slack_socket_mode_bot/simple_web_socket.rb
36
- - lib/slack_socket_mode_bot/version.rb
37
- - sig/slack_socket_mode.rbs
38
- homepage: https://github.com/mame/slack_socket_mode_bot
39
- licenses: []
34
+ - LICENSE
35
+ - README.md
36
+ - Rakefile
37
+ - lib/slack_socket_mode_bot.rb
38
+ - lib/slack_socket_mode_bot/simple_web_socket.rb
39
+ - lib/slack_socket_mode_bot/version.rb
40
+ - sig/slack_socket_mode.rbs
41
+ homepage: "https://github.com/mame/slack_socket_mode_bot"
42
+ licenses:
43
+ - MIT
40
44
  metadata:
41
- homepage_uri: https://github.com/mame/slack_socket_mode_bot
42
- source_code_uri: https://github.com/mame/slack_socket_mode_bot
45
+ homepage_uri: "https://github.com/mame/slack_socket_mode_bot"
46
+ source_code_uri: "https://github.com/mame/slack_socket_mode_bot"
43
47
  rdoc_options: []
44
48
  require_paths:
45
- - lib
49
+ - lib
46
50
  required_ruby_version: !ruby/object:Gem::Requirement
47
51
  requirements:
48
- - - ">="
49
- - !ruby/object:Gem::Version
50
- version: 3.0.0
52
+ -
53
+ - ">="
54
+ - !ruby/object:Gem::Version
55
+ version: 3.3.0
51
56
  required_rubygems_version: !ruby/object:Gem::Requirement
52
57
  requirements:
53
- - - ">="
54
- - !ruby/object:Gem::Version
55
- version: '0'
58
+ -
59
+ - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: "0"
56
62
  requirements: []
57
- rubygems_version: 3.6.0.dev
63
+ rubygems_version: 4.1.0.dev
58
64
  specification_version: 4
59
65
  summary: A simple wrapper library for Slack's Socket Mode API
60
66
  test_files: []