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

Class Method Details

.authorizeObject



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.authorize
  client_id, token_store = client_id_and_token_store
  authorizer = Google::Auth::UserAuthorizer.new(client_id, SCOPE, token_store)
  user_id = "default"

  begin
    credentials = authorizer.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.message))

    # 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
    authorizer = Google::Auth::UserAuthorizer.new(client_id, SCOPE, token_store)
    credentials = authorizer.get_credentials(user_id)
  end

  return credentials unless credentials.nil?

  # perform interactive authorization flow (extracted to reduce complexity)
  interactive_auth_flow(authorizer, user_id)
end

.build_message(events) ⇒ Object



7
8
9
10
11
12
13
# File 'lib/syodosima/message.rb', line 7

def self.build_message(events)
  return "おはようございます!\n今日の予定はありません。" if events.empty?

  message = "おはようございます!\n今日の予定をお知らせします。\n\n"
  events.each { |e| message += format_event(e) }
  message
end

.client_id_and_token_storeObject

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_filesObject



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.deliver_message_with_bot(bot, channel, message)
  error_occurred = false

  bot.ready do |_event|
    logger.info("Bot is ready!")
    bot.send_message(channel, message)
  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_eventsObject



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.client_options.application_name = APPLICATION_NAME
  service.authorization = authorize

  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_tokenObject

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.message))
  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(authorizer, user_id)
  raise Messages::AUTH_FAILED_CI if ENV["CI"] || ENV["GITHUB_ACTIONS"]

  raise Messages::AUTH_FAILED_NO_METHOD unless authorizer.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 = authorizer.get_authorization_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 = authorizer.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.message)
  end

  credentials
end

.loggerObject



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_portObject



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

.runObject



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

  message = build_message(events)

  logger.info("Discordに通知を送信します...")
  send_discord_message(message)
  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.send_discord_message(message)
  raise ArgumentError, "Message cannot be nil or empty" if message.nil? || message.empty?

  bot = create_discord_bot(DISCORD_BOT_TOKEN)
  deliver_message_with_bot(bot, DISCORD_CHANNEL_ID, message)
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_windowObject

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