slack_socket_mode_bot 0.9.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ea84a2e36d8b349ef867322ad4e932703dc5fa7eea1397f693268977aecfb001
4
+ data.tar.gz: 1802e47527f56357fa5a3d564f61f91a6a3f3741bb107227dad18be8beedb08f
5
+ SHA512:
6
+ metadata.gz: 4b5cbb57859b8f34a2d45c8869d2f197ef84f24d7554c12027e331a783edc510357dd6d7cd1b199db98870b17abb2a27e2ca1f70fc4fce0151fa82392db4456a
7
+ data.tar.gz: e84f97c7a0dbe89e249315f5142b7c275de3b7cfb4f90127727d7d9e943f14c1eb493994a56554d8af22c7a2248e9e5a9c873a2a08e3f21bd7614265d5fa383b
data/README.md ADDED
@@ -0,0 +1,127 @@
1
+ # SlackSocketModeBot
2
+
3
+ This is a simple Ruby wrapper for the [Slack's Socket Mode API](https://api.slack.com/apis/socket-mode).
4
+ It allows you to write a Slack bot without exposing a public HTTP endpoint.
5
+
6
+ ## Usage
7
+
8
+ First, set up your Slack app for Socket Mode by reading [the official document](https://api.slack.com/apis/socket-mode).
9
+
10
+ Then, run the following script.
11
+
12
+ ```ruby
13
+ # simple echo bot
14
+
15
+ require "slack_socket_mode_bot"
16
+ require "logger"
17
+
18
+ # Slack's Bot User OAuth Token
19
+ # You can create this token with: https://api.slack.com/apps/ - "OAuth & Permissions" - "OAuth Tokens for Your Workspace"
20
+ SLACK_BOT_TOKEN = "xoxb-..."
21
+
22
+ # Slack's App-Level Token
23
+ # You can create this token with: https://api.slack.com/apps/ - "Basic Information" - "App-Level Tokens"
24
+ SLACK_APP_TOKEN = "xapp-..."
25
+
26
+ logger = Logger.new(STDOUT, level: Logger::Severity::INFO)
27
+
28
+ bot = SlackSocketModeBot.new(token: SLACK_BOT_TOKEN, app_token: SLACK_APP_TOKEN, logger: logger) do |data|
29
+ # Event handler. A sample data is
30
+ #
31
+ # {
32
+ # "type": "events_api",
33
+ # "envelope_id": "...",
34
+ # "accepts_response_payload": false,
35
+ # "payload": {
36
+ # "type": "event_callback",
37
+ # "event": {
38
+ # "type": "app_mention",
39
+ # "text": "hello",
40
+ # ...
41
+ # },
42
+ # ...
43
+ # }
44
+ # }
45
+ #
46
+ # See https://api.slack.com/apis/socket-mode#events in detail
47
+
48
+ if data[:type] == "events_api" && data[:payload][:event][:type] == "app_mention"
49
+ event = data[:payload][:event]
50
+
51
+ text = event[:text]
52
+
53
+ echo_text = "echo:" + text
54
+
55
+ bot.call("chat.postMessage", { channel: event[:channel], text: echo_text })
56
+ end
57
+
58
+ rescue Exception
59
+ puts $!.full_message
60
+ end
61
+
62
+ # Start the communication
63
+ bot.run
64
+ ```
65
+
66
+ ```
67
+ $ ruby example/echo_bot.rb
68
+ I, [20XX-XX-XXTXX:XX:XX.XXXXXX #XXXXXX] INFO -- : [ws:2560] websocket open
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
+ 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
+ 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)
83
+ ...
84
+ ```
85
+
86
+ ## API
87
+
88
+ ### `SlackSocketModeBot.new(token:, app_token:, logger:)`
89
+
90
+ Connects to Slack with Socket Mode.
91
+
92
+ * `token`: Slack's Bot User OAuth token (starting with `xoxb-`)
93
+ * `app_token`: Slack's App-Level token (starting with `xapp-`)
94
+ * `logger`: A Logger instance (optional)
95
+ * block: Handles events received from Slack
96
+
97
+ ### `SlackSocketModeBot#call(method, data, token:)`
98
+
99
+ Calls Slack's [Web API](https://api.slack.com/methods), such as [chat.postMessage](https://api.slack.com/methods/chat.postMessage).
100
+
101
+ * `method`: API name (such as `"chat.postMessage"`)
102
+ * `data`: Arguments
103
+
104
+ This method returns the response as a JSON data.
105
+
106
+ ### `SlackSocketModeBot#run`
107
+
108
+ Starts the main loop of communication with Slack. This method does not return.
109
+
110
+ ### `SlackSocketModeBot#step`
111
+
112
+ Proceeds with the communication one step.
113
+
114
+ This method returns an array of IO waiting to be readable and an array of IO waiting to be writable.
115
+ They are supposed to be passed to `IO.select`.
116
+
117
+ Typically, this method should be used as follows.
118
+
119
+ ```ruby
120
+ while true
121
+ read_ios, write_ios = app.step
122
+ IO.select(read_ios, write_ios)
123
+ end
124
+ ```
125
+
126
+ This method allows you to manage the main loop yourself.
127
+ If you don't need it, you can just use `SlackSocketModeBot#run`.
data/Rakefile ADDED
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ task default: %i[]
@@ -0,0 +1,114 @@
1
+ class SlackSocketModeBot::SimpleWebSocket
2
+ def initialize(url)
3
+ uri = URI.parse(url)
4
+
5
+ unless uri.scheme == "https" || uri.scheme == "wss"
6
+ raise "unexpected scheme (not secure?): #{ uri.scheme }"
7
+ end
8
+
9
+ ctx = OpenSSL::SSL::SSLContext.new
10
+ ctx.ssl_version = "SSLv23"
11
+ ctx.verify_mode = OpenSSL::SSL::VERIFY_PEER
12
+ ctx.cert_store = OpenSSL::X509::Store.new
13
+ ctx.cert_store.set_default_paths
14
+
15
+ count = 0
16
+ begin
17
+ io = TCPSocket.new(uri.host, uri.port || 443)
18
+ @io = OpenSSL::SSL::SSLSocket.new(io, ctx)
19
+ @io.connect
20
+ rescue Socket::ResolutionError
21
+ sleep 1
22
+ count += 1
23
+ retry if count < 3
24
+ raise
25
+ end
26
+
27
+ @version = nil
28
+
29
+ @fib = Fiber.new do
30
+ closed = false
31
+ begin
32
+ handshake = WebSocket::Handshake::Client.new(url: url)
33
+ @write_buff = handshake.to_s.dup
34
+ handshake << Fiber.yield until handshake.finished?
35
+
36
+ @version = handshake.version
37
+ yield :open
38
+
39
+ frame = WebSocket::Frame::Incoming::Client.new
40
+ frame << handshake.leftovers
41
+ while true
42
+ while msg = frame.next
43
+ case msg.type
44
+ when :close
45
+ yield :close unless closed
46
+ closed = true
47
+ when :ping
48
+ send(msg.data, type: :pong)
49
+ when :pong
50
+ when :text
51
+ yield :message, msg.data, :text
52
+ when :binary
53
+ yield :message, msg.data, :binary
54
+ end
55
+ end
56
+ frame << Fiber.yield
57
+ end
58
+ rescue EOFError
59
+ ensure
60
+ yield :close unless closed
61
+ @io.close
62
+ end
63
+ end
64
+
65
+ @fib.resume
66
+ end
67
+
68
+ def send(data, type: :text, code: nil)
69
+ raise "not opened yet" unless @version
70
+ frame = WebSocket::Frame::Outgoing::Client.new(version: @version, data: data, type: type, code: code)
71
+ @write_buff << frame.to_s
72
+ end
73
+
74
+ def close(code: 1000, reason: "")
75
+ send(reason, type: :close, code: code) unless @io.closed?
76
+ end
77
+
78
+ 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
89
+ end
90
+
91
+ while true
92
+ read_buff = @io.read_nonblock(4096, exception: false)
93
+ case read_buff
94
+ when :wait_readable then wait_readable = true; break
95
+ when :wait_writable then wait_writable = true; break
96
+ when nil
97
+ raise Errno::EPIPE
98
+ else
99
+ @fib.resume(read_buff)
100
+ end
101
+ end
102
+
103
+ read_ios << @io if wait_readable
104
+ write_ios << @io if wait_writable
105
+ return true
106
+
107
+ rescue Errno::EPIPE, Errno::ECONNRESET
108
+ begin
109
+ @fib.raise(EOFError)
110
+ rescue FiberError
111
+ end
112
+ return false
113
+ end
114
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class SlackSocketModeBot
4
+ VERSION = "0.9.0"
5
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require "net/http"
5
+ require "openssl"
6
+ require "websocket"
7
+ require "json"
8
+
9
+ require_relative "slack_socket_mode_bot/version"
10
+ require_relative "slack_socket_mode_bot/simple_web_socket"
11
+
12
+ class SlackSocketModeBot
13
+ class Error < StandardError; end
14
+
15
+ #: (token: String, ?app_token: String, ?num_of_connections: Integer, ?debug: boolean, ?logger: Logger) { (untyped) -> untyped } -> void
16
+ def initialize(token:, app_token: nil, num_of_connections: 4, debug: false, logger: nil, &callback)
17
+ @token = token
18
+ @app_token = app_token
19
+ @conns = []
20
+ @debug = debug
21
+ @logger = logger
22
+ num_of_connections.times { add_connection(callback) } if app_token
23
+ end
24
+
25
+ #: (String method, untyped data, ?token: String) -> untyped
26
+ def call(method, data, token: @token)
27
+ count = 0
28
+ 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
39
+ sleep 1
40
+ count += 1
41
+ retry if count < 3
42
+ raise
43
+ end
44
+ end
45
+
46
+ private def add_connection(callback)
47
+ json = call("apps.connections.open", {}, token: @app_token)
48
+
49
+ url = json[:url]
50
+ url += "&debug_reconnects=true" if @debug
51
+ ws = SimpleWebSocket.new(url) do |type, data|
52
+ case type
53
+ when :open
54
+ @logger.info("[ws:#{ ws.object_id }] websocket open") if @logger
55
+ when :close
56
+ @logger.info("[ws:#{ ws.object_id }] websocket closed") if @logger
57
+ add_connection(callback)
58
+ when :message
59
+ begin
60
+ json = JSON.parse(data, symbolize_names: true)
61
+ rescue JSON::ParserError
62
+ add_connection(callback)
63
+ next
64
+ end
65
+
66
+ if @logger
67
+ @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
+ end
73
+
74
+ case json[:type]
75
+ when "hello"
76
+ @logger.info("[ws:#{ ws.object_id }] active connection count: #{ @conns.size }") if @logger
77
+ when "disconnect"
78
+ ws.close
79
+ else
80
+ response = { envelope_id: json[:envelope_id] }
81
+ if json[:accepts_response_payload]
82
+ response[:payload] = callback.call(json)
83
+ else
84
+ callback.call(json)
85
+ end
86
+ ws.send(JSON.generate(response))
87
+ end
88
+ end
89
+ end
90
+
91
+ @conns << ws
92
+ end
93
+
94
+ #: -> [Array[IO], Array[IO]]
95
+ def step
96
+ read_ios, write_ios = [], []
97
+ @conns.select! {|ws| ws.step(read_ios, write_ios) }
98
+ return read_ios, write_ios
99
+ end
100
+
101
+ #: -> bot
102
+ def run
103
+ while true
104
+ read_ios, write_ios = step
105
+ IO.select(read_ios, write_ios)
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,13 @@
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
12
+ end
13
+ end
metadata ADDED
@@ -0,0 +1,60 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: slack_socket_mode_bot
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.9.0
5
+ platform: ruby
6
+ authors:
7
+ - Yusuke Endoh
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 2024-06-21 00:00:00.000000000 Z
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'
26
+ email:
27
+ - mame@ruby-lang.org
28
+ executables: []
29
+ extensions: []
30
+ extra_rdoc_files: []
31
+ 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: []
40
+ metadata:
41
+ homepage_uri: https://github.com/mame/slack_socket_mode_bot
42
+ source_code_uri: https://github.com/mame/slack_socket_mode_bot
43
+ rdoc_options: []
44
+ require_paths:
45
+ - lib
46
+ required_ruby_version: !ruby/object:Gem::Requirement
47
+ requirements:
48
+ - - ">="
49
+ - !ruby/object:Gem::Version
50
+ version: 3.0.0
51
+ required_rubygems_version: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ version: '0'
56
+ requirements: []
57
+ rubygems_version: 3.6.0.dev
58
+ specification_version: 4
59
+ summary: A simple wrapper library for Slack's Socket Mode API
60
+ test_files: []