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>"), replies posted at turn completion, and plain-text approvals ("approve"/"deny"/an answer) in the thread.

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

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.

Raises:



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

  post_message(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
  post_message(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"
    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) ---



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