Class: Xeno::Channels::Slack
- Inherits:
-
Object
- Object
- Xeno::Channels::Slack
- Defined in:
- lib/xeno/channels/slack.rb
Overview
The Slack channel: Events API webhook with constant-time signature
verification, thread-scoped sessions (continuation token =
"slack:
v0.1 scope: mentions and DMs start sessions; thread replies continue them. Post-then-edit streaming and Block Kit buttons are v0.2.
Defined Under Namespace
Classes: RateLimited, Streamer
Constant Summary collapse
- SIGNATURE_VERSION =
"v0".freeze
- TIMESTAMP_TOLERANCE =
seconds; replayed webhooks are rejected
300- APPROVE_WORDS =
%w[approve approved yes y ok 👍].freeze
- DENY_WORDS =
%w[deny denied no n reject rejected 👎].freeze
Instance Method Summary collapse
-
#api_base(value = nil) ⇒ Object
Overridable for offline tests (the fake Slack API).
-
#api_post(method, payload) ⇒ Object
One JSON POST to the Slack Web API; 429 becomes RateLimited (with Retry-After), any other failure raises Xeno::Error.
- #bot_token(value = nil) ⇒ Object
-
#deliver_completion(session, content) ⇒ Object
--- outbound: delivery ---.
- #deliver_input_request(session, actions) ⇒ Object
- #handle_event(payload) ⇒ Object
-
#initialize(&block) ⇒ Slack
constructor
A new instance of Slack.
-
#signing_secret(value = nil) ⇒ Object
--- the config DSL (setter-and-reader hybrids, like AgentConfig) ---.
-
#stream_replies(value = nil) ⇒ Object
Opt-in post-then-edit streaming: the reply posts on the first model delta and is edited (~1s cadence, rate-limit aware) until the final text lands.
-
#streamer_for(session) ⇒ Object
One per content-bearing model reply when stream_replies is on: the runner pushes deltas, we post-then-edit in the thread.
-
#verify_signature(timestamp, signature, raw_body) ⇒ Object
--- inbound: webhook verification + event handling ---.
Constructor Details
#initialize(&block) ⇒ Slack
Returns a new instance of Slack.
20 21 22 23 |
# File 'lib/xeno/channels/slack.rb', line 20 def initialize(&block) @api_base = "https://slack.com/api" instance_eval(&block) if block end |
Instance Method Details
#api_base(value = nil) ⇒ Object
Overridable for offline tests (the fake Slack API).
38 39 40 41 |
# File 'lib/xeno/channels/slack.rb', line 38 def api_base(value = nil) @api_base = value if value @api_base end |
#api_post(method, payload) ⇒ Object
One JSON POST to the Slack Web API; 429 becomes RateLimited (with Retry-After), any other failure raises Xeno::Error.
201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 |
# File 'lib/xeno/channels/slack.rb', line 201 def api_post(method, payload) uri = URI("#{api_base}/#{method}") request = Net::HTTP::Post.new(uri) request["Authorization"] = "Bearer #{bot_token}" request["Content-Type"] = "application/json; charset=utf-8" request.body = JSON.generate(payload) response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", read_timeout: 10) do |http| http.request(request) end parsed = JSON.parse(response.body) rescue {} raise RateLimited.new(retry_after: response["Retry-After"]) if response.code.to_i == 429 unless response.is_a?(Net::HTTPSuccess) && parsed["ok"] raise Xeno::Error, "slack #{method} failed: #{response.code} #{parsed['error']}" end parsed end |
#bot_token(value = nil) ⇒ Object
32 33 34 35 |
# File 'lib/xeno/channels/slack.rb', line 32 def bot_token(value = nil) @bot_token = value if value @bot_token end |
#deliver_completion(session, content) ⇒ Object
--- outbound: delivery ---
78 79 80 81 82 83 |
# File 'lib/xeno/channels/slack.rb', line 78 def deliver_completion(session, content) channel_id, thread_ts = thread_for(session) return unless channel_id (channel: channel_id, thread_ts: thread_ts, text: content.to_s) end |
#deliver_input_request(session, actions) ⇒ Object
96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 |
# File 'lib/xeno/channels/slack.rb', line 96 def deliver_input_request(session, actions) channel_id, thread_ts = thread_for(session) return unless channel_id prompts = actions.map do |action| if action.kind == "question" question = action.input&.dig("question") choices = Array(action.input&.dig("choices")) choices.any? ? "❓ #{question} (#{choices.join(' / ')})" : "❓ #{question}" else "⏸ Approval needed: `#{action.tool_name}(#{action.input.to_json})` — reply *approve* or *deny*." end end (channel: channel_id, thread_ts: thread_ts, text: prompts.join("\n")) end |
#handle_event(payload) ⇒ Object
63 64 65 66 67 68 69 70 71 72 73 74 |
# File 'lib/xeno/channels/slack.rb', line 63 def handle_event(payload) event = payload["event"] || {} return if event["bot_id"].present? # never talk to ourselves case event["type"] when "app_mention" (event) when "message" # DMs only; edits/joins/etc. carry a subtype and are ignored. (event) if event["channel_type"] == "im" && event["subtype"].blank? end end |
#signing_secret(value = nil) ⇒ Object
--- the config DSL (setter-and-reader hybrids, like AgentConfig) ---
27 28 29 30 |
# File 'lib/xeno/channels/slack.rb', line 27 def signing_secret(value = nil) @signing_secret = value if value @signing_secret end |
#stream_replies(value = nil) ⇒ Object
Opt-in post-then-edit streaming: the reply posts on the first model delta and is edited (~1s cadence, rate-limit aware) until the final text lands. Off by default — replies post once at completion.
46 47 48 49 |
# File 'lib/xeno/channels/slack.rb', line 46 def stream_replies(value = nil) @stream_replies = value unless value.nil? @stream_replies end |
#streamer_for(session) ⇒ Object
One per content-bearing model reply when stream_replies is on: the runner pushes deltas, we post-then-edit in the thread.
87 88 89 90 91 92 93 94 |
# File 'lib/xeno/channels/slack.rb', line 87 def streamer_for(session) return nil unless stream_replies channel_id, thread_ts = thread_for(session) return nil unless channel_id Streamer.new(self, channel: channel_id, thread_ts: thread_ts) end |
#verify_signature(timestamp, signature, raw_body) ⇒ Object
--- inbound: webhook verification + event handling ---
53 54 55 56 57 58 59 60 61 |
# File 'lib/xeno/channels/slack.rb', line 53 def verify_signature(, signature, raw_body) return false if signing_secret.blank? || .blank? || signature.blank? return false if (Time.now.to_i - .to_i).abs > TIMESTAMP_TOLERANCE base = "#{SIGNATURE_VERSION}:#{}:#{raw_body}" digest = OpenSSL::HMAC.hexdigest("sha256", signing_secret, base) expected = "#{SIGNATURE_VERSION}=#{digest}" ActiveSupport::SecurityUtils.secure_compare(expected, signature.to_s) end |