Class: Xeno::Channels::Slack

Inherits:
Object
  • Object
show all
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::<thread_ts>"), and plain-text approvals in the thread. Mentions and DMs start sessions; thread replies continue them. Post-then-edit streaming is opt-in (stream_replies); Block Kit buttons are not implemented.

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

Constructor Details

#initialize(&block) ⇒ Slack

Returns a new instance of Slack.



19
20
21
22
# File 'lib/xeno/channels/slack.rb', line 19

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).



37
38
39
40
# File 'lib/xeno/channels/slack.rb', line 37

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.

Raises:



198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
# File 'lib/xeno/channels/slack.rb', line 198

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



31
32
33
34
# File 'lib/xeno/channels/slack.rb', line 31

def bot_token(value = nil)
  @bot_token = value if value
  @bot_token
end

#deliver_completion(session, content) ⇒ Object

--- outbound: delivery ---



77
78
79
80
81
82
# File 'lib/xeno/channels/slack.rb', line 77

def deliver_completion(session, content)
  channel_id, thread_ts = thread_for(session)
  return unless channel_id

  post_message(channel: channel_id, thread_ts: thread_ts, text: content.to_s)
end

#deliver_input_request(session, actions) ⇒ Object



95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
# File 'lib/xeno/channels/slack.rb', line 95

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
  post_message(channel: channel_id, thread_ts: thread_ts, text: prompts.join("\n"))
end

#handle_event(payload) ⇒ Object



62
63
64
65
66
67
68
69
70
71
72
73
# File 'lib/xeno/channels/slack.rb', line 62

def handle_event(payload)
  event = payload["event"] || {}
  return if event["bot_id"].present? # never talk to ourselves

  case event["type"]
  when "app_mention"
    handle_message(event)
  when "message"
    # DMs only; edits/joins/etc. carry a subtype and are ignored.
    handle_message(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) ---



26
27
28
29
# File 'lib/xeno/channels/slack.rb', line 26

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.



45
46
47
48
# File 'lib/xeno/channels/slack.rb', line 45

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.



86
87
88
89
90
91
92
93
# File 'lib/xeno/channels/slack.rb', line 86

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 ---



52
53
54
55
56
57
58
59
60
# File 'lib/xeno/channels/slack.rb', line 52

def verify_signature(timestamp, signature, raw_body)
  return false if signing_secret.blank? || timestamp.blank? || signature.blank?
  return false if (Time.now.to_i - timestamp.to_i).abs > TIMESTAMP_TOLERANCE

  base = "#{SIGNATURE_VERSION}:#{timestamp}:#{raw_body}"
  digest = OpenSSL::HMAC.hexdigest("sha256", signing_secret, base)
  expected = "#{SIGNATURE_VERSION}=#{digest}"
  ActiveSupport::SecurityUtils.secure_compare(expected, signature.to_s)
end