Module: Syodosima
- Defined in:
- lib/syodosima.rb,
lib/syodosima/oauth.rb,
lib/syodosima/config.rb,
lib/syodosima/logger.rb,
lib/syodosima/discord.rb,
lib/syodosima/message.rb,
lib/syodosima/version.rb,
lib/syodosima/messages.rb
Overview
Message constants for Syodosima
Centralized error messages, log messages, and user-facing text to ensure consistency between implementation and tests.
Defined Under Namespace
Modules: Messages Classes: Error
Constant Summary collapse
- APPLICATION_NAME =
"Discord Calendar Notifier".freeze
- CREDENTIALS_PATH =
ENV.fetch("CREDENTIALS_PATH", "credentials.json")
- TOKEN_PATH =
ENV.fetch("TOKEN_PATH", "token.yaml")
- SCOPE =
Google Calendar scope required for reading events
Google::Apis::CalendarV3::AUTH_CALENDAR_READONLY
- DISCORD_BOT_TOKEN =
Discord configuration (expect env or use placeholder)
ENV["DISCORD_BOT_TOKEN"]
- DISCORD_CHANNEL_ID =
ENV["DISCORD_CHANNEL_ID"]
- REQUIRED_ENV_VARS =
List of env vars required for operation and a short description GOOGLE_TOKEN_YAML is optional; if missing, the app will initiate OAuth flow.
{ "DISCORD_BOT_TOKEN" => "Discord bot token used to post messages", "DISCORD_CHANNEL_ID" => "Discord channel ID to send notifications to", "GOOGLE_CREDENTIALS_JSON" => "Base64 or raw JSON for Google OAuth client credentials" }.freeze
- VERSION =
"0.1.0".freeze
Class Method Summary collapse
- .authorize ⇒ Object
- .build_message(events) ⇒ Object
-
.client_id_and_token_store ⇒ Object
Helper: create client id and token store.
-
.create_discord_bot(token) ⇒ Object
Create a Discord bot instance (extracted for testability).
-
.create_webrick_server(port) ⇒ Object
Create WEBrick server with minimal logging (extracted for clarity).
- .created_files ⇒ Object
-
.deliver_message_with_bot(bot, channel, message) ⇒ Object
Deliver message using a bot instance and manage its lifecycle.
- .fetch_today_events ⇒ Object
-
.format_event(event) ⇒ Object
Format a single event into a message line.
-
.handle_corrupted_token ⇒ Object
Handle corrupted token file by backing up and deleting.
-
.interactive_auth_flow(authorizer, user_id) ⇒ Object
Extracted interactive auth flow to reduce method complexity.
- .logger ⇒ Object
- .logger=(val) ⇒ Object
- .oauth_port ⇒ Object
-
.oauth_request_handler(code_container, server) ⇒ Object
Return a proc that handles oauth callback requests and stores the code.
-
.open_auth_url(auth_url) ⇒ Object
Helper: try to open auth URL in browser (best-effort).
- .redirect_uri_for_port(port) ⇒ Object
- .run ⇒ Object
- .send_discord_message(message) ⇒ Object
-
.start_oauth_server(port) ⇒ Object
Helper: start oauth HTTP server and return [server, code_container, thread].
-
.today_time_window ⇒ Object
Compute RFC3339 time_min/time_max for today according to TIMEZONE_OFFSET.
- .validate_env! ⇒ Object
- .write_credential_files! ⇒ Object
-
.write_env_file(env_key, path) ⇒ Object
Helper to write an environment variable content to a file with restrictive perms.
Class Method Details
.authorize ⇒ Object
10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
# File 'lib/syodosima/oauth.rb', line 10 def self. client_id, token_store = client_id_and_token_store = Google::Auth::UserAuthorizer.new(client_id, SCOPE, token_store) user_id = "default" begin credentials = .get_credentials(user_id) rescue StandardError => e # Some environments may raise a PStore::Error when the token file is corrupted. # Avoid requiring the pstore library; detect by class name instead. raise unless e.is_a?(::PStore::Error) logger.warn(Messages.corrupted_token_log(TOKEN_PATH, e.class, e.)) # In CI, do not attempt deletion or interactive auth; surface a clear error. raise Messages::AUTH_FAILED_CI if ENV["CI"] || ENV["GITHUB_ACTIONS"] handle_corrupted_token # Retry once after cleanup client_id, token_store = client_id_and_token_store = Google::Auth::UserAuthorizer.new(client_id, SCOPE, token_store) credentials = .get_credentials(user_id) end return credentials unless credentials.nil? # perform interactive authorization flow (extracted to reduce complexity) interactive_auth_flow(, user_id) end |
.build_message(events) ⇒ Object
7 8 9 10 11 12 13 |
# File 'lib/syodosima/message.rb', line 7 def self.(events) return "おはようございます!\n今日の予定はありません。" if events.empty? = "おはようございます!\n今日の予定をお知らせします。\n\n" events.each { |e| += format_event(e) } end |
.client_id_and_token_store ⇒ Object
Helper: create client id and token store
105 106 107 108 109 |
# File 'lib/syodosima/oauth.rb', line 105 def self.client_id_and_token_store client_id = Google::Auth::ClientId.from_file(CREDENTIALS_PATH) token_store = Google::Auth::Stores::FileTokenStore.new(file: TOKEN_PATH) [client_id, token_store] end |
.create_discord_bot(token) ⇒ Object
Create a Discord bot instance (extracted for testability)
17 18 19 |
# File 'lib/syodosima/discord.rb', line 17 def self.create_discord_bot(token) Discordrb::Bot.new(token: token) end |
.create_webrick_server(port) ⇒ Object
Create WEBrick server with minimal logging (extracted for clarity)
131 132 133 |
# File 'lib/syodosima/oauth.rb', line 131 def self.create_webrick_server(port) WEBrick::HTTPServer.new(Port: port, Logger: WEBrick::Log.new(IO::NULL), AccessLog: []) end |
.created_files ⇒ Object
29 30 31 |
# File 'lib/syodosima/config.rb', line 29 def self.created_files @created_files end |
.deliver_message_with_bot(bot, channel, message) ⇒ Object
Deliver message using a bot instance and manage its lifecycle
22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
# File 'lib/syodosima/discord.rb', line 22 def self.(bot, channel, ) error_occurred = false bot.ready do |_event| logger.info("Bot is ready!") bot.(channel, ) rescue StandardError => e logger.error("Failed to send message: #{e.message}") error_occurred = true ensure bot.stop end bot.run(true) bot.join raise "Message delivery failed" if error_occurred logger.info("Message sent and bot stopped.") end |
.fetch_today_events ⇒ Object
65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 |
# File 'lib/syodosima.rb', line 65 def self.fetch_today_events service = Google::Apis::CalendarV3::CalendarService.new service..application_name = APPLICATION_NAME service. = time_min, time_max = today_time_window events = service.list_events( "primary", single_events: true, order_by: "startTime", time_min: time_min, time_max: time_max ) events.items end |
.format_event(event) ⇒ Object
Format a single event into a message line
16 17 18 19 20 21 22 23 24 25 |
# File 'lib/syodosima/message.rb', line 16 def self.format_event(event) if event.start.date_time start_time = event.start.date_time end_time = event.end.date_time formatted_time = "#{start_time.strftime('%H:%M')}〜#{end_time.strftime('%H:%M')}" "【#{formatted_time}】 #{event.summary}\n" else "【終日】 #{event.summary}\n" end end |
.handle_corrupted_token ⇒ Object
Handle corrupted token file by backing up and deleting
41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 |
# File 'lib/syodosima/oauth.rb', line 41 def self.handle_corrupted_token return unless File.exist?(TOKEN_PATH) begin ts = Time.now.utc.strftime("%Y%m%d%H%M%S") backup = "#{TOKEN_PATH}.#{ts}.bak" begin File.rename(TOKEN_PATH, backup) logger.warn("#{Messages::BACKUP_CREATED} #{backup}") rescue StandardError FileUtils.cp(TOKEN_PATH, backup) logger.warn("#{Messages::BACKUP_COPIED} #{backup}") File.delete(TOKEN_PATH) end rescue StandardError => e logger.warn(Messages.backup_failed_log(TOKEN_PATH, e.)) end end |
.interactive_auth_flow(authorizer, user_id) ⇒ Object
Extracted interactive auth flow to reduce method complexity
61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 |
# File 'lib/syodosima/oauth.rb', line 61 def self.interactive_auth_flow(, user_id) raise Messages::AUTH_FAILED_CI if ENV["CI"] || ENV["GITHUB_ACTIONS"] raise Messages::AUTH_FAILED_NO_METHOD unless .respond_to?(:get_authorization_url) port = oauth_port redirect_uri = redirect_uri_for_port(port) _server, code_container, server_thread = start_oauth_server(port) auth_url = .(base_url: redirect_uri) logger.info(Messages::BROWSER_AUTH_PROMPT) logger.info(auth_url) logger.info(Messages.oauth_callback_info(port)) open_auth_url(auth_url) server_thread.join code = code_container[:code] raise Messages::AUTH_CODE_NOT_RECEIVED if code.nil? || code.to_s.strip.empty? begin credentials = .get_and_store_credentials_from_code( user_id: user_id, code: code, base_url: redirect_uri ) rescue StandardError => e raise Messages.auth_code_exchange_error(e.) end credentials end |
.logger ⇒ Object
56 57 58 |
# File 'lib/syodosima/logger.rb', line 56 def self.logger @logger end |
.logger=(val) ⇒ Object
60 61 62 |
# File 'lib/syodosima/logger.rb', line 60 def self.logger=(val) @logger = val end |
.oauth_port ⇒ Object
96 97 98 |
# File 'lib/syodosima/oauth.rb', line 96 def self.oauth_port (ENV["OAUTH_PORT"] || "8080").to_i end |
.oauth_request_handler(code_container, server) ⇒ Object
Return a proc that handles oauth callback requests and stores the code
136 137 138 139 140 141 142 143 144 |
# File 'lib/syodosima/oauth.rb', line 136 def self.oauth_request_handler(code_container, server) proc do |req, res| q = URI.decode_www_form(req.query_string || "").to_h code_container[:code] = q["code"] || req.query["code"] res.body = Messages::AUTH_SUCCESS_HTML res.content_type = "text/html; charset=utf-8" Thread.new { server.shutdown } end end |
.open_auth_url(auth_url) ⇒ Object
Helper: try to open auth URL in browser (best-effort)
147 148 149 150 151 152 153 154 155 156 157 158 159 160 |
# File 'lib/syodosima/oauth.rb', line 147 def self.open_auth_url(auth_url) host_os = RbConfig::CONFIG["host_os"] case host_os when /linux|bsd/ system("xdg-open", auth_url) when /darwin/ system("open", auth_url) when /mswin|mingw|cygwin/ system("cmd", "/c", "start", "", auth_url) end rescue StandardError logger.warn(Messages::BROWSER_AUTO_OPEN_FAILED) logger.warn(auth_url) end |
.redirect_uri_for_port(port) ⇒ Object
100 101 102 |
# File 'lib/syodosima/oauth.rb', line 100 def self.redirect_uri_for_port(port) "http://127.0.0.1:#{port}/oauth2callback" end |
.run ⇒ Object
92 93 94 95 96 97 98 99 100 101 102 103 104 |
# File 'lib/syodosima.rb', line 92 def self.run validate_env! write_credential_files! logger.info("今日の予定を取得しています...") events = fetch_today_events = (events) logger.info("Discordに通知を送信します...") () logger.info("完了しました!") end |
.send_discord_message(message) ⇒ Object
7 8 9 10 11 12 13 14 |
# File 'lib/syodosima/discord.rb', line 7 def self.() raise ArgumentError, "Message cannot be nil or empty" if .nil? || .empty? bot = create_discord_bot(DISCORD_BOT_TOKEN) (bot, DISCORD_CHANNEL_ID, ) rescue StandardError => e logger.error("Failed to send Discord message: #{e.message}") end |
.start_oauth_server(port) ⇒ Object
Helper: start oauth HTTP server and return [server, code_container, thread]
112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 |
# File 'lib/syodosima/oauth.rb', line 112 def self.start_oauth_server(port) code_container = { code: nil } server = create_webrick_server(port) # create a handler that closes over the server so it can shut it down mounted_handler = oauth_request_handler(code_container, server) server.mount_proc "/oauth2callback", &mounted_handler server.mount_proc "/auth/callback", &mounted_handler server_thread = Thread.new do server.start rescue StandardError => e warn "WEBrick server error: #{e.message}" end [server, code_container, server_thread] end |
.today_time_window ⇒ Object
Compute RFC3339 time_min/time_max for today according to TIMEZONE_OFFSET
83 84 85 86 87 88 89 90 |
# File 'lib/syodosima.rb', line 83 def self.today_time_window timezone_offset = ENV.fetch("TIMEZONE_OFFSET", "+09:00") now_tz = DateTime.now.new_offset(timezone_offset) today = now_tz.to_date time_min = DateTime.new(today.year, today.month, today.day, 0, 0, 0, timezone_offset).rfc3339 time_max = DateTime.new(today.year, today.month, today.day, 23, 59, 59, timezone_offset).rfc3339 [time_min, time_max] end |
.validate_env! ⇒ Object
27 28 29 30 31 32 33 34 35 36 37 |
# File 'lib/syodosima.rb', line 27 def self.validate_env! missing = REQUIRED_ENV_VARS.select { |k, _| ENV[k].nil? || ENV[k].empty? } return if missing.empty? msg = "Missing required environment variable(s):\n" missing.each do |key, desc| msg += " - #{key}: #{desc}\n" end msg += "\nPlease set these variables in your .env file or environment." abort msg end |
.write_credential_files! ⇒ Object
39 40 41 42 |
# File 'lib/syodosima.rb', line 39 def self.write_credential_files! write_env_file("GOOGLE_CREDENTIALS_JSON", CREDENTIALS_PATH) write_env_file("GOOGLE_TOKEN_YAML", TOKEN_PATH) end |
.write_env_file(env_key, path) ⇒ Object
Helper to write an environment variable content to a file with restrictive perms.
45 46 47 48 49 50 51 52 |
# File 'lib/syodosima.rb', line 45 def self.write_env_file(env_key, path) v = ENV[env_key] return if v.to_s.strip == "" FileUtils.mkdir_p(File.dirname(path)) File.open(path, File::WRONLY | File::CREAT | File::TRUNC, 0o600) { |f| f.write(v) } created_files << path end |