From 818ba17db4fc62d343dd593cb540c27acb70ff02 Mon Sep 17 00:00:00 2001 From: Yoshinari Nomura Date: Wed, 16 Jul 2025 10:09:41 +0900 Subject: [PATCH] Import workings as is --- .ruby-version | 1 + exe/google_oauth_initializer | 12 +- exe/swimmy | 47 ++-- lib/slack_socket_mode_bot.rb | 202 ++++++++++++++++++ .../simple_web_socket.rb | 117 ++++++++++ lib/slack_socket_mode_bot/version.rb | 5 + lib/swimmy/command/at.rb | 2 +- lib/swimmy/command/base.rb | 44 +++- lib/swimmy/command/lunch_time.rb | 2 +- lib/swimmy/command/photo_upload.rb | 2 +- lib/swimmy/command/poll.rb | 20 +- lib/swimmy/command/qiita_trend.rb | 2 +- lib/swimmy/command/today.rb | 11 +- lib/swimmy/resource/schedule.rb | 6 +- 14 files changed, 424 insertions(+), 49 deletions(-) create mode 100644 .ruby-version create mode 100644 lib/slack_socket_mode_bot.rb create mode 100644 lib/slack_socket_mode_bot/simple_web_socket.rb create mode 100644 lib/slack_socket_mode_bot/version.rb diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 0000000..6a81b4c --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +2.7.8 diff --git a/exe/google_oauth_initializer b/exe/google_oauth_initializer index 10c02d8..aa339b7 100755 --- a/exe/google_oauth_initializer +++ b/exe/google_oauth_initializer @@ -39,7 +39,7 @@ class OauthData + @client_id \ + '&redirect_uri=' \ + @redirect_uri \ - + '&scope=https://www.googleapis.com/auth/photoslibrary ' \ + + '&scope=https://www.googleapis.com/auth/photoslibrary.appendonly ' \ + 'https://www.googleapis.com/auth/calendar.events.readonly' \ + '&access_type=offline' end @@ -81,15 +81,7 @@ def authorize(oauth_data) end def open_browser(url) - res = nil - if OS.windows? - res = system('start', url) - elsif OS.mac? - res = system('open', url) - elsif OS.linux? - res = system('xdg-open', url) - end - + res = system('xdg-open', url) if (res.nil? || res == false) puts 'Open below URL with your browser' puts url diff --git a/exe/swimmy b/exe/swimmy index 81a6bd2..884cebf 100755 --- a/exe/swimmy +++ b/exe/swimmy @@ -100,12 +100,6 @@ def to_open_struct(obj) end end -def child_command_classes(command_classes) - command_classes.reject do |k| - k.name&.starts_with?('SlackRubyBot::Commands::') - end -end - def initialize_spreadsheet(spreadsheet_id) require "clian" require "sheetq" @@ -142,20 +136,45 @@ Swimmy::Command.mqtt_client = BOT_NAME = ARGV[0] || 'swimmy' +sent_hello = false + bot = SlackSocketModeBot.new(name: BOT_NAME, token: SLACK_BOT_TOKEN, app_token: SLACK_APP_TOKEN, logger: logger) do |data| - logger.debug("data: #{data}") - # retry_attempt == 0 means the event is the first time. - # see https://api.slack.com/apis/events-api#retries - next unless data[:payload] && data[:payload][:event] && data[:retry_attempt] == 0 + logger.debug("data-to-bot: #{data}") - data = to_open_struct(data[:payload][:event]) + case data[:type] + when "ping" + # ping packet from Websocket layer + data = to_open_struct({:type => "ping"}) - child_command_classes(SlackRubyBot::Commands::Base.command_classes).each do |command_class| - logger.debug("invoke: #{command_class.name}") - command_class.invoke(bot, data) + when "hello" + # "hello" message from Slack + # https://api.slack.com/apis/socket-mode#connect + # Socket Mode 2. Connect to the WebSocket + + next if sent_hello + data = to_open_struct(data) + sent_hello = true + + else + # Event object from Slack Event API. + # retry_attempt == 0 means the event is the first time. + # see https://api.slack.com/apis/events-api#retries + next unless data[:payload] && data[:payload][:event] && data[:retry_attempt] == 0 + + # NOTE: It does not have data[:type]. + data = to_open_struct(data[:payload][:event]) + + # Event examples: + # reaction_added event + # https://api.slack.com/events/reaction_added + # + # message event + # https://api.slack.com/events/message end + Swimmy::Command::Base.invoke_all(bot, data) + rescue Exception puts $!.full_message end diff --git a/lib/slack_socket_mode_bot.rb b/lib/slack_socket_mode_bot.rb new file mode 100644 index 0000000..4612328 --- /dev/null +++ b/lib/slack_socket_mode_bot.rb @@ -0,0 +1,202 @@ +# frozen_string_literal: true + +require "uri" +require "net/http" +require "openssl" +require "websocket" +require "json" + +require_relative "slack_socket_mode_bot/version" +require_relative "slack_socket_mode_bot/simple_web_socket" + +class SlackSocketModeBot + class Error < StandardError; end + + attr_reader :name, :user_id, :cannonical_name + + #: (token: String, ?app_token: String, ?num_of_connections: Integer, ?debug: boolean, ?logger: Logger) { (untyped) -> untyped } -> void + def initialize(name:, token:, app_token: nil, num_of_connections: 4, debug: false, logger: nil, &callback) + @name = name + @token = token + @app_token = app_token + @conns = [] + @debug = debug + @logger = logger + @events = {} + auth_info = web_client.auth_test + @user_id = auth_info.user_id + @cannonical_name = auth_info.user + num_of_connections.times { add_connection(callback) } if app_token + end + + def web_client + self + end + + def reactions_add(options = {}) + call("reactions.add", options) + end + + def chat_postMessage(options = {}) + call("chat.postMessage", options) + end + alias_method :say, :chat_postMessage + + def users_info(options = {}) + to_open_struct(call("users.info", options, http_method: :get)) + end + + def conversations_replies(options = {}) + to_open_struct(call("conversations.replies", options, http_method: :get)) + end + + def name?(bot_name) + bot_name == name + end + + # https://api.slack.com/methods/auth.test + # auth_info = web_client.auth_test + # + # puts "name: #{auth_info.user} id: #{auth_info.user_id}" + # + def auth_test(options = {}) + to_open_struct(call("auth.test", options, http_method: :post)) + end + + #: (String method, untyped data, ?token: String) -> untyped + def call(method, data, token: @token, http_method: :post) + count = 0 + begin + url = URI("https://slack.com/api/" + method) + + if http_method == :get + url.query = URI.encode_www_form(data) + + http = Net::HTTP.new(url.host, url.port) + http.use_ssl = (url.scheme == "https") + request = Net::HTTP::Get.new(url) + request["Authorization"] = "Bearer " + token + + res = http.request(request) + puts "----------------------------------------------" + pp JSON.parse(res.body, symbolize_names: true) + else + res = Net::HTTP.post( + url, JSON.generate(data), + "Content-type" => "application/json; charset=utf-8", + "Authorization" => "Bearer " + token, + ) + end + json = JSON.parse(res.body, symbolize_names: true) + raise Error, json[:error] unless json[:ok] + to_open_struct(json) + # rescue Socket::ResolutionError + rescue SocketError + sleep 1 + count += 1 + retry if count < 3 + raise + end + end + + private def to_open_struct(obj) + case obj + when Hash + OpenStruct.new( + obj.transform_values do |v| + to_open_struct(v) + end + ) + when Array + obj.map do |v| + to_open_struct(v) + end + else + obj + end + end + + private def add_connection(callback) + json = call("apps.connections.open", {}, token: @app_token) + + url = json[:url] + url += "&debug_reconnects=true" if @debug + ws = SimpleWebSocket.new(url) do |type, data| + case type + when :open + @logger.info("[ws:#{ ws.object_id }] websocket open") if @logger + when :close + @logger.info("[ws:#{ ws.object_id }] websocket closed") if @logger + add_connection(callback) + when :ping + callback.call({type: "ping"}) + when :message + begin + json = JSON.parse(data, symbolize_names: true) + rescue JSON::ParserError + add_connection(callback) + next + end + + if @logger + @logger.debug("[ws:#{ ws.object_id }] slack message: #{ JSON.generate(json) }") + end + + case json[:type] + when "hello" + @logger.info("[ws:#{ ws.object_id }] hello (active connections: #{ @conns.size })") if @logger + callback.call(json) + when "disconnect" + ws.close + @logger.info("[ws:#{ ws.object_id }] disconnect (active connections: #{ @conns.size })") if @logger + else + # Event API + payload = json[:payload] + if @logger + msg = "[ws:#{ ws.object_id }] #{ json[:type] } [##{ json[:retry_attempt] + 1 }] (#{ + { + event_id: payload[:event_id], + event_time: Time.at(payload[:event_time]).strftime("%FT%T"), + type: payload[:type], + }.map {|k, v| "#{ k }: #{ v }" }.join(", ") + })" + @logger.info(msg) + end + expired = Time.now.to_i - 600 + @events.reject! {|_, timestamp| timestamp < expired } + + if @events[json[:payload][:event_id]] + # ignore + else + @events[json[:payload][:event_id]] = json[:payload][:event_time] + + response = { envelope_id: json[:envelope_id] } + if json[:accepts_response_payload] + response[:payload] = callback.call(json) + else + callback.call(json) + end + ws.send(JSON.generate(response)) + end + end + end + end + + @conns << ws + end + + #: -> [Array[IO], Array[IO]] + def step + read_ios, write_ios = [], [] + @conns.select! {|ws| ws.step(read_ios, write_ios) } + return read_ios, write_ios + end + + #: -> bot + def run + while true + read_ios, write_ios = step + IO.select(read_ios, write_ios) + end + end +end diff --git a/lib/slack_socket_mode_bot/simple_web_socket.rb b/lib/slack_socket_mode_bot/simple_web_socket.rb new file mode 100644 index 0000000..e76e196 --- /dev/null +++ b/lib/slack_socket_mode_bot/simple_web_socket.rb @@ -0,0 +1,117 @@ +class SlackSocketModeBot::SimpleWebSocket + def initialize(url) + uri = URI.parse(url) + + unless uri.scheme == "https" || uri.scheme == "wss" + raise "unexpected scheme (not secure?): #{ uri.scheme }" + end + + ctx = OpenSSL::SSL::SSLContext.new + ctx.ssl_version = "SSLv23" + ctx.verify_mode = OpenSSL::SSL::VERIFY_PEER + ctx.cert_store = OpenSSL::X509::Store.new + ctx.cert_store.set_default_paths + + count = 0 + begin + io = TCPSocket.new(uri.host, uri.port || 443) + @io = OpenSSL::SSL::SSLSocket.new(io, ctx) + @io.connect + # rescue Socket::ResolutionError + rescue SocketError + sleep 1 + count += 1 + retry if count < 3 + raise + end + + @version = nil + + @fib = Fiber.new do + closed = false + begin + handshake = WebSocket::Handshake::Client.new(url: url) + @write_buff = handshake.to_s.dup + handshake << Fiber.yield until handshake.finished? + + @version = handshake.version + yield :open + + frame = WebSocket::Frame::Incoming::Client.new + frame << handshake.leftovers + while true + while msg = frame.next + case msg.type + when :close + yield :close unless closed + closed = true + when :ping + # FIXME: should yield for tick + send(msg.data, type: :pong) + yield :ping + when :pong + when :text + yield :message, msg.data, :text + when :binary + yield :message, msg.data, :binary + end + end + frame << Fiber.yield + end + rescue EOFError + ensure + yield :close unless closed + @io.close + end + end + + @fib.resume + end + + def send(data, type: :text, code: nil) + raise "not opened yet" unless @version + frame = WebSocket::Frame::Outgoing::Client.new(version: @version, data: data, type: type, code: code) + @write_buff << frame.to_s + end + + def close(code: 1000, reason: "") + send(reason, type: :close, code: code) unless @io.closed? + end + + def step(read_ios, write_ios) + wait_readable = wait_writable = false + + unless @write_buff.empty? + len = @io.write_nonblock(@write_buff, exception: false) + case len + when :wait_readable then wait_readable = true + when :wait_writable then wait_writable = true + else + @write_buff.clear + end + end + + while true + read_buff = @io.read_nonblock(4096, exception: false) + case read_buff + when :wait_readable then wait_readable = true; break + when :wait_writable then wait_writable = true; break + when nil + raise Errno::EPIPE + else + @fib.resume(read_buff) + end + end + + read_ios << @io if wait_readable + write_ios << @io if wait_writable + return true + + rescue Errno::EPIPE, Errno::ECONNRESET + begin + @fib.raise(EOFError) + rescue FiberError + end + return false + end +end diff --git a/lib/slack_socket_mode_bot/version.rb b/lib/slack_socket_mode_bot/version.rb new file mode 100644 index 0000000..df1b34f --- /dev/null +++ b/lib/slack_socket_mode_bot/version.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class SlackSocketModeBot + VERSION = "0.9.1" +end diff --git a/lib/swimmy/command/at.rb b/lib/swimmy/command/at.rb index 707787a..cd556ac 100644 --- a/lib/swimmy/command/at.rb +++ b/lib/swimmy/command/at.rb @@ -55,7 +55,7 @@ class At < Swimmy::Command::Base end tick do |client, data| - puts "at command..." + # puts "at command..." now = Time.now diff --git a/lib/swimmy/command/base.rb b/lib/swimmy/command/base.rb index 41908d3..3bb7f48 100644 --- a/lib/swimmy/command/base.rb +++ b/lib/swimmy/command/base.rb @@ -51,21 +51,51 @@ def self.help_message(command_name = nil) end # You can Create periodic task by using tick. - # tick do |client, data| + # tick |client, data| do # CHANNEL_LIST.each do |channel| # client.say(channel: channel, text: "Hi!") # end # end def self.tick(&block) - SlackRubyBot::Server.on("pong", &block) + on "ping", &block end - # Can remove this in the future? - # Define hooks from within the bot instance #211 - # https://github.com/slack-ruby/slack-ruby-bot/issues/211 - # def self.on(event_name, &block) - SlackRubyBot::Server.on(event_name, &block) + @hooks ||= {} + @hooks[event_name] ||= [] + @hooks[event_name] << block + end + + def self.invoke(client, data) + # data.type is one of + # "ping" ... websocket layer event + # "hello" ... Slack greeting event + # "message", "reaction_added" etc.: Slack Event API event + # + event_name = data.type + if @hooks && @hooks[event_name] + @hooks[event_name].each do |hook| + begin + hook.call(client, data) + rescue StandardError => e + puts "Error in 'on' hook: #{e.message}" + end + end + end + super(client, data) + end + + def self.child_command_classes(command_classes) + command_classes.reject do |k| + k.name&.starts_with?('SlackRubyBot::Commands::') + end + end + private_class_method :child_command_classes + + def self.invoke_all(client, data) + child_command_classes(SlackRubyBot::Commands::Base.command_classes).each do |command_class| + command_class.invoke(client, data) + end end end end diff --git a/lib/swimmy/command/lunch_time.rb b/lib/swimmy/command/lunch_time.rb index cc7f9da..e9af9a4 100644 --- a/lib/swimmy/command/lunch_time.rb +++ b/lib/swimmy/command/lunch_time.rb @@ -37,7 +37,7 @@ class LunchTime < Swimmy::Command::Base end tick do |client, data| - puts "Lunch Time..." + # puts "Lunch Time..." now = Time.new POST_SCHEDULE.each do |channel, time| diff --git a/lib/swimmy/command/photo_upload.rb b/lib/swimmy/command/photo_upload.rb index 5e0ba64..d36c47b 100644 --- a/lib/swimmy/command/photo_upload.rb +++ b/lib/swimmy/command/photo_upload.rb @@ -31,7 +31,7 @@ class PhotoUploadBot < Base client.say(channel: data.channel, text: message) return end - blob = SlackFileDownloader.new(ENV["SLACK_API_TOKEN"]).fetch(file.url_private_download) + blob = SlackFileDownloader.new(ENV["SLACK_BOT_TOKEN"]).fetch(file.url_private_download) url = GooglePhotosUploader.new(google_oauth).upload(blob, file.name, data.text) client.say(channel: data.channel, text: "アップロード完了 #{url}") rescue diff --git a/lib/swimmy/command/poll.rb b/lib/swimmy/command/poll.rb index 9d6a384..1f67ef1 100644 --- a/lib/swimmy/command/poll.rb +++ b/lib/swimmy/command/poll.rb @@ -22,13 +22,22 @@ class PollMatch < Swimmy::Command::Base json = {:user_name => data.user, :text => data.text}.to_json params = JSON.parse(json, symbolize_names: true) res = Poll.new.response(params) - text = JSON.parse(res) + if res + text = JSON.parse(res)["text"] + error = false + else + text = help_message || "poll question answer1,answer2,..." + error = true + end res = client.web_client.chat_postMessage( channel: data.channel, as_user: true, - text: text["text"] + text: text ) + + next if error + $poll[:ts] = res.message.ts $poll[:choices].each_with_index do |choice, i| client.web_client.reactions_add(name: $emoji_list[i][1], channel: data.channel, timestamp: $poll[:ts]) @@ -44,10 +53,6 @@ class PollMatch < Swimmy::Command::Base "選択肢が複数ある場合は,半角コンマで区切ってください." + "選択肢は最大9つまで入力できます." end - - end - - class PollCounter < SlackRubyBot::Server on 'reaction_added' do |client, data| p data if data.item.ts == $poll[:ts] and data.item_user != data.user @@ -74,11 +79,12 @@ class PollCounter < SlackRubyBot::Server end end end - + class Poll def response(params, options = {}) query_str = params[:text] query_str = query_str.match(/poll (.*) (.*)/) + return nil unless query_str $poll[:title] = query_str[1] $poll[:choices] = query_str[2].split(",") $poll[:choices].each do |choice| diff --git a/lib/swimmy/command/qiita_trend.rb b/lib/swimmy/command/qiita_trend.rb index 4277b4b..1ef75b0 100644 --- a/lib/swimmy/command/qiita_trend.rb +++ b/lib/swimmy/command/qiita_trend.rb @@ -44,7 +44,7 @@ class QiitaTrend < Swimmy::Command::Base end tick do |client, data| - puts "Qiita Trend..." + # puts "Qiita Trend..." now = Time.new message = nil diff --git a/lib/swimmy/command/today.rb b/lib/swimmy/command/today.rb index 19554b8..9869194 100644 --- a/lib/swimmy/command/today.rb +++ b/lib/swimmy/command/today.rb @@ -17,16 +17,16 @@ class Today < Base google_oauth ||= begin Swimmy::Resource::GoogleOAuth.new('config/credentials.json', 'config/tokens.json') - rescue e + rescue message = 'Google OAuthの認証に失敗しました.適切な認証情報が設定されているか確認してください.' client.say(channel: data.channel, text: message) return end begin - message = "今日(#{Date.today.strftime("%m/%d")})の予定\n" + message = "今日 (#{Date.today.strftime('%Y-%m-%d')}) の予定\n" message << GetEvents.new(spreadsheet, google_oauth).message - rescue => e + rescue message = "予定の取得に失敗しました." end @@ -61,7 +61,10 @@ def message message = "- 今日の予定はありません.\n" if events.empty? for time, summary, name in events - message << "#{time}: #{summary}(#{name})\n" + if time =~ /(\d{2}):(\d{2}):(\d{2})/ + time = $1 + ":" + $2 + end + message << "#{time} #{summary} (#{name})\n" end return message diff --git a/lib/swimmy/resource/schedule.rb b/lib/swimmy/resource/schedule.rb index 7978e7c..b739fe9 100644 --- a/lib/swimmy/resource/schedule.rb +++ b/lib/swimmy/resource/schedule.rb @@ -77,9 +77,9 @@ def should_execute?(deadline) end def execute(client) - puts "at command executing [#{@command}]..." - text = 'swimmy ' + @command - SlackRubyBot::Hooks::Message.new.call( + text = "#{client.name} #{@command}" + puts "at command executing [#{text}]..." + Swimmy::Command::Base.invoke_all( client, Hashie::Mash.new(type: 'message', text: text, channel: @channel) )