syodosima 0.1.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 493d11bbfa6735b9e21c7d36f231b3c4409bab5eec23935bedf1dd81a99fc6c0
4
+ data.tar.gz: 497ccecb229f7d8c029645c9ddf16feefc1dbd154a801ee4aeb26e76e768d3f8
5
+ SHA512:
6
+ metadata.gz: 78db993a78afe58c8d719c0607008d1db2acfe47566fa6db3d3856c50391bb138fbacea64f955cff2795c3e59064a8d27b2365f3dc26edff2d10ef6132c2deba
7
+ data.tar.gz: d2abeab626daf29111f3fae97890ef13b1c47af1e20ae800a2b71eec8e4c348f5a8f2a743feb7e4996524c285495f2f0fbf054af015cd7d4d74f85170c6ad8ba
data/.rubocop.yml ADDED
@@ -0,0 +1,18 @@
1
+ inherit_from: .rubocop_todo.yml
2
+
3
+ # The behavior of RuboCop can be controlled via the .rubocop.yml
4
+ # configuration file. It makes it possible to enable/disable
5
+ # certain cops (checks) and to alter their behavior if they accept
6
+ # any parameters. The file can be placed either in your home
7
+ # directory or in some project directory.
8
+ #
9
+ # RuboCop will start looking for the configuration file in the directory
10
+ # where the inspected file is and continue its way up to the root directory.
11
+ #
12
+ # See https://docs.rubocop.org/rubocop/configuration
13
+
14
+ Style/StringLiterals:
15
+ EnforcedStyle: double_quotes
16
+
17
+ Style/FrozenStringLiteralComment:
18
+ EnforcedStyle: never
data/.rubocop_todo.yml ADDED
@@ -0,0 +1,37 @@
1
+ # This configuration was generated by
2
+ # `rubocop --auto-gen-config`
3
+ # on 2025-10-22 09:53:58 UTC using RuboCop version 1.81.1.
4
+ # The point is for the user to remove these configuration records
5
+ # one by one as the offenses are removed from the code base.
6
+ # Note that changes in the inspected code, or installation of new
7
+ # versions of RuboCop, may require this file to be generated again.
8
+
9
+ # Offense count: 8
10
+ # Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes.
11
+ Metrics/AbcSize:
12
+ Max: 38
13
+
14
+ # Offense count: 1
15
+ # Configuration parameters: CountComments, CountAsOne.
16
+ Metrics/ClassLength:
17
+ Max: 181
18
+
19
+ # Offense count: 3
20
+ # Configuration parameters: AllowedMethods, AllowedPatterns.
21
+ Metrics/CyclomaticComplexity:
22
+ Max: 9
23
+
24
+ # Offense count: 16
25
+ # Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns.
26
+ Metrics/MethodLength:
27
+ Max: 34
28
+
29
+ # Offense count: 1
30
+ # Configuration parameters: CountComments, CountAsOne.
31
+ Metrics/ModuleLength:
32
+ Max: 111
33
+
34
+ # Offense count: 3
35
+ # Configuration parameters: AllowedMethods, AllowedPatterns.
36
+ Metrics/PerceivedComplexity:
37
+ Max: 9
@@ -0,0 +1,26 @@
1
+ {
2
+ "cSpell.words": [
3
+ "autocorrect",
4
+ "autocorrection",
5
+ "autorun",
6
+ "bindir",
7
+ "CREAT",
8
+ "discordrb",
9
+ "dotenv",
10
+ "Euki",
11
+ "evend",
12
+ "googleauth",
13
+ "mswin",
14
+ "popen",
15
+ "progname",
16
+ "pstore",
17
+ "readlines",
18
+ "rubocop",
19
+ "rubygems",
20
+ "SANADA",
21
+ "solargraph",
22
+ "syodosima",
23
+ "TRUNC",
24
+ "WRONLY"
25
+ ]
26
+ }
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2025-10-22
4
+
5
+ - Initial release
@@ -0,0 +1,132 @@
1
+ # Contributor Covenant Code of Conduct
2
+
3
+ ## Our Pledge
4
+
5
+ We as members, contributors, and leaders pledge to make participation in our
6
+ community a harassment-free experience for everyone, regardless of age, body
7
+ size, visible or invisible disability, ethnicity, sex characteristics, gender
8
+ identity and expression, level of experience, education, socio-economic status,
9
+ nationality, personal appearance, race, caste, color, religion, or sexual
10
+ identity and orientation.
11
+
12
+ We pledge to act and interact in ways that contribute to an open, welcoming,
13
+ diverse, inclusive, and healthy community.
14
+
15
+ ## Our Standards
16
+
17
+ Examples of behavior that contributes to a positive environment for our
18
+ community include:
19
+
20
+ * Demonstrating empathy and kindness toward other people
21
+ * Being respectful of differing opinions, viewpoints, and experiences
22
+ * Giving and gracefully accepting constructive feedback
23
+ * Accepting responsibility and apologizing to those affected by our mistakes,
24
+ and learning from the experience
25
+ * Focusing on what is best not just for us as individuals, but for the overall
26
+ community
27
+
28
+ Examples of unacceptable behavior include:
29
+
30
+ * The use of sexualized language or imagery, and sexual attention or advances of
31
+ any kind
32
+ * Trolling, insulting or derogatory comments, and personal or political attacks
33
+ * Public or private harassment
34
+ * Publishing others' private information, such as a physical or email address,
35
+ without their explicit permission
36
+ * Other conduct which could reasonably be considered inappropriate in a
37
+ professional setting
38
+
39
+ ## Enforcement Responsibilities
40
+
41
+ Community leaders are responsible for clarifying and enforcing our standards of
42
+ acceptable behavior and will take appropriate and fair corrective action in
43
+ response to any behavior that they deem inappropriate, threatening, offensive,
44
+ or harmful.
45
+
46
+ Community leaders have the right and responsibility to remove, edit, or reject
47
+ comments, commits, code, wiki edits, issues, and other contributions that are
48
+ not aligned to this Code of Conduct, and will communicate reasons for moderation
49
+ decisions when appropriate.
50
+
51
+ ## Scope
52
+
53
+ This Code of Conduct applies within all community spaces, and also applies when
54
+ an individual is officially representing the community in public spaces.
55
+ Examples of representing our community include using an official email address,
56
+ posting via an official social media account, or acting as an appointed
57
+ representative at an online or offline event.
58
+
59
+ ## Enforcement
60
+
61
+ Instances of abusive, harassing, or otherwise unacceptable behavior may be
62
+ reported to the community leaders responsible for enforcement at
63
+ [yuu.mat.930@gmail.com](mailto:yuu.mat.930@gmail.com).
64
+ All complaints will be reviewed and investigated promptly and fairly.
65
+
66
+ All community leaders are obligated to respect the privacy and security of the
67
+ reporter of any incident.
68
+
69
+ ## Enforcement Guidelines
70
+
71
+ Community leaders will follow these Community Impact Guidelines in determining
72
+ the consequences for any action they deem in violation of this Code of Conduct:
73
+
74
+ ### 1. Correction
75
+
76
+ **Community Impact**: Use of inappropriate language or other behavior deemed
77
+ unprofessional or unwelcome in the community.
78
+
79
+ **Consequence**: A private, written warning from community leaders, providing
80
+ clarity around the nature of the violation and an explanation of why the
81
+ behavior was inappropriate. A public apology may be requested.
82
+
83
+ ### 2. Warning
84
+
85
+ **Community Impact**: A violation through a single incident or series of
86
+ actions.
87
+
88
+ **Consequence**: A warning with consequences for continued behavior. No
89
+ interaction with the people involved, including unsolicited interaction with
90
+ those enforcing the Code of Conduct, for a specified period of time. This
91
+ includes avoiding interactions in community spaces as well as external channels
92
+ like social media. Violating these terms may lead to a temporary or permanent
93
+ ban.
94
+
95
+ ### 3. Temporary Ban
96
+
97
+ **Community Impact**: A serious violation of community standards, including
98
+ sustained inappropriate behavior.
99
+
100
+ **Consequence**: A temporary ban from any sort of interaction or public
101
+ communication with the community for a specified period of time. No public or
102
+ private interaction with the people involved, including unsolicited interaction
103
+ with those enforcing the Code of Conduct, is allowed during this period.
104
+ Violating these terms may lead to a permanent ban.
105
+
106
+ ### 4. Permanent Ban
107
+
108
+ **Community Impact**: Demonstrating a pattern of violation of community
109
+ standards, including sustained inappropriate behavior, harassment of an
110
+ individual, or aggression toward or disparagement of classes of individuals.
111
+
112
+ **Consequence**: A permanent ban from any sort of public interaction within the
113
+ community.
114
+
115
+ ## Attribution
116
+
117
+ This Code of Conduct is adapted from the [Contributor Covenant][homepage],
118
+ version 2.1, available at
119
+ [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
120
+
121
+ Community Impact Guidelines were inspired by
122
+ [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
123
+
124
+ For answers to common questions about this code of conduct, see the FAQ at
125
+ [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
126
+ [https://www.contributor-covenant.org/translations][translations].
127
+
128
+ [homepage]: https://www.contributor-covenant.org
129
+ [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
130
+ [Mozilla CoC]: https://github.com/mozilla/diversity
131
+ [FAQ]: https://www.contributor-covenant.org/faq
132
+ [translations]: https://www.contributor-covenant.org/translations
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 SANADA Euki
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,178 @@
1
+ # Syodosima
2
+
3
+ Syodosima gem is a tool to notify Discord of appointments on a given Google Calendar.
4
+
5
+ ## Installation
6
+
7
+ Install the gem and add to the application's Gemfile by executing:
8
+
9
+ ```bash
10
+ bundle add syodosima
11
+ ```
12
+
13
+ If bundler is not being used to manage dependencies, install the gem by executing:
14
+
15
+ ```bash
16
+ gem install syodosima
17
+ ```
18
+
19
+ ### Dependencies
20
+
21
+ This application uses the following Ruby gems:
22
+
23
+ #### Required Dependencies
24
+
25
+ - `discordrb` - Library for operating Discord API
26
+ - `google-apis-calendar_v3` - Library for operating Google Calendar API
27
+ - `dotenv` - Library for managing environment variables
28
+
29
+ #### Development Dependencies
30
+
31
+ - `rubocop` - Ruby code style checker
32
+ - `solargraph` - Ruby language server
33
+
34
+ ### Manual Installation from Source
35
+
36
+ 1. Clone the repository:
37
+
38
+ ```bash
39
+ git clone https://github.com/Desert-sabaku/syodosima.git
40
+ cd syodosima
41
+ ```
42
+
43
+ 2. Install dependencies:
44
+
45
+ ```bash
46
+ bundle install
47
+ ```
48
+
49
+ ## Usage
50
+
51
+ Syodosima automatically retrieves today's appointments from Google Calendar and sends notifications to Discord channels.
52
+
53
+ ### Features
54
+
55
+ - Automatically retrieve today's appointments from Google Calendar
56
+ - Properly display timed events and all-day events
57
+ - Send notifications to Discord channels
58
+ - Support for automatic execution in GitHub Actions
59
+
60
+ ### Setup
61
+
62
+ #### Google Calendar API Setup
63
+
64
+ 1. Create a new project in [Google Cloud Console](https://console.cloud.google.com/)
65
+ 2. Enable Google Calendar API
66
+ 3. Create credentials (OAuth 2.0 Client ID)
67
+ 4. Download the `credentials.json` file
68
+
69
+ #### Discord Bot Setup
70
+
71
+ 1. Create a new application in [Discord Developer Portal](https://discord.com/developers/applications)
72
+ 2. Create a bot and get the token
73
+ 3. Invite the bot to your server and get the channel ID
74
+
75
+ ### Environment Variables
76
+
77
+ Set the following environment variables:
78
+
79
+ #### For Local Execution
80
+
81
+ Create a `.env` file with the following content:
82
+
83
+ ```env
84
+ DISCORD_BOT_TOKEN=your_discord_bot_token_here
85
+ DISCORD_CHANNEL_ID=your_channel_id_here
86
+ GOOGLE_CREDENTIALS_JSON={"type":"service_account","project_id":"..."} # contents of credentials.json
87
+ GOOGLE_TOKEN_YAML=credentials_yaml_content_here # contents of token.yaml
88
+ ```
89
+
90
+ #### For GitHub Actions Execution
91
+
92
+ Set the following secrets in GitHub repository Settings > Secrets and variables > Actions:
93
+
94
+ - `DISCORD_BOT_TOKEN`: Discord Bot token
95
+ - `DISCORD_CHANNEL_ID`: Discord channel ID to send notifications
96
+ - `GOOGLE_CREDENTIALS_JSON`: Contents of credentials.json file
97
+ - `GOOGLE_TOKEN_YAML`: Contents of token.yaml file
98
+
99
+ ### Running the Application
100
+
101
+ #### Local Execution
102
+
103
+ 1. For first run, Google authentication is required:
104
+
105
+ ```bash
106
+ bundle exec rake run:once
107
+ ```
108
+
109
+ 2. Perform Google authentication in the browser and enter the displayed code in the console
110
+ 3. Authentication information will be saved in `token.yaml`
111
+
112
+ #### Automatic Execution with GitHub Actions
113
+
114
+ Create a GitHub Actions workflow file (e.g., `.github/workflows/notify.yml`):
115
+
116
+ ```yaml
117
+ name: Daily Calendar Notification
118
+
119
+ on:
120
+ schedule:
121
+ - cron: "0 0 * * *" # Run daily at 0:00 UTC
122
+ workflow_dispatch: # Manual execution also possible
123
+
124
+ jobs:
125
+ notify:
126
+ runs-on: ubuntu-latest
127
+ steps:
128
+ - uses: actions/checkout@v3
129
+ - uses: ruby/setup-ruby@v1
130
+ with:
131
+ ruby-version: "3.4"
132
+ - name: Install dependencies
133
+ run: bundle install
134
+ - name: Run notification bot
135
+ run: bundle exec rake run:once
136
+ env:
137
+ DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_BOT_TOKEN }}
138
+ DISCORD_CHANNEL_ID: ${{ secrets.DISCORD_CHANNEL_ID }}
139
+ GOOGLE_CREDENTIALS_JSON: ${{ secrets.GOOGLE_CREDENTIALS_JSON }}
140
+ GOOGLE_TOKEN_YAML: ${{ secrets.GOOGLE_TOKEN_YAML }}
141
+ ```
142
+
143
+ ### Notification Example
144
+
145
+ Example of messages sent by the bot:
146
+
147
+ ```text
148
+ おはようございます!
149
+ 今日の予定をお知らせします。
150
+
151
+ 【09:00〜10:00】 チームミーティング
152
+ 【13:00〜14:00】 プロジェクトレビュー
153
+ 【終日】 休日
154
+ ```
155
+
156
+ ### Notes
157
+
158
+ - Google authentication is required for first run
159
+ - Timezone follows system settings
160
+ - Only retrieves appointments from Google Calendar's primary calendar
161
+
162
+ ## Development
163
+
164
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
165
+
166
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
167
+
168
+ ## Contributing
169
+
170
+ Bug reports and pull requests are welcome on GitHub at https://github.com/desert-sabaku/syodosima. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/[USERNAME]/syodosima/blob/gem/CODE_OF_CONDUCT.md).
171
+
172
+ ## License
173
+
174
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
175
+
176
+ ## Code of Conduct
177
+
178
+ Everyone interacting in the Syodosima project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/[USERNAME]/syodosima/blob/gem/CODE_OF_CONDUCT.md).
data/Rakefile ADDED
@@ -0,0 +1,41 @@
1
+ require "bundler/gem_tasks"
2
+ require "minitest/test_task"
3
+
4
+ Minitest::TestTask.create
5
+
6
+ require "rubocop/rake_task"
7
+
8
+ RuboCop::RakeTask.new
9
+
10
+ task default: %i[test rubocop]
11
+
12
+ namespace :run do
13
+ desc "Run the Syodosima notifier once (loads .env if present)"
14
+ task :once do
15
+ require "dotenv/load"
16
+ require_relative "lib/syodosima"
17
+
18
+ Syodosima.run
19
+ end
20
+
21
+ desc "Print environment variables used by the notifier"
22
+ task :env do
23
+ require "dotenv/load"
24
+
25
+ keys = %w[
26
+ DISCORD_BOT_TOKEN
27
+ DISCORD_CHANNEL_ID
28
+ GOOGLE_CREDENTIALS_JSON
29
+ GOOGLE_TOKEN_YAML
30
+ OAUTH_PORT
31
+ TIMEZONE_OFFSET
32
+ LOG_LEVEL
33
+ LOG_OUTPUT
34
+ LOG_FORMAT
35
+ ]
36
+
37
+ keys.each do |k|
38
+ puts "#{k}=#{ENV[k].inspect}"
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,32 @@
1
+ # Configuration constants for Syodosima.
2
+ #
3
+ # Extracted into a separate file to keep `lib/syodosima.rb` concise and
4
+ # to make configuration easier to test and override.
5
+ module Syodosima
6
+ APPLICATION_NAME = "Discord Calendar Notifier".freeze
7
+
8
+ CREDENTIALS_PATH = ENV.fetch("CREDENTIALS_PATH", "credentials.json")
9
+ TOKEN_PATH = ENV.fetch("TOKEN_PATH", "token.yaml")
10
+
11
+ # Google Calendar scope required for reading events
12
+ SCOPE = Google::Apis::CalendarV3::AUTH_CALENDAR_READONLY
13
+
14
+ # Discord configuration (expect env or use placeholder)
15
+ DISCORD_BOT_TOKEN = ENV["DISCORD_BOT_TOKEN"]
16
+ DISCORD_CHANNEL_ID = ENV["DISCORD_CHANNEL_ID"]
17
+
18
+ # List of env vars required for operation and a short description
19
+ # GOOGLE_TOKEN_YAML is optional; if missing, the app will initiate OAuth flow.
20
+ REQUIRED_ENV_VARS = {
21
+ "DISCORD_BOT_TOKEN" => "Discord bot token used to post messages",
22
+ "DISCORD_CHANNEL_ID" => "Discord channel ID to send notifications to",
23
+ "GOOGLE_CREDENTIALS_JSON" => "Base64 or raw JSON for Google OAuth client credentials"
24
+ }.freeze
25
+
26
+ # Track files created at runtime so CI cleanup can remove them.
27
+ @created_files = []
28
+
29
+ def self.created_files
30
+ @created_files
31
+ end
32
+ end
@@ -0,0 +1,42 @@
1
+ # Discord helpers
2
+ #
3
+ # Provides small helpers to create a short-lived Discord bot instance
4
+ # and deliver a single message. Extracted to keep the core module
5
+ # focused on orchestration.
6
+ module Syodosima
7
+ def self.send_discord_message(message)
8
+ raise ArgumentError, "Message cannot be nil or empty" if message.nil? || message.empty?
9
+
10
+ bot = create_discord_bot(DISCORD_BOT_TOKEN)
11
+ deliver_message_with_bot(bot, DISCORD_CHANNEL_ID, message)
12
+ rescue StandardError => e
13
+ logger.error("Failed to send Discord message: #{e.message}")
14
+ end
15
+
16
+ # Create a Discord bot instance (extracted for testability)
17
+ def self.create_discord_bot(token)
18
+ Discordrb::Bot.new(token: token)
19
+ end
20
+
21
+ # Deliver message using a bot instance and manage its lifecycle
22
+ def self.deliver_message_with_bot(bot, channel, message)
23
+ error_occurred = false
24
+
25
+ bot.ready do |_event|
26
+ logger.info("Bot is ready!")
27
+ bot.send_message(channel, message)
28
+ rescue StandardError => e
29
+ logger.error("Failed to send message: #{e.message}")
30
+ error_occurred = true
31
+ ensure
32
+ bot.stop
33
+ end
34
+
35
+ bot.run(true)
36
+ bot.join
37
+
38
+ raise "Message delivery failed" if error_occurred
39
+
40
+ logger.info("Message sent and bot stopped.")
41
+ end
42
+ end
@@ -0,0 +1,63 @@
1
+ # Logger helpers for Syodosima.
2
+ #
3
+ # This module configures and exposes a module-level logger instance
4
+ # which is driven by the environment variables:
5
+ # - LOG_LEVEL (DEBUG/INFO/WARN/ERROR)
6
+ # - LOG_OUTPUT (stdout/stderr/file path)
7
+ # - LOG_FORMAT (text/json)
8
+ #
9
+ # The logger is used across the application for consistent output
10
+ # and is safe to override in tests via `Syodosima.logger=`.
11
+ module Syodosima
12
+ require "logger"
13
+
14
+ # Module-level logger. Configurable via environment variables for CI.
15
+ # LOG_LEVEL: DEBUG/INFO/WARN/ERROR/FATAL/UNKNOWN (default INFO, or WARN in CI)
16
+ # LOG_OUTPUT: stdout|stderr|/path/to/file (default stdout)
17
+ # LOG_FORMAT: text|json (default text)
18
+
19
+ output = ENV.fetch("LOG_OUTPUT", "stdout")
20
+ logger_output = case output.downcase
21
+ when "stdout"
22
+ $stdout
23
+ when "stderr"
24
+ $stderr
25
+ else
26
+ # treat as file path
27
+ output
28
+ end
29
+ @logger = Logger.new(logger_output)
30
+
31
+ # Default log level based on environment
32
+ default_level = ENV["CI"] || ENV["GITHUB_ACTIONS"] ? Logger::WARN : Logger::INFO
33
+
34
+ # Set log level from environment or use default
35
+ env_level = ENV["LOG_LEVEL"]&.upcase
36
+ @logger.level = if env_level && Logger.const_defined?(env_level)
37
+ Logger.const_get(env_level)
38
+ else
39
+ default_level
40
+ end
41
+
42
+ # Formatter: text or json
43
+ format = ENV.fetch("LOG_FORMAT", "text").downcase
44
+ if format == "json"
45
+ require "json"
46
+ @logger.formatter = proc do |severity, datetime, _progname, msg|
47
+ "#{{ timestamp: datetime.iso8601, app: Syodosima::APPLICATION_NAME, level: severity, message: msg }.to_json}\n"
48
+ end
49
+ else
50
+ @logger.formatter = proc do |severity, datetime, _progname, msg|
51
+ timestamp = datetime.iso8601
52
+ "#{timestamp} [#{Syodosima::APPLICATION_NAME}] #{severity} -- : #{msg}\n"
53
+ end
54
+ end
55
+
56
+ def self.logger
57
+ @logger
58
+ end
59
+
60
+ def self.logger=(val)
61
+ @logger = val
62
+ end
63
+ end
@@ -0,0 +1,26 @@
1
+ # Message helpers
2
+ #
3
+ # Small utilities for building and formatting the daily notification
4
+ # message sent to Discord. Extracted from the main module for clarity
5
+ # and better testability.
6
+ module Syodosima
7
+ def self.build_message(events)
8
+ return "おはようございます!\n今日の予定はありません。" if events.empty?
9
+
10
+ message = "おはようございます!\n今日の予定をお知らせします。\n\n"
11
+ events.each { |e| message += format_event(e) }
12
+ message
13
+ end
14
+
15
+ # Format a single event into a message line
16
+ def self.format_event(event)
17
+ if event.start.date_time
18
+ start_time = event.start.date_time
19
+ end_time = event.end.date_time
20
+ formatted_time = "#{start_time.strftime('%H:%M')}〜#{end_time.strftime('%H:%M')}"
21
+ "【#{formatted_time}】 #{event.summary}\n"
22
+ else
23
+ "【終日】 #{event.summary}\n"
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,46 @@
1
+ # Message constants for Syodosima
2
+ #
3
+ # Centralized error messages, log messages, and user-facing text
4
+ # to ensure consistency between implementation and tests.
5
+ module Syodosima
6
+ # Message constants module
7
+ #
8
+ # Contains all user-facing and log messages as frozen constants
9
+ # to maintain consistency across the codebase and tests.
10
+ module Messages
11
+ # OAuth and authentication error messages
12
+ AUTH_FAILED_CI = "Google認証に失敗しました。CI 上では対話認証ができませんので、" \
13
+ "ローカルで一度認証を通し、token.yaml を Secret (GOOGLE_TOKEN_YAML) に登録してください。".freeze
14
+ AUTH_FAILED_NO_METHOD = "Google認証に失敗しました。ローカルで一度認証を通し、token.yamlをSecretに登録してください。".freeze
15
+ AUTH_CODE_EXCHANGE_FAILED = "Google認証に失敗しました(コード交換エラー)".freeze
16
+ AUTH_CODE_NOT_RECEIVED = "認可コードが取得できませんでした。ブラウザでアクセスした際にこのプロセスが起動しているか確認してください。".freeze
17
+
18
+ # OAuth flow information messages
19
+ BROWSER_AUTH_PROMPT = "ブラウザで認証してください:".freeze
20
+ BROWSER_AUTO_OPEN_FAILED = "ブラウザを自動で開けませんでした。URLを手動で開いてください:".freeze
21
+ AUTH_SUCCESS_HTML = "<html><body><h1>認証成功!このウィンドウを閉じてください。</h1></body></html>".freeze
22
+
23
+ # Token corruption messages
24
+ CORRUPTED_TOKEN_DETECTED = "Detected corrupted token store".freeze
25
+ BACKUP_CREATED = "Backed up corrupted token file to:".freeze
26
+ BACKUP_COPIED = "Copied corrupted token file to backup:".freeze
27
+ BACKUP_FAILED = "Failed to backup/delete corrupted token file".freeze
28
+
29
+ # Helper methods for formatted messages
30
+ def self.oauth_callback_info(port)
31
+ "このプロセスは 127.0.0.1:#{port} でコールバックを待ち受けます。(PATH: /oauth2callback または /auth/callback)"
32
+ end
33
+
34
+ def self.corrupted_token_log(token_path, error_class, error_message)
35
+ "#{CORRUPTED_TOKEN_DETECTED} (#{token_path}): #{error_class}: #{error_message}"
36
+ end
37
+
38
+ def self.auth_code_exchange_error(message)
39
+ "#{AUTH_CODE_EXCHANGE_FAILED}: #{message}"
40
+ end
41
+
42
+ def self.backup_failed_log(token_path, error_message)
43
+ "#{BACKUP_FAILED} #{token_path}: #{error_message}"
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,161 @@
1
+ require "fileutils"
2
+
3
+ # OAuth helper functions
4
+ #
5
+ # Contains helpers for performing Google OAuth interactive flows and
6
+ # for starting a local HTTP server to receive the callback during
7
+ # user authorization. These are extracted from the main module to
8
+ # keep the top-level module concise.
9
+ module Syodosima
10
+ def self.authorize
11
+ client_id, token_store = client_id_and_token_store
12
+ authorizer = Google::Auth::UserAuthorizer.new(client_id, SCOPE, token_store)
13
+ user_id = "default"
14
+
15
+ begin
16
+ credentials = authorizer.get_credentials(user_id)
17
+ rescue StandardError => e
18
+ # Some environments may raise a PStore::Error when the token file is corrupted.
19
+ # Avoid requiring the pstore library; detect by class name instead.
20
+ raise unless e.is_a?(::PStore::Error)
21
+
22
+ logger.warn(Messages.corrupted_token_log(TOKEN_PATH, e.class, e.message))
23
+
24
+ # In CI, do not attempt deletion or interactive auth; surface a clear error.
25
+ raise Messages::AUTH_FAILED_CI if ENV["CI"] || ENV["GITHUB_ACTIONS"]
26
+
27
+ handle_corrupted_token
28
+ # Retry once after cleanup
29
+ client_id, token_store = client_id_and_token_store
30
+ authorizer = Google::Auth::UserAuthorizer.new(client_id, SCOPE, token_store)
31
+ credentials = authorizer.get_credentials(user_id)
32
+ end
33
+
34
+ return credentials unless credentials.nil?
35
+
36
+ # perform interactive authorization flow (extracted to reduce complexity)
37
+ interactive_auth_flow(authorizer, user_id)
38
+ end
39
+
40
+ # Handle corrupted token file by backing up and deleting
41
+ def self.handle_corrupted_token
42
+ return unless File.exist?(TOKEN_PATH)
43
+
44
+ begin
45
+ ts = Time.now.utc.strftime("%Y%m%d%H%M%S")
46
+ backup = "#{TOKEN_PATH}.#{ts}.bak"
47
+ begin
48
+ File.rename(TOKEN_PATH, backup)
49
+ logger.warn("#{Messages::BACKUP_CREATED} #{backup}")
50
+ rescue StandardError
51
+ FileUtils.cp(TOKEN_PATH, backup)
52
+ logger.warn("#{Messages::BACKUP_COPIED} #{backup}")
53
+ File.delete(TOKEN_PATH)
54
+ end
55
+ rescue StandardError => e
56
+ logger.warn(Messages.backup_failed_log(TOKEN_PATH, e.message))
57
+ end
58
+ end
59
+
60
+ # Extracted interactive auth flow to reduce method complexity
61
+ def self.interactive_auth_flow(authorizer, user_id)
62
+ raise Messages::AUTH_FAILED_CI if ENV["CI"] || ENV["GITHUB_ACTIONS"]
63
+
64
+ raise Messages::AUTH_FAILED_NO_METHOD unless authorizer.respond_to?(:get_authorization_url)
65
+
66
+ port = oauth_port
67
+ redirect_uri = redirect_uri_for_port(port)
68
+
69
+ _server, code_container, server_thread = start_oauth_server(port)
70
+
71
+ auth_url = authorizer.get_authorization_url(base_url: redirect_uri)
72
+ logger.info(Messages::BROWSER_AUTH_PROMPT)
73
+ logger.info(auth_url)
74
+ logger.info(Messages.oauth_callback_info(port))
75
+
76
+ open_auth_url(auth_url)
77
+
78
+ server_thread.join
79
+
80
+ code = code_container[:code]
81
+ raise Messages::AUTH_CODE_NOT_RECEIVED if code.nil? || code.to_s.strip.empty?
82
+
83
+ begin
84
+ credentials = authorizer.get_and_store_credentials_from_code(
85
+ user_id: user_id,
86
+ code: code,
87
+ base_url: redirect_uri
88
+ )
89
+ rescue StandardError => e
90
+ raise Messages.auth_code_exchange_error(e.message)
91
+ end
92
+
93
+ credentials
94
+ end
95
+
96
+ def self.oauth_port
97
+ (ENV["OAUTH_PORT"] || "8080").to_i
98
+ end
99
+
100
+ def self.redirect_uri_for_port(port)
101
+ "http://127.0.0.1:#{port}/oauth2callback"
102
+ end
103
+
104
+ # Helper: create client id and token store
105
+ def self.client_id_and_token_store
106
+ client_id = Google::Auth::ClientId.from_file(CREDENTIALS_PATH)
107
+ token_store = Google::Auth::Stores::FileTokenStore.new(file: TOKEN_PATH)
108
+ [client_id, token_store]
109
+ end
110
+
111
+ # Helper: start oauth HTTP server and return [server, code_container, thread]
112
+ def self.start_oauth_server(port)
113
+ code_container = { code: nil }
114
+
115
+ server = create_webrick_server(port)
116
+ # create a handler that closes over the server so it can shut it down
117
+ mounted_handler = oauth_request_handler(code_container, server)
118
+ server.mount_proc "/oauth2callback", &mounted_handler
119
+ server.mount_proc "/auth/callback", &mounted_handler
120
+
121
+ server_thread = Thread.new do
122
+ server.start
123
+ rescue StandardError => e
124
+ warn "WEBrick server error: #{e.message}"
125
+ end
126
+
127
+ [server, code_container, server_thread]
128
+ end
129
+
130
+ # Create WEBrick server with minimal logging (extracted for clarity)
131
+ def self.create_webrick_server(port)
132
+ WEBrick::HTTPServer.new(Port: port, Logger: WEBrick::Log.new(IO::NULL), AccessLog: [])
133
+ end
134
+
135
+ # Return a proc that handles oauth callback requests and stores the code
136
+ def self.oauth_request_handler(code_container, server)
137
+ proc do |req, res|
138
+ q = URI.decode_www_form(req.query_string || "").to_h
139
+ code_container[:code] = q["code"] || req.query["code"]
140
+ res.body = Messages::AUTH_SUCCESS_HTML
141
+ res.content_type = "text/html; charset=utf-8"
142
+ Thread.new { server.shutdown }
143
+ end
144
+ end
145
+
146
+ # Helper: try to open auth URL in browser (best-effort)
147
+ def self.open_auth_url(auth_url)
148
+ host_os = RbConfig::CONFIG["host_os"]
149
+ case host_os
150
+ when /linux|bsd/
151
+ system("xdg-open", auth_url)
152
+ when /darwin/
153
+ system("open", auth_url)
154
+ when /mswin|mingw|cygwin/
155
+ system("cmd", "/c", "start", "", auth_url)
156
+ end
157
+ rescue StandardError
158
+ logger.warn(Messages::BROWSER_AUTO_OPEN_FAILED)
159
+ logger.warn(auth_url)
160
+ end
161
+ end
@@ -0,0 +1,3 @@
1
+ module Syodosima
2
+ VERSION = "0.1.0".freeze
3
+ end
data/lib/syodosima.rb ADDED
@@ -0,0 +1,105 @@
1
+ require_relative "syodosima/version"
2
+
3
+ require "bundler/setup"
4
+ require "google/apis/calendar_v3"
5
+ require "googleauth"
6
+ require "googleauth/stores/file_token_store"
7
+ require "fileutils"
8
+ require "discordrb"
9
+ require "date"
10
+ require "dotenv/load"
11
+ require "rbconfig"
12
+ require "webrick"
13
+ require "uri"
14
+
15
+ # Syodosima integrates Google Calendar with Discord to send daily event notifications
16
+ module Syodosima
17
+ class Error < StandardError; end
18
+
19
+ # Validate required environment variables and configuration constants
20
+ require_relative "syodosima/config"
21
+ require_relative "syodosima/logger"
22
+ require_relative "syodosima/messages"
23
+ require_relative "syodosima/oauth"
24
+ require_relative "syodosima/discord"
25
+ require_relative "syodosima/message"
26
+
27
+ def self.validate_env!
28
+ missing = REQUIRED_ENV_VARS.select { |k, _| ENV[k].nil? || ENV[k].empty? }
29
+ return if missing.empty?
30
+
31
+ msg = "Missing required environment variable(s):\n"
32
+ missing.each do |key, desc|
33
+ msg += " - #{key}: #{desc}\n"
34
+ end
35
+ msg += "\nPlease set these variables in your .env file or environment."
36
+ abort msg
37
+ end
38
+
39
+ def self.write_credential_files!
40
+ write_env_file("GOOGLE_CREDENTIALS_JSON", CREDENTIALS_PATH)
41
+ write_env_file("GOOGLE_TOKEN_YAML", TOKEN_PATH)
42
+ end
43
+
44
+ # Helper to write an environment variable content to a file with restrictive perms.
45
+ def self.write_env_file(env_key, path)
46
+ v = ENV[env_key]
47
+ return if v.to_s.strip == ""
48
+
49
+ FileUtils.mkdir_p(File.dirname(path))
50
+ File.open(path, File::WRONLY | File::CREAT | File::TRUNC, 0o600) { |f| f.write(v) }
51
+ created_files << path
52
+ end
53
+
54
+ # Register at_exit handler only in CI environments
55
+ if ENV["CI"] || ENV["GITHUB_ACTIONS"]
56
+ at_exit do
57
+ created_files.each do |file|
58
+ File.delete(file) if File.exist?(file)
59
+ rescue StandardError => e
60
+ warn "Warning: Failed to cleanup #{file}: #{e.message}"
61
+ end
62
+ end
63
+ end
64
+
65
+ def self.fetch_today_events
66
+ service = Google::Apis::CalendarV3::CalendarService.new
67
+ service.client_options.application_name = APPLICATION_NAME
68
+ service.authorization = authorize
69
+
70
+ time_min, time_max = today_time_window
71
+
72
+ events = service.list_events(
73
+ "primary",
74
+ single_events: true,
75
+ order_by: "startTime",
76
+ time_min: time_min,
77
+ time_max: time_max
78
+ )
79
+ events.items
80
+ end
81
+
82
+ # Compute RFC3339 time_min/time_max for today according to TIMEZONE_OFFSET
83
+ def self.today_time_window
84
+ timezone_offset = ENV.fetch("TIMEZONE_OFFSET", "+09:00")
85
+ now_tz = DateTime.now.new_offset(timezone_offset)
86
+ today = now_tz.to_date
87
+ time_min = DateTime.new(today.year, today.month, today.day, 0, 0, 0, timezone_offset).rfc3339
88
+ time_max = DateTime.new(today.year, today.month, today.day, 23, 59, 59, timezone_offset).rfc3339
89
+ [time_min, time_max]
90
+ end
91
+
92
+ def self.run
93
+ validate_env!
94
+ write_credential_files!
95
+
96
+ logger.info("今日の予定を取得しています...")
97
+ events = fetch_today_events
98
+
99
+ message = build_message(events)
100
+
101
+ logger.info("Discordに通知を送信します...")
102
+ send_discord_message(message)
103
+ logger.info("完了しました!")
104
+ end
105
+ end
data/sig/syodosima.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Syodosima
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,204 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: syodosima
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - SANADA Euki
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: discordrb
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: 3.5.0
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: 3.5.0
26
+ - !ruby/object:Gem::Dependency
27
+ name: dotenv
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: 3.1.8
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: 3.1.8
40
+ - !ruby/object:Gem::Dependency
41
+ name: google-apis-calendar_v3
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: 0.53.0
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: 0.53.0
54
+ - !ruby/object:Gem::Dependency
55
+ name: pstore
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: 0.2.0
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: 0.2.0
68
+ - !ruby/object:Gem::Dependency
69
+ name: webrick
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: 1.9.1
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: 1.9.1
82
+ - !ruby/object:Gem::Dependency
83
+ name: bundler
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: 2.7.2
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: 2.7.2
96
+ - !ruby/object:Gem::Dependency
97
+ name: minitest
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: 5.26.0
103
+ type: :development
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: 5.26.0
110
+ - !ruby/object:Gem::Dependency
111
+ name: rake
112
+ requirement: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - ">="
115
+ - !ruby/object:Gem::Version
116
+ version: 13.3.0
117
+ type: :development
118
+ prerelease: false
119
+ version_requirements: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - ">="
122
+ - !ruby/object:Gem::Version
123
+ version: 13.3.0
124
+ - !ruby/object:Gem::Dependency
125
+ name: rubocop
126
+ requirement: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - ">="
129
+ - !ruby/object:Gem::Version
130
+ version: 1.81.1
131
+ type: :development
132
+ prerelease: false
133
+ version_requirements: !ruby/object:Gem::Requirement
134
+ requirements:
135
+ - - ">="
136
+ - !ruby/object:Gem::Version
137
+ version: 1.81.1
138
+ - !ruby/object:Gem::Dependency
139
+ name: solargraph
140
+ requirement: !ruby/object:Gem::Requirement
141
+ requirements:
142
+ - - ">="
143
+ - !ruby/object:Gem::Version
144
+ version: 0.57.0
145
+ type: :development
146
+ prerelease: false
147
+ version_requirements: !ruby/object:Gem::Requirement
148
+ requirements:
149
+ - - ">="
150
+ - !ruby/object:Gem::Version
151
+ version: 0.57.0
152
+ description: 'A Ruby gem that sends notifications to a Discord channel about events
153
+ from a specified Google Calendar.
154
+
155
+ '
156
+ email:
157
+ - yuu.mat.930@gmail.com
158
+ executables: []
159
+ extensions: []
160
+ extra_rdoc_files: []
161
+ files:
162
+ - ".rubocop.yml"
163
+ - ".rubocop_todo.yml"
164
+ - ".vscode/settings.json"
165
+ - CHANGELOG.md
166
+ - CODE_OF_CONDUCT.md
167
+ - LICENSE.txt
168
+ - README.md
169
+ - Rakefile
170
+ - lib/syodosima.rb
171
+ - lib/syodosima/config.rb
172
+ - lib/syodosima/discord.rb
173
+ - lib/syodosima/logger.rb
174
+ - lib/syodosima/message.rb
175
+ - lib/syodosima/messages.rb
176
+ - lib/syodosima/oauth.rb
177
+ - lib/syodosima/version.rb
178
+ - sig/syodosima.rbs
179
+ homepage: https://github.com/Desert-sabaku/syodosima
180
+ licenses:
181
+ - MIT
182
+ metadata:
183
+ allowed_push_host: https://rubygems.org
184
+ homepage_uri: https://github.com/Desert-sabaku/syodosima
185
+ source_code_uri: https://github.com/Desert-sabaku/syodosima
186
+ changelog_uri: https://github.com/Desert-sabaku/syodosima/blob/main/CHANGELOG.md
187
+ rdoc_options: []
188
+ require_paths:
189
+ - lib
190
+ required_ruby_version: !ruby/object:Gem::Requirement
191
+ requirements:
192
+ - - ">="
193
+ - !ruby/object:Gem::Version
194
+ version: 3.1.0
195
+ required_rubygems_version: !ruby/object:Gem::Requirement
196
+ requirements:
197
+ - - ">="
198
+ - !ruby/object:Gem::Version
199
+ version: '0'
200
+ requirements: []
201
+ rubygems_version: 3.7.2
202
+ specification_version: 4
203
+ summary: Notify Discord of appointments on a given Google Calendar.
204
+ test_files: []