console_agent 0.10.0 → 0.11.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.
@@ -0,0 +1,207 @@
1
+ module ConsoleAgent
2
+ # Raised by safety guards to block dangerous operations.
3
+ # Host apps should raise this error in their custom guards.
4
+ # ConsoleAgent will catch it and guide the user to use 'd' or /danger.
5
+ class SafetyError < StandardError
6
+ attr_reader :guard, :blocked_key
7
+
8
+ def initialize(message, guard: nil, blocked_key: nil)
9
+ super(message)
10
+ @guard = guard
11
+ @blocked_key = blocked_key
12
+ end
13
+ end
14
+
15
+ class SafetyGuards
16
+ attr_reader :guards
17
+
18
+ def initialize
19
+ @guards = {}
20
+ @enabled = true
21
+ @allowlist = {} # { guard_name => [String or Regexp, ...] }
22
+ end
23
+
24
+ def add(name, &block)
25
+ @guards[name.to_sym] = block
26
+ end
27
+
28
+ def remove(name)
29
+ @guards.delete(name.to_sym)
30
+ end
31
+
32
+ def enabled?
33
+ @enabled
34
+ end
35
+
36
+ def enable!
37
+ @enabled = true
38
+ end
39
+
40
+ def disable!
41
+ @enabled = false
42
+ end
43
+
44
+ def empty?
45
+ @guards.empty?
46
+ end
47
+
48
+ def names
49
+ @guards.keys
50
+ end
51
+
52
+ def allow(guard_name, key)
53
+ guard_name = guard_name.to_sym
54
+ @allowlist[guard_name] ||= []
55
+ @allowlist[guard_name] << key unless @allowlist[guard_name].include?(key)
56
+ end
57
+
58
+ def allowed?(guard_name, key)
59
+ entries = @allowlist[guard_name.to_sym]
60
+ return false unless entries
61
+
62
+ entries.any? do |entry|
63
+ case entry
64
+ when Regexp then key.match?(entry)
65
+ else entry.to_s == key.to_s
66
+ end
67
+ end
68
+ end
69
+
70
+ def allowlist
71
+ @allowlist
72
+ end
73
+
74
+ # Compose all guards around a block of code.
75
+ # Each guard is an around-block: guard.call { inner }
76
+ # Result: guard_1 { guard_2 { guard_3 { yield } } }
77
+ def wrap(&block)
78
+ return yield unless @enabled && !@guards.empty?
79
+
80
+ @guards.values.reduce(block) { |inner, guard|
81
+ -> { guard.call(&inner) }
82
+ }.call
83
+ end
84
+ end
85
+
86
+ # Built-in guard: database write prevention
87
+ # Works on all Rails versions (5+) and all database adapters.
88
+ # Prepends a write-intercepting module once, controlled by a thread-local flag.
89
+ module BuiltinGuards
90
+ # Blocks INSERT, UPDATE, DELETE, DROP, CREATE, ALTER, TRUNCATE
91
+ module WriteBlocker
92
+ WRITE_PATTERN = /\A\s*(INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|TRUNCATE)\b/i
93
+ TABLE_PATTERN = /\b(?:INTO|FROM|UPDATE|TABLE|TRUNCATE)\s+[`"]?(\w+)[`"]?/i
94
+
95
+ private
96
+
97
+ def console_agent_check_write!(sql)
98
+ return unless Thread.current[:console_agent_block_writes] && sql.match?(WRITE_PATTERN)
99
+
100
+ table = sql.match(TABLE_PATTERN)&.captures&.first
101
+ guards = ConsoleAgent.configuration.safety_guards
102
+ return if table && guards.allowed?(:database_writes, table)
103
+
104
+ raise ConsoleAgent::SafetyError.new(
105
+ "Database write blocked: #{sql.strip.split(/\s+/).first(3).join(' ')}...",
106
+ guard: :database_writes,
107
+ blocked_key: table
108
+ )
109
+ end
110
+
111
+ public
112
+
113
+ def execute(sql, *args, **kwargs)
114
+ console_agent_check_write!(sql)
115
+ super
116
+ end
117
+
118
+ def exec_delete(sql, *args, **kwargs)
119
+ console_agent_check_write!(sql)
120
+ super
121
+ end
122
+
123
+ def exec_update(sql, *args, **kwargs)
124
+ console_agent_check_write!(sql)
125
+ super
126
+ end
127
+ end
128
+
129
+ def self.database_writes
130
+ ->(& block) {
131
+ ensure_write_blocker_installed!
132
+ Thread.current[:console_agent_block_writes] = true
133
+ begin
134
+ block.call
135
+ ensure
136
+ Thread.current[:console_agent_block_writes] = false
137
+ end
138
+ }
139
+ end
140
+
141
+ def self.ensure_write_blocker_installed!
142
+ return if @write_blocker_installed
143
+
144
+ connection = ActiveRecord::Base.connection
145
+ unless connection.class.ancestors.include?(WriteBlocker)
146
+ connection.class.prepend(WriteBlocker)
147
+ end
148
+ @write_blocker_installed = true
149
+ end
150
+
151
+ # Blocks non-safe HTTP requests (POST, PUT, PATCH, DELETE, etc.) via Net::HTTP.
152
+ # Since most Ruby HTTP libraries (HTTParty, RestClient, Faraday) use Net::HTTP
153
+ # under the hood, this covers them all.
154
+ module HttpBlocker
155
+ SAFE_METHODS = %w[GET HEAD OPTIONS TRACE].freeze
156
+
157
+ def request(req, *args, &block)
158
+ if Thread.current[:console_agent_block_http] && !SAFE_METHODS.include?(req.method)
159
+ host = @address.to_s
160
+ guards = ConsoleAgent.configuration.safety_guards
161
+ unless guards.allowed?(:http_mutations, host)
162
+ raise ConsoleAgent::SafetyError.new(
163
+ "HTTP #{req.method} blocked (#{host}#{req.path})",
164
+ guard: :http_mutations,
165
+ blocked_key: host
166
+ )
167
+ end
168
+ end
169
+ super
170
+ end
171
+ end
172
+
173
+ def self.http_mutations
174
+ ->(&block) {
175
+ ensure_http_blocker_installed!
176
+ Thread.current[:console_agent_block_http] = true
177
+ begin
178
+ block.call
179
+ ensure
180
+ Thread.current[:console_agent_block_http] = false
181
+ end
182
+ }
183
+ end
184
+
185
+ def self.mailers
186
+ ->(&block) {
187
+ old_value = ActionMailer::Base.perform_deliveries
188
+ ActionMailer::Base.perform_deliveries = false
189
+ begin
190
+ block.call
191
+ ensure
192
+ ActionMailer::Base.perform_deliveries = old_value
193
+ end
194
+ }
195
+ end
196
+
197
+ def self.ensure_http_blocker_installed!
198
+ return if @http_blocker_installed
199
+
200
+ require 'net/http'
201
+ unless Net::HTTP.ancestors.include?(HttpBlocker)
202
+ Net::HTTP.prepend(HttpBlocker)
203
+ end
204
+ @http_blocker_installed = true
205
+ end
206
+ end
207
+ end
@@ -5,12 +5,12 @@ module ConsoleAgent
5
5
  return unless ConsoleAgent.configuration.session_logging
6
6
  return unless table_exists?
7
7
 
8
- record = session_class.create!(
8
+ create_attrs = {
9
9
  query: attrs[:query],
10
10
  conversation: Array(attrs[:conversation]).to_json,
11
11
  input_tokens: attrs[:input_tokens] || 0,
12
12
  output_tokens: attrs[:output_tokens] || 0,
13
- user_name: current_user_name,
13
+ user_name: attrs[:user_name] || current_user_name,
14
14
  mode: attrs[:mode].to_s,
15
15
  name: attrs[:name],
16
16
  code_executed: attrs[:code_executed],
@@ -22,7 +22,9 @@ module ConsoleAgent
22
22
  model: ConsoleAgent.configuration.resolved_model,
23
23
  duration_ms: attrs[:duration_ms],
24
24
  created_at: Time.respond_to?(:current) ? Time.current : Time.now
25
- )
25
+ }
26
+ create_attrs[:slack_thread_ts] = attrs[:slack_thread_ts] if attrs[:slack_thread_ts]
27
+ record = session_class.create!(create_attrs)
26
28
  record.id
27
29
  rescue => e
28
30
  msg = "ConsoleAgent: session logging failed: #{e.class}: #{e.message}"
@@ -31,6 +33,15 @@ module ConsoleAgent
31
33
  nil
32
34
  end
33
35
 
36
+ def find_by_slack_thread(thread_ts)
37
+ return nil unless ConsoleAgent.configuration.session_logging
38
+ return nil unless table_exists?
39
+ session_class.where(slack_thread_ts: thread_ts).order(created_at: :desc).first
40
+ rescue => e
41
+ ConsoleAgent.logger.warn("ConsoleAgent: session lookup failed: #{e.class}: #{e.message}")
42
+ nil
43
+ end
44
+
34
45
  def update(id, attrs)
35
46
  return unless id
36
47
  return unless ConsoleAgent.configuration.session_logging
@@ -0,0 +1,465 @@
1
+ require 'json'
2
+ require 'uri'
3
+ require 'net/http'
4
+ require 'openssl'
5
+ require 'console_agent/channel/slack'
6
+ require 'console_agent/conversation_engine'
7
+ require 'console_agent/context_builder'
8
+ require 'console_agent/providers/base'
9
+ require 'console_agent/executor'
10
+
11
+ module ConsoleAgent
12
+ class SlackBot
13
+ def initialize
14
+ @bot_token = ConsoleAgent.configuration.slack_bot_token || ENV['SLACK_BOT_TOKEN']
15
+ @app_token = ConsoleAgent.configuration.slack_app_token || ENV['SLACK_APP_TOKEN']
16
+ @channel_ids = resolve_channel_ids
17
+
18
+ raise ConfigurationError, "SLACK_BOT_TOKEN is required" unless @bot_token
19
+ raise ConfigurationError, "SLACK_APP_TOKEN is required (Socket Mode)" unless @app_token
20
+
21
+ @bot_user_id = nil
22
+ @sessions = {} # thread_ts → { channel:, engine:, thread: }
23
+ @user_cache = {} # slack user_id → display_name
24
+ @mutex = Mutex.new
25
+ end
26
+
27
+ def start
28
+ @bot_user_id = slack_api("auth.test", token: @bot_token).dig("user_id")
29
+ log_startup
30
+
31
+ loop do
32
+ run_socket_mode
33
+ puts "Reconnecting in 5s..."
34
+ sleep 5
35
+ end
36
+ rescue Interrupt
37
+ puts "\nSlackBot shutting down."
38
+ end
39
+
40
+ private
41
+
42
+ # --- Socket Mode connection ---
43
+
44
+ def run_socket_mode
45
+ url = obtain_wss_url
46
+ uri = URI.parse(url)
47
+
48
+ tcp = TCPSocket.new(uri.host, uri.port || 443)
49
+ ssl = OpenSSL::SSL::SSLSocket.new(tcp, OpenSSL::SSL::SSLContext.new)
50
+ ssl.hostname = uri.host
51
+ ssl.connect
52
+
53
+ # WebSocket handshake
54
+ path = "#{uri.path}?#{uri.query}"
55
+ handshake = [
56
+ "GET #{path} HTTP/1.1",
57
+ "Host: #{uri.host}",
58
+ "Upgrade: websocket",
59
+ "Connection: Upgrade",
60
+ "Sec-WebSocket-Key: #{SecureRandom.base64(16)}",
61
+ "Sec-WebSocket-Version: 13",
62
+ "", ""
63
+ ].join("\r\n")
64
+
65
+ ssl.write(handshake)
66
+
67
+ # Read HTTP 101 response headers
68
+ response_line = ssl.gets
69
+ unless response_line&.include?("101")
70
+ raise "WebSocket handshake failed: #{response_line}"
71
+ end
72
+ # Consume remaining headers
73
+ loop do
74
+ line = ssl.gets
75
+ break if line.nil? || line.strip.empty?
76
+ end
77
+
78
+ puts "Connected to Slack Socket Mode."
79
+
80
+ # Main read loop
81
+ loop do
82
+ data = read_ws_frame(ssl)
83
+ next unless data
84
+
85
+ begin
86
+ msg = JSON.parse(data, symbolize_names: true)
87
+ rescue JSON::ParserError
88
+ next
89
+ end
90
+
91
+ # Acknowledge immediately (Slack requires fast ack)
92
+ if msg[:envelope_id]
93
+ send_ws_frame(ssl, JSON.generate({ envelope_id: msg[:envelope_id] }))
94
+ end
95
+
96
+ case msg[:type]
97
+ when "hello"
98
+ # Connection confirmed
99
+ when "disconnect"
100
+ puts "Slack disconnect: #{msg[:reason]}"
101
+ break
102
+ when "events_api"
103
+ handle_event(msg)
104
+ end
105
+ end
106
+ rescue EOFError, IOError, Errno::ECONNRESET, OpenSSL::SSL::SSLError => e
107
+ puts "Socket Mode connection lost: #{e.message}"
108
+ ensure
109
+ ssl&.close rescue nil
110
+ tcp&.close rescue nil
111
+ end
112
+
113
+ def obtain_wss_url
114
+ result = slack_api("apps.connections.open", token: @app_token)
115
+ raise "Failed to obtain WSS URL: #{result["error"]}" unless result["ok"]
116
+ result["url"]
117
+ end
118
+
119
+ # --- WebSocket frame reading/writing (RFC 6455 minimal implementation) ---
120
+
121
+ def read_ws_frame(ssl)
122
+ first_byte = ssl.read(1)&.unpack1("C")
123
+ return nil unless first_byte
124
+
125
+ opcode = first_byte & 0x0F
126
+ # Handle ping (opcode 9) → send pong (opcode 10)
127
+ if opcode == 9
128
+ payload = read_ws_payload(ssl)
129
+ send_ws_pong(ssl, payload)
130
+ return nil
131
+ end
132
+ # Close frame (opcode 8)
133
+ return nil if opcode == 8
134
+ # Only process text frames (opcode 1)
135
+ return nil unless opcode == 1
136
+
137
+ read_ws_payload(ssl)
138
+ end
139
+
140
+ def read_ws_payload(ssl)
141
+ second_byte = ssl.read(1)&.unpack1("C")
142
+ return nil unless second_byte
143
+
144
+ masked = (second_byte & 0x80) != 0
145
+ length = second_byte & 0x7F
146
+
147
+ if length == 126
148
+ length = ssl.read(2).unpack1("n")
149
+ elsif length == 127
150
+ length = ssl.read(8).unpack1("Q>")
151
+ end
152
+
153
+ if masked
154
+ mask_key = ssl.read(4).bytes
155
+ raw = ssl.read(length).bytes
156
+ raw.each_with_index.map { |b, i| (b ^ mask_key[i % 4]).chr }.join
157
+ else
158
+ ssl.read(length)
159
+ end
160
+ end
161
+
162
+ def send_ws_frame(ssl, text)
163
+ bytes = text.encode("UTF-8").bytes
164
+ # Client frames must be masked per RFC 6455
165
+ mask_key = 4.times.map { rand(256) }
166
+ masked = bytes.each_with_index.map { |b, i| b ^ mask_key[i % 4] }
167
+
168
+ frame = [0x81].pack("C") # FIN + text opcode
169
+ if bytes.length < 126
170
+ frame << [(bytes.length | 0x80)].pack("C")
171
+ elsif bytes.length < 65536
172
+ frame << [126 | 0x80].pack("C")
173
+ frame << [bytes.length].pack("n")
174
+ else
175
+ frame << [127 | 0x80].pack("C")
176
+ frame << [bytes.length].pack("Q>")
177
+ end
178
+ frame << mask_key.pack("C*")
179
+ frame << masked.pack("C*")
180
+ ssl.write(frame)
181
+ end
182
+
183
+ def send_ws_pong(ssl, payload)
184
+ payload ||= ""
185
+ bytes = payload.bytes
186
+ mask_key = 4.times.map { rand(256) }
187
+ masked = bytes.each_with_index.map { |b, i| b ^ mask_key[i % 4] }
188
+
189
+ frame = [0x8A].pack("C") # FIN + pong opcode
190
+ frame << [(bytes.length | 0x80)].pack("C")
191
+ frame << mask_key.pack("C*")
192
+ frame << masked.pack("C*")
193
+ ssl.write(frame)
194
+ end
195
+
196
+ # --- Slack Web API (minimal, uses Net::HTTP) ---
197
+
198
+ def slack_api(method, token: @bot_token, **params)
199
+ uri = URI("https://slack.com/api/#{method}")
200
+ http = Net::HTTP.new(uri.host, uri.port)
201
+ http.use_ssl = true
202
+
203
+ if params.empty?
204
+ req = Net::HTTP::Post.new(uri.path)
205
+ else
206
+ req = Net::HTTP::Post.new(uri.path)
207
+ req.body = JSON.generate(params)
208
+ req["Content-Type"] = "application/json; charset=utf-8"
209
+ end
210
+ req["Authorization"] = "Bearer #{token}"
211
+
212
+ resp = http.request(req)
213
+ JSON.parse(resp.body)
214
+ rescue => e
215
+ { "ok" => false, "error" => e.message }
216
+ end
217
+
218
+ def post_message(channel:, thread_ts:, text:)
219
+ slack_api("chat.postMessage", channel: channel, thread_ts: thread_ts, text: text)
220
+ end
221
+
222
+ # --- Event handling ---
223
+
224
+ def handle_event(msg)
225
+ event = msg.dig(:payload, :event)
226
+ return unless event
227
+ return unless event[:type] == "message"
228
+
229
+ # Ignore bot messages, subtypes (edits/deletes), own messages
230
+ return if event[:bot_id]
231
+ return if event[:user] == @bot_user_id
232
+ return if event[:subtype]
233
+
234
+ text = event[:text]
235
+ return unless text && !text.strip.empty?
236
+
237
+ channel_id = event[:channel]
238
+ return unless watched_channel?(channel_id)
239
+
240
+ thread_ts = event[:thread_ts] || event[:ts]
241
+ user_id = event[:user]
242
+ user_name = resolve_user_name(user_id)
243
+
244
+ puts "[#{channel_id}/#{thread_ts}] @#{user_name} << #{text.strip}"
245
+
246
+ session = @mutex.synchronize { @sessions[thread_ts] }
247
+
248
+ command = text.strip.downcase
249
+ if command == 'cancel' || command == 'stop'
250
+ cancel_session(session, channel_id, thread_ts)
251
+ return
252
+ end
253
+
254
+ if command == 'clear'
255
+ count = count_bot_messages(channel_id, thread_ts)
256
+ if count == 0
257
+ post_message(channel: channel_id, thread_ts: thread_ts, text: "No bot messages to clear.")
258
+ else
259
+ post_message(channel: channel_id, thread_ts: thread_ts,
260
+ text: "This will permanently delete #{count} bot message#{'s' unless count == 1} from this thread. Type `clear!` to confirm.")
261
+ end
262
+ return
263
+ end
264
+
265
+ if command == 'clear!'
266
+ cancel_session(session, channel_id, thread_ts) if session
267
+ clear_bot_messages(channel_id, thread_ts)
268
+ return
269
+ end
270
+
271
+ if session
272
+ handle_thread_reply(session, text.strip)
273
+ else
274
+ # New thread, or existing thread after bot restart — start a fresh session
275
+ start_session(channel_id, thread_ts, text.strip, user_name)
276
+ end
277
+ rescue => e
278
+ ConsoleAgent.logger.error("SlackBot event handling error: #{e.class}: #{e.message}")
279
+ end
280
+
281
+ def start_session(channel_id, thread_ts, text, user_name)
282
+ channel = Channel::Slack.new(
283
+ slack_bot: self,
284
+ channel_id: channel_id,
285
+ thread_ts: thread_ts,
286
+ user_name: user_name
287
+ )
288
+
289
+ sandbox_binding = Object.new.instance_eval { binding }
290
+ engine = ConversationEngine.new(
291
+ binding_context: sandbox_binding,
292
+ channel: channel,
293
+ slack_thread_ts: thread_ts
294
+ )
295
+
296
+ # Try to restore conversation history from a previous session (e.g. after bot restart)
297
+ restored = restore_from_db(engine, thread_ts)
298
+
299
+ session = { channel: channel, engine: engine, thread: nil }
300
+ @mutex.synchronize { @sessions[thread_ts] = session }
301
+
302
+ session[:thread] = Thread.new do
303
+ Thread.current.report_on_exception = false
304
+ begin
305
+ channel.display_dim("_session: #{channel_id}/#{thread_ts}_")
306
+ if restored
307
+ puts "Restored session for thread #{thread_ts} (#{engine.history.length} messages)"
308
+ channel.display_dim("_(session restored — continuing from previous conversation)_")
309
+ end
310
+ engine.process_message(text)
311
+ rescue => e
312
+ channel.display_error("Error: #{e.class}: #{e.message}")
313
+ ConsoleAgent.logger.error("SlackBot session error: #{e.class}: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
314
+ ensure
315
+ ActiveRecord::Base.clear_active_connections! if defined?(ActiveRecord::Base)
316
+ end
317
+ end
318
+ end
319
+
320
+ def restore_from_db(engine, thread_ts)
321
+ require 'console_agent/session_logger'
322
+ saved = SessionLogger.find_by_slack_thread(thread_ts)
323
+ return false unless saved
324
+
325
+ engine.init_interactive
326
+ engine.restore_session(saved)
327
+ true
328
+ rescue => e
329
+ ConsoleAgent.logger.warn("SlackBot: failed to restore session for #{thread_ts}: #{e.message}")
330
+ false
331
+ end
332
+
333
+ def handle_thread_reply(session, text)
334
+ channel = session[:channel]
335
+ engine = session[:engine]
336
+
337
+ # If the engine is blocked waiting for user input (ask_user), push to queue
338
+ if waiting_for_reply?(channel)
339
+ channel.receive_reply(text)
340
+ return
341
+ end
342
+
343
+ # Otherwise treat as a new message in the conversation
344
+ session[:thread] = Thread.new do
345
+ Thread.current.report_on_exception = false
346
+ begin
347
+ engine.process_message(text)
348
+ rescue => e
349
+ channel.display_error("Error: #{e.class}: #{e.message}")
350
+ ConsoleAgent.logger.error("SlackBot session error: #{e.class}: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
351
+ ensure
352
+ ActiveRecord::Base.clear_active_connections! if defined?(ActiveRecord::Base)
353
+ end
354
+ end
355
+ end
356
+
357
+ def cancel_session(session, channel_id, thread_ts)
358
+ if session
359
+ session[:channel].cancel!
360
+ session[:channel].display("Stopped.")
361
+ puts "[#{channel_id}/#{thread_ts}] cancel requested"
362
+ else
363
+ post_message(channel: channel_id, thread_ts: thread_ts, text: "No active session to stop.")
364
+ puts "[#{channel_id}/#{thread_ts}] cancel: no session"
365
+ end
366
+ @mutex.synchronize { @sessions.delete(thread_ts) }
367
+ end
368
+
369
+ def count_bot_messages(channel_id, thread_ts)
370
+ result = slack_get("conversations.replies", channel: channel_id, ts: thread_ts, limit: 200)
371
+ return 0 unless result["ok"]
372
+ (result["messages"] || []).count { |m| m["user"] == @bot_user_id }
373
+ rescue
374
+ 0
375
+ end
376
+
377
+ def clear_bot_messages(channel_id, thread_ts)
378
+ result = slack_get("conversations.replies", channel: channel_id, ts: thread_ts, limit: 200)
379
+ unless result["ok"]
380
+ puts "[#{channel_id}/#{thread_ts}] clear: failed to fetch replies: #{result["error"]}"
381
+ return
382
+ end
383
+
384
+ bot_messages = (result["messages"] || []).select { |m| m["user"] == @bot_user_id }
385
+ bot_messages.each do |m|
386
+ puts "[#{channel_id}/#{thread_ts}] clearing #{channel_id.length} / #{m["ts"]}"
387
+ slack_api("chat.delete", channel: channel_id, ts: m["ts"])
388
+ end
389
+ puts "[#{channel_id}/#{thread_ts}] cleared #{bot_messages.length} bot messages"
390
+ rescue => e
391
+ puts "[#{channel_id}/#{thread_ts}] clear failed: #{e.message}"
392
+ end
393
+
394
+ def slack_get(method, **params)
395
+ uri = URI("https://slack.com/api/#{method}")
396
+ uri.query = URI.encode_www_form(params)
397
+ http = Net::HTTP.new(uri.host, uri.port)
398
+ http.use_ssl = true
399
+ req = Net::HTTP::Get.new(uri)
400
+ req["Authorization"] = "Bearer #{@bot_token}"
401
+ resp = http.request(req)
402
+ JSON.parse(resp.body)
403
+ rescue => e
404
+ { "ok" => false, "error" => e.message }
405
+ end
406
+
407
+ def waiting_for_reply?(channel)
408
+ channel.instance_variable_get(:@reply_queue).num_waiting > 0
409
+ end
410
+
411
+ def watched_channel?(channel_id)
412
+ return true if @channel_ids.nil? || @channel_ids.empty?
413
+ @channel_ids.include?(channel_id)
414
+ end
415
+
416
+ def resolve_channel_ids
417
+ ids = ConsoleAgent.configuration.slack_channel_ids || ENV['CONSOLE_AGENT_SLACK_CHANNELS']
418
+ return nil if ids.nil?
419
+ ids = ids.split(',').map(&:strip) if ids.is_a?(String)
420
+ ids
421
+ end
422
+
423
+ def resolve_user_name(user_id)
424
+ return @user_cache[user_id] if @user_cache.key?(user_id)
425
+
426
+ # users.info requires form-encoded params, not JSON
427
+ uri = URI("https://slack.com/api/users.info")
428
+ uri.query = URI.encode_www_form(user: user_id)
429
+ http = Net::HTTP.new(uri.host, uri.port)
430
+ http.use_ssl = true
431
+ req = Net::HTTP::Get.new(uri)
432
+ req["Authorization"] = "Bearer #{@bot_token}"
433
+ resp = http.request(req)
434
+ result = JSON.parse(resp.body)
435
+
436
+ name = result.dig("user", "profile", "display_name")
437
+ name = result.dig("user", "real_name") if name.nil? || name.empty?
438
+ name = result.dig("user", "name") if name.nil? || name.empty?
439
+ @user_cache[user_id] = name || user_id
440
+ rescue => e
441
+ ConsoleAgent.logger.warn("Failed to resolve user name for #{user_id}: #{e.message}")
442
+ @user_cache[user_id] = user_id
443
+ end
444
+
445
+ def log_startup
446
+ channel_info = if @channel_ids && !@channel_ids.empty?
447
+ "channels: #{@channel_ids.join(', ')}"
448
+ else
449
+ "all channels"
450
+ end
451
+ puts "ConsoleAgent SlackBot started (#{channel_info}, bot: #{@bot_user_id})"
452
+
453
+ channel = Channel::Slack.new(slack_bot: self, channel_id: "boot", thread_ts: "boot")
454
+ engine = ConversationEngine.new(
455
+ binding_context: Object.new.instance_eval { binding },
456
+ channel: channel
457
+ )
458
+ puts "\nFull system prompt for Slack sessions:"
459
+ puts "-" * 60
460
+ puts engine.context
461
+ puts "-" * 60
462
+ puts
463
+ end
464
+ end
465
+ end