diff --git a/.github/actions/load-config/action.yaml b/.github/actions/load-config/action.yaml new file mode 100644 index 0000000..dd9b0e8 --- /dev/null +++ b/.github/actions/load-config/action.yaml @@ -0,0 +1,37 @@ +name: 'Write Application Config' +description: 'Write application config.json file' +author: 'Your Name' + +inputs: + app_config: + description: 'Application config.json content' + required: true + app_config_path: + description: 'Path where config.json will be created' + required: false + default: 'config.json' + +runs: + using: 'composite' + steps: + - name: Write config.json + shell: bash + run: | + if [ -z "${{ inputs.app_config }}" ]; then + echo "::error::APP_CONFIG is empty" + exit 1 + fi + + # Mask the config value to prevent it from appearing in logs + echo "::add-mask::${{ inputs.app_config }}" + + # Write the config and validate it's valid JSON + echo '${{ inputs.app_config }}' > "${{ inputs.app_config_path }}" + + # Validate JSON format + if ! jq empty "${{ inputs.app_config_path }}" 2>/dev/null; then + echo "::error::APP_CONFIG is not valid JSON" + exit 1 + fi + + echo "::notice::Application config file created successfully" diff --git a/.github/actions/setup-terraform/action.yaml b/.github/actions/setup-terraform/action.yaml new file mode 100644 index 0000000..e973368 --- /dev/null +++ b/.github/actions/setup-terraform/action.yaml @@ -0,0 +1,123 @@ +name: 'Setup Terraform Environment' +description: 'Initialize Terraform with workspace, config, and SA key' +author: 'Your Name' + +inputs: + terraform_version: + description: 'Terraform version to install' + required: false + default: '1.6.3' + terraform_wrapper: + description: 'Enable Terraform wrapper' + required: false + default: 'false' + service_account_key: + description: 'Yandex Cloud Service Account Key (JSON)' + required: true + service_account_key_path: + description: 'Path where iam.json will be created' + required: false + default: 'iam.json' + app_config: + description: 'Application config.json content' + required: true + app_config_path: + description: 'Path where config.json will be created' + required: false + default: 'config.json' + workspace: + description: 'Terraform workspace name' + required: true + aws_access_key_id: + description: 'AWS access key for S3 backend' + required: true + aws_secret_access_key: + description: 'AWS secret key for S3 backend' + required: true + cache_key: + description: 'Cache key for Terraform providers' + required: true + +outputs: + cache-hit: + description: 'Whether the cache was hit' + value: ${{ steps.cache.outputs.cache-hit }} + +runs: + using: 'composite' + steps: + - name: Setup Terraform + uses: hashicorp/setup-terraform@v3 + with: + terraform_version: ${{ inputs.terraform_version }} + terraform_wrapper: ${{ inputs.terraform_wrapper }} + + - name: Write service account key + shell: bash + run: | + if [ -z "${{ inputs.service_account_key }}" ]; then + echo "::error::SERVICE_ACCOUNT_KEY is empty" + exit 1 + fi + + # Write the key and validate it's valid JSON + echo '${{ inputs.service_account_key }}' > "${{ inputs.service_account_key_path }}" + + # Validate JSON format + if ! jq empty "${{ inputs.service_account_key_path }}" 2>/dev/null; then + echo "::error::SERVICE_ACCOUNT_KEY is not valid JSON" + exit 1 + fi + + echo "::notice::Service account key file created and validated" + + - name: Write config.json + shell: bash + run: | + if [ -z "${{ inputs.app_config }}" ]; then + echo "::error::APP_CONFIG is empty" + exit 1 + fi + + # Mask the config value to prevent it from appearing in logs + echo "::add-mask::${{ inputs.app_config }}" + + # Write the config and validate it's valid JSON + echo '${{ inputs.app_config }}' > "${{ inputs.app_config_path }}" + + # Validate JSON format + if ! jq empty "${{ inputs.app_config_path }}" 2>/dev/null; then + echo "::error::APP_CONFIG is not valid JSON" + exit 1 + fi + + echo "::notice::Application config written to: ${{ inputs.app_config_path }}" + + - name: Cache Terraform plugins + id: cache + uses: actions/cache@v3 + with: + path: | + .terraform + .terraform.lock.hcl + key: ${{ inputs.cache_key }} + restore-keys: | + ${{ inputs.cache_key }} + + - name: Terraform Init + if: steps.cache.outputs.cache-hit != 'true' + shell: bash + env: + AWS_ACCESS_KEY_ID: ${{ inputs.aws_access_key_id }} + AWS_SECRET_ACCESS_KEY: ${{ inputs.aws_secret_access_key }} + run: | + terraform init \ + -backend-config="access_key=$AWS_ACCESS_KEY_ID" \ + -backend-config="secret_key=$AWS_SECRET_ACCESS_KEY" + echo "::notice::Terraform initialized" + + - name: Select or Create Terraform Workspace + shell: bash + run: | + terraform workspace select ${{ inputs.workspace }} || terraform workspace new ${{ inputs.workspace }} + echo "::notice::Terraform workspace: ${{ inputs.workspace }}" diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml new file mode 100644 index 0000000..f5ba158 --- /dev/null +++ b/.github/workflows/deploy.yaml @@ -0,0 +1,366 @@ +name: Deploy to Yandex Cloud + +on: + push: + branches: + - main + - dev + workflow_dispatch: + +concurrency: + group: deploy-${{ github.ref }} + cancel-in-progress: true + +env: + TERRAFORM_VERSION: 1.6.3 + GOOSE_VERSION: 3.26.0 + GO_VERSION: 1.21 + APP_CONFIG_PATH: config.json + +jobs: + setup: + name: Setup Environment + runs-on: ubuntu-latest + environment: ${{ github.ref == 'refs/heads/main' && 'prod' || 'dev' }} + outputs: + cache_key: ${{ steps.set-cache-key.outputs.cache_key }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set workspace + run: | + WORKSPACE=${{ github.ref == 'refs/heads/main' && 'prod' || 'dev' }} + echo "WORKSPACE=$WORKSPACE" >> $GITHUB_ENV + echo "::notice::Workspace: $WORKSPACE" + + - name: Set cache key + id: set-cache-key + run: | + CACHE_KEY="${{ runner.os }}-${{ github.ref_name }}-terraform-${{ hashFiles('**/.terraform.lock.hcl') }}" + echo "cache_key=$CACHE_KEY" >> $GITHUB_OUTPUT + echo "::notice::Cache key: $CACHE_KEY" + + - name: Setup Terraform Environment + uses: ./.github/actions/setup-terraform + with: + terraform_version: ${{ env.TERRAFORM_VERSION }} + service_account_key: ${{ secrets.SERVICE_ACCOUNT_KEY }} + app_config: ${{ secrets.APP_CONFIG }} + app_config_path: ${{ env.APP_CONFIG_PATH }} + workspace: ${{ env.WORKSPACE }} + aws_access_key_id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws_secret_access_key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + cache_key: ${{ steps.set-cache-key.outputs.cache_key }} + + lint: + name: Code Linting + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + + - name: Cache Go modules + uses: actions/cache@v3 + with: + path: | + ~/.cache/go-build + ~/go/pkg/mod + key: ${{ runner.os }}-${{ github.ref_name }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-${{ github.ref_name }}-go- + + - name: Install revive + run: go install github.com/mgechev/revive@latest + + - name: Run revive + run: revive -config revive.toml -formatter friendly ./... + + migrate: + name: Database Migration + runs-on: ubuntu-latest + environment: ${{ github.ref == 'refs/heads/main' && 'prod' || 'dev' }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set workspace + run: | + WORKSPACE=${{ github.ref == 'refs/heads/main' && 'prod' || 'dev' }} + echo "WORKSPACE=$WORKSPACE" >> $GITHUB_ENV + echo "::notice::Workspace: $WORKSPACE" + + - name: Install YC CLI + run: | + curl -sSL https://storage.yandexcloud.net/yandexcloud-yc/install.sh | bash + echo "${HOME}/yandex-cloud/bin" >> $GITHUB_PATH + + - name: Authenticate YC CLI + env: + YC_SERVICE_ACCOUNT_KEY: ${{ secrets.SERVICE_ACCOUNT_KEY }} + YC_CLOUD_ID: ${{ secrets.CLOUD_ID }} + YC_FOLDER_ID: ${{ secrets.FOLDER_ID }} + run: | + echo "$YC_SERVICE_ACCOUNT_KEY" > iam.json + yc config set service-account-key iam.json + yc config set cloud-id "$YC_CLOUD_ID" + yc config set folder-id "$YC_FOLDER_ID" + + - name: Generate short-lived IAM token + run: | + export YDB_ACCESS_TOKEN=$(yc iam create-token) + echo "YDB_ACCESS_TOKEN=$YDB_ACCESS_TOKEN" >> $GITHUB_ENV + + - name: Install Goose + run: | + GOOSE_VERSION="${{ env.GOOSE_VERSION }}" + wget -q "https://github.com/pressly/goose/releases/download/v${GOOSE_VERSION}/goose_linux_x86_64" -O goose + chmod +x goose + mkdir -p $HOME/.goose + mv goose $HOME/.goose/ + echo "$HOME/.goose" >> $GITHUB_PATH + + - name: Run Goose migrations + env: + GOOSE_DRIVER: ydb + GOOSE_DBSTRING_BASE: ${{ secrets.DB_CONNECTION_STRING }} + run: | + export PATH="$HOME/.goose:$PATH" + cd migrations + + # Add token and query options to connection string + GOOSE_DBSTRING="${GOOSE_DBSTRING_BASE}&token=${YDB_ACCESS_TOKEN}&go_query_mode=scripting&go_fake_tx=scripting&go_query_bind=declare,numeric" + export GOOSE_DBSTRING + + echo "::notice::Running Goose migrations for $WORKSPACE environment" + goose up + echo "::notice::Migration completed successfully for $WORKSPACE environment" + continue-on-error: true + + validate: + name: Terraform Validate + runs-on: ubuntu-latest + needs: setup + environment: ${{ github.ref == 'refs/heads/main' && 'prod' || 'dev' }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set workspace + run: | + WORKSPACE=${{ github.ref == 'refs/heads/main' && 'prod' || 'dev' }} + echo "WORKSPACE=$WORKSPACE" >> $GITHUB_ENV + echo "::notice::Workspace: $WORKSPACE" + + - name: Setup Terraform Environment + uses: ./.github/actions/setup-terraform + with: + terraform_version: ${{ env.TERRAFORM_VERSION }} + service_account_key: ${{ secrets.SERVICE_ACCOUNT_KEY }} + app_config: ${{ secrets.APP_CONFIG }} + app_config_path: ${{ env.APP_CONFIG_PATH }} + workspace: ${{ env.WORKSPACE }} + aws_access_key_id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws_secret_access_key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + cache_key: ${{ needs.setup.outputs.cache_key }} + + - name: Terraform Format Check + run: terraform fmt -check -recursive + continue-on-error: false + + - name: Terraform Validate + run: terraform validate + + plan: + name: Terraform Plan + runs-on: ubuntu-latest + needs: [setup, lint, validate] + environment: ${{ github.ref == 'refs/heads/main' && 'prod' || 'dev' }} + outputs: + plan-exists: ${{ steps.plan.outputs.exitcode }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set workspace + run: | + WORKSPACE=${{ github.ref == 'refs/heads/main' && 'prod' || 'dev' }} + echo "WORKSPACE=$WORKSPACE" >> $GITHUB_ENV + echo "::notice::Workspace: $WORKSPACE" + + - name: Setup Terraform Environment + uses: ./.github/actions/setup-terraform + with: + terraform_version: ${{ env.TERRAFORM_VERSION }} + service_account_key: ${{ secrets.SERVICE_ACCOUNT_KEY }} + app_config: ${{ secrets.APP_CONFIG }} + app_config_path: ${{ env.APP_CONFIG_PATH }} + workspace: ${{ env.WORKSPACE }} + aws_access_key_id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws_secret_access_key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + cache_key: ${{ needs.setup.outputs.cache_key }} + + - name: Terraform Plan + id: plan + env: + TF_VAR_cloud_id: ${{ secrets.CLOUD_ID }} + TF_VAR_folder_id: ${{ secrets.FOLDER_ID }} + run: | + terraform plan -out=tfplan -detailed-exitcode || EXIT_CODE=$? + echo "exitcode=$EXIT_CODE" >> $GITHUB_OUTPUT + + # Print the plan output + terraform show tfplan + + if [ $EXIT_CODE -eq 2 ]; then + echo "::notice::Changes detected in Terraform plan" + elif [ $EXIT_CODE -eq 0 ]; then + echo "::notice::No changes detected in Terraform plan" + else + echo "::error::Terraform plan failed" + exit 1 + fi + + apply: + name: Terraform Apply + runs-on: ubuntu-latest + needs: [setup, plan, migrate] + if: always() && needs.plan.result == 'success' && (needs.migrate.result == 'success' || needs.migrate.result == 'skipped') + environment: ${{ github.ref == 'refs/heads/main' && 'prod' || 'dev' }} + outputs: + dispatcher_function_id: ${{ steps.outputs.outputs.dispatcher_function_id }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set workspace + run: | + WORKSPACE=${{ github.ref == 'refs/heads/main' && 'prod' || 'dev' }} + echo "WORKSPACE=$WORKSPACE" >> $GITHUB_ENV + echo "::notice::Workspace: $WORKSPACE" + + - name: Setup Terraform Environment + uses: ./.github/actions/setup-terraform + with: + terraform_version: ${{ env.TERRAFORM_VERSION }} + terraform_wrapper: 'false' + service_account_key: ${{ secrets.SERVICE_ACCOUNT_KEY }} + app_config: ${{ secrets.APP_CONFIG }} + app_config_path: ${{ env.APP_CONFIG_PATH }} + workspace: ${{ env.WORKSPACE }} + aws_access_key_id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws_secret_access_key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + cache_key: ${{ needs.setup.outputs.cache_key }} + + - name: Backup current state + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + run: | + TIMESTAMP=$(date +%Y%m%d-%H%M%S) + mkdir -p state-backups + terraform state pull > state-backups/terraform-$WORKSPACE-${TIMESTAMP}-${{ github.run_id }}.tfstate + + - name: Upload state backup + uses: actions/upload-artifact@v4 + with: + name: terraform-state-backup-${{ env.WORKSPACE }}-${{ github.run_id }} + path: state-backups/ + retention-days: 90 + + - name: Terraform Apply + env: + TF_VAR_cloud_id: ${{ secrets.CLOUD_ID }} + TF_VAR_folder_id: ${{ secrets.FOLDER_ID }} + run: terraform apply -auto-approve + continue-on-error: false + + - name: Export Terraform Outputs + id: outputs + run: | + DISPATCHER_FUNCTION_ID=$(terraform output -raw dispatcher_function_id) + echo "dispatcher_function_id=$DISPATCHER_FUNCTION_ID" >> $GITHUB_OUTPUT + echo "::notice::Dispatcher Function ID: $DISPATCHER_FUNCTION_ID" + + # hook: + # name: Setup Telegram Webhooks + # runs-on: ubuntu-latest + # needs: [apply] + # if: needs.apply.result == 'success' + # environment: ${{ github.ref == 'refs/heads/main' && 'prod' || 'dev' }} + # steps: + # - name: Checkout code + # uses: actions/checkout@v4 + + # - name: Set workspace + # run: | + # WORKSPACE=${{ github.ref == 'refs/heads/main' && 'prod' || 'dev' }} + # echo "WORKSPACE=$WORKSPACE" >> $GITHUB_ENV + # echo "::notice::Workspace: $WORKSPACE" + + # - name: Load application config + # uses: ./.github/actions/load-config + # with: + # app_config: ${{ secrets.APP_CONFIG }} + # app_config_path: ${{ env.APP_CONFIG_PATH }} + + # - name: Get Dispatcher Function ID + # run: | + # DISPATCHER_FUNCTION_ID="${{ needs.apply.outputs.dispatcher_function_id }}" + # echo "DISPATCHER_FUNCTION_ID=$DISPATCHER_FUNCTION_ID" >> $GITHUB_ENV + # echo "::notice::Dispatcher Function ID: $DISPATCHER_FUNCTION_ID" + + # - name: Execute webhook setup script + # run: | + # echo "::notice::Config file: ${{ env.APP_CONFIG_PATH }}" + # echo "::notice::Dispatcher function ID: $DISPATCHER_FUNCTION_ID" + + # chmod +x _scripts/hook.sh + # bash _scripts/hook.sh + # continue-on-error: false + + # - name: Verify webhook setup + # run: | + # echo "::notice::Webhook setup completed successfully for $WORKSPACE environment" + + summary: + name: Deployment Summary + runs-on: ubuntu-latest + needs: [setup, validate, lint, migrate, plan, apply, hook] + if: always() + steps: + - name: Set workspace + run: | + WORKSPACE=${{ github.ref == 'refs/heads/main' && 'prod' || 'dev' }} + echo "WORKSPACE=$WORKSPACE" >> $GITHUB_ENV + + - name: Generate summary + run: | + echo "## Deployment Summary - $WORKSPACE environment" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Job | Status |" >> $GITHUB_STEP_SUMMARY + echo "|-----|--------|" >> $GITHUB_STEP_SUMMARY + echo "| Validate | ${{ needs.validate.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| Code Linting | ${{ needs.lint.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| Database Migration | ${{ needs.migrate.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| Terraform Plan | ${{ needs.plan.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| Terraform Apply | ${{ needs.apply.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| Webhook Setup | ${{ needs.hook.result }} |" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Branch:** ${{ github.ref_name }}" >> $GITHUB_STEP_SUMMARY + echo "**Workspace:** $WORKSPACE" >> $GITHUB_STEP_SUMMARY + echo "**Commit:** ${{ github.sha }}" >> $GITHUB_STEP_SUMMARY + echo "**Cache Key:** ${{ needs.setup.outputs.cache_key }}" >> $GITHUB_STEP_SUMMARY + + if [ "${{ needs.apply.outputs.dispatcher_function_id }}" != "" ]; then + echo "**Dispatcher Function ID:** ${{ needs.apply.outputs.dispatcher_function_id }}" >> $GITHUB_STEP_SUMMARY + fi + + if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then + echo "**Manual Trigger:** Yes" >> $GITHUB_STEP_SUMMARY + fi diff --git a/.gitignore b/.gitignore index 100bebf..4550f78 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,8 @@ _config *.tfstate.* *.tfvars *.zip + +# secret +iam.json +key.json +config.json diff --git a/README.md b/README.md index 73c576b..f5c4385 100644 --- a/README.md +++ b/README.md @@ -47,8 +47,8 @@ Below is an example of an `APP_CONFIG` value containing all bot information: - To find your owner ID, use [ID bot](https://t.me/myidbot) - Bot ID is the first number before `:` in the bot token - - TOKEN: `123456789:ABC-DEF1234ghIkl-zyx57W2v1u123ew11` - - Bot ID: `123456789` + - TOKEN: `123456789:ABC-DEF1234ghIkl-zyx57W2v1u123ew11` + - Bot ID: `123456789` --- @@ -92,21 +92,25 @@ Below is an example of an `APP_CONFIG` value containing all bot information: ### [1] Add a City You do: + 1. Get prayer times for a city in CSV format 2. Make a pull request (or open an issue) with the new file I do: + 1. Create a new Telegram bot 2. Upload the city file to storage bucket ### [2] Add a Language You do: + 1. Create translation text for the following files: - [./serverless/reminder/internal/handler/languages/text.yaml](./serverless/reminder/internal/handler/languages/text.yaml) - [./serverless/dispatcher/internal/handler/languages/en.yaml](./serverless/dispatcher/internal/handler/languages/en.yaml) (replace `en` with the new language code) I do: + 1. Deploy a new version of the code ### [3] Code Contributions @@ -118,16 +122,19 @@ Found a bug? Want to add a new feature? Just open an issue or submit a pull requ ## Development Roadmap 🚀 ### V1 ✅ + - [x] Support date format for `/prayersdate` command with leading zeros and delimiters (. / -) - [x] Implement subscriptions & notifications - [x] Update text messages to be more user-friendly ### V2 ✅ + - [x] Store prayer times in memory to reduce database requests - [x] Add response endpoint for admin to address feedback & bug messages - [x] Add Jumu'ah prayer reminders on Fridays ### V3 ✅ + - [x] Add time keyboard to `/date` command - [x] Remove selection message for `/date` & `/lang` after user interaction or timeout - [x] Terminate other active channels when user sends new commands @@ -137,6 +144,7 @@ Found a bug? Want to add a new feature? Just open an issue or submit a pull requ - [x] Write more robust tests for core features ### V4 ✅ + - [x] Add multi-language support (AR, RU, TT, TR, UZ) - [x] Implement script messages in the bot - [x] Set user script before command if not set @@ -144,11 +152,13 @@ Found a bug? Want to add a new feature? Just open an issue or submit a pull requ - [x] Fix prayer timetables for other languages ### V5 ✅ + - [x] Refactor code for better readability and maintainability - [x] Enhance logging to be more informative - [x] Enable using multiple bots with the same codebase ### V6 ✅ + - [x] Migrate to serverless architecture - [x] Automate deployment using Terraform - [x] Add support for multiple cities @@ -156,29 +166,26 @@ Found a bug? Want to add a new feature? Just open an issue or submit a pull requ - [x] Add `/stats` command for bot usage statistics ### V7 ✅ + - [x] Add jamaat gathering feature for group chats ### V8 🔄 + - [ ] Add support for all major world cities --- -## Development Setup 🖥️ - -Deploy bot on Yandex Cloud: +## Visualization 🖥️ ```bash -export ENV=dev # or prod -./_scripts/init.sh $ENV prayer-bot -terraform workspace select -or-create=true $ENV -terraform apply # -auto-approve -./_scripts/hook.sh +terraform plan -out plan.out +terraform show -json plan.out > plan.json +docker run --rm -it -p 9000:9000 -v $(pwd)/plan.json:/src/plan.json im2nguyen/rover:latest -planJSONPath=plan.json ``` -Generate Terraform dependencies visualization: +## Useful ```bash -terraform plan -out plan.out -terraform show -json plan.out > plan.json -docker run --rm -it -p 9000:9000 -v $(pwd)/plan.json:/src/plan.json im2nguyen/rover:latest -planJSONPath=plan.json +export AWS_ACCESS_KEY_ID=$(jq -r '.aws_access_key_id' key.json) +export AWS_SECRET_ACCESS_KEY=$(jq -r '.aws_secret_access_key' key.json) ``` diff --git a/_scripts/hook.sh b/_scripts/hook.sh index 63decdf..868b0e4 100644 --- a/_scripts/hook.sh +++ b/_scripts/hook.sh @@ -1,11 +1,21 @@ #!/bin/bash -DISPATCHER_ENDPOINT="https://functions.yandexcloud.net/$(terraform output -raw dispatcher_fn_id)" -export DISPATCHER_ENDPOINT +if [[ -z "$APP_CONFIG_PATH" ]]; then + echo "[ERROR] APP_CONFIG_PATH is not set" + exit 1 +fi -CONFIG_FILE="_config/$(terraform workspace show)/config.json" +if [[ -z "$DISPATCHER_FUNCTION_ID" ]]; then + echo "[ERROR] DISPATCHER_FUNCTION_ID is not set" + exit 1 +fi + +export DISPATCHER_ENDPOINT="https://functions.yandexcloud.net/${DISPATCHER_FUNCTION_ID}" + +CONFIG_FILE="$APP_CONFIG_PATH" echo "[INFO] using config file: \"$CONFIG_FILE\"" +echo "[INFO] using dispatcher function ID: \"$DISPATCHER_FUNCTION_ID\"" jq -c 'to_entries[]' "$CONFIG_FILE" | while read -r entry; do BOT_ID=$(echo "$entry" | jq -r '.value.bot_id') diff --git a/_scripts/init.sh b/_scripts/init.sh deleted file mode 100755 index d7fe655..0000000 --- a/_scripts/init.sh +++ /dev/null @@ -1,58 +0,0 @@ -#!/bin/bash - -# This script fetches the `access token`, `cloudID`, and `folderID` from Yandex Cloud -# and stores them in a `terraform.tfvars` file. - -set -e - -# check input arguments -if [ "$#" -ne 2 ]; then - echo "usage: $0 " - exit 1 -fi - -CLOUD_NAME="$1" -FOLDER_NAME="$2" -TFVARS_FILE="terraform.tfvars" - - -# get access token -echo "fetching access token" -ACCESS_TOKEN=$(yc iam create-token) -if [ -z "$ACCESS_TOKEN" ]; then - echo "error: failed to get access token" - exit 1 -fi - -# get cloud_id by cloud name -echo "fetching cloud id for cloud name '$CLOUD_NAME'" -CLOUD_ID=$(curl -s -H "Authorization: Bearer ${ACCESS_TOKEN}" \ - https://resource-manager.api.cloud.yandex.net/resource-manager/v1/clouds \ - | jq -r --arg CLOUD_NAME "$CLOUD_NAME" '.clouds[] | select(.name == $CLOUD_NAME) | .id') - -if [ -z "$CLOUD_ID" ]; then - echo "error: could not find cloud with name '$CLOUD_NAME'" - exit 1 -fi - -# get folder_id by folder name -echo "fetching folder id for folder name '$FOLDER_NAME' in cloud '$CLOUD_NAME'" -FOLDER_ID=$(curl -s -H "Authorization: Bearer ${ACCESS_TOKEN}" \ - -G https://resource-manager.api.cloud.yandex.net/resource-manager/v1/folders \ - --data-urlencode "cloudId=${CLOUD_ID}" \ - | jq -r --arg FOLDER_NAME "$FOLDER_NAME" '.folders[] | select(.name == $FOLDER_NAME) | .id') - -if [ -z "$FOLDER_ID" ]; then - echo "error: could not find folder with name '$FOLDER_NAME' in cloud '$CLOUD_NAME'" - exit 1 -fi - -# replace values in terraform.tfvars -echo "updating $TFVARS_FILE" - -echo "access_token = \"$ACCESS_TOKEN\"" > terraform.tfvars -echo "cloud_id = \"$CLOUD_ID\"" >> terraform.tfvars -echo "folder_id = \"$FOLDER_ID\"" >> terraform.tfvars - -echo "terraform.tfvars updated successfully" - diff --git a/_scripts/migrate.sh b/_scripts/migrate.sh deleted file mode 100644 index f51f69f..0000000 --- a/_scripts/migrate.sh +++ /dev/null @@ -1,47 +0,0 @@ -ENV="dev" -ENDPOINT="grpcs://ydb.serverless.yandexcloud.net:2135" - -DB_PATH=" -{ - \"dev\": { - \"source\": \"/REGION/XXX/YYY\", - \"target\": \"/REGION/XXX/YYY\" - }, - \"prod\": { - \"source\": \"/REGION/XXX/YYY\", - \"target\": \"/REGION/XXX/YYY\" - } -} -" - -echo "[INFO] Using environment: $ENV" - -DB_PATH_SOURCE=$(echo -n "$DB_PATH" | jq -r ".\"$ENV\".source") -DB_PATH_TARGET=$(echo -n "$DB_PATH" | jq -r ".\"$ENV\".target") - -if [ -z "$DB_PATH_SOURCE" ] || [ -z "$DB_PATH_TARGET" ]; then - echo "[ERROR] Invalid environment: $ENV" - exit 1 -fi - -echo "[INFO] Source database path: \"$DB_PATH_SOURCE\"" -echo "[INFO] Target database path: \"$DB_PATH_TARGET\"" - -ydb config profile replace source-db -d "$DB_PATH_SOURCE" -e "$ENDPOINT" --token-file "./source.txt" -echo "[INFO] Source database profile created" - -ydb config profile replace target-db -d "$DB_PATH_TARGET" -e "$ENDPOINT" --token-file "./target.txt" -echo "[INFO] Target database profile created" - -DIR="$(eval echo ~$USER)/ydb_dump/$ENV/chats" -echo "[INFO] Using directory: $DIR" - -echo "[INFO] Recreating directory: $DIR" -rm -rf "$DIR" -mkdir -p "$DIR" - -echo "[INFO] Dumping source database..." -ydb --profile source-db tools dump --path chats --output "$DIR" - -echo "[INFO] Restoring to target database..." -ydb --profile target-db tools restore --path "$DB_PATH_TARGET" --input "$DIR" diff --git a/_scripts/migrate/README.md b/_scripts/migrate/README.md new file mode 100644 index 0000000..3535b6b --- /dev/null +++ b/_scripts/migrate/README.md @@ -0,0 +1,85 @@ +# Chat Migration Script + +This script migrates chat data from the old YDB table schema to the new one. + +## Prerequisites + +- Go 1.23 or higher +- Access to both old and new YDB databases + +## Environment Variables + +Set the following environment variables before running the script: + +- `OLD_DB_CONNECTION_STRING`: Connection string for the old YDB database +- `NEW_DB_CONNECTION_STRING`: Connection string for the new YDB database +- `YDB_TOKEN`: Access token for authentication to both databases + +## Usage + +```bash +# Set environment variables +export OLD_DB_CONNECTION_STRING="grpcs://ydb.serverless.yandexcloud.net:2135/?database=/ru-central1/..." +export NEW_DB_CONNECTION_STRING="grpcs://ydb.serverless.yandexcloud.net:2135/?database=/ru-central1/..." +export YDB_TOKEN="your-access-token" + +# Run the migration +cd _scripts/migrate +go run main.go +``` + +## What the script does + +1. Connects to both old and new YDB databases +2. Fetches all chats from the old `chats` table +3. Transforms the data: + - Most fields remain the same (chat_id, bot_id, language_code, state, subscribed, subscribed_at, created_at) + - Creates new `reminder` JSON object with: + - `tomorrow`: LastAt set to current time (Moscow timezone) + - `soon`: Offset from old `reminder_offset`, MessageID from old `reminder_message_id` + - `arrive`: MessageID from old `jamaat_message_id` + - `jamaat`: Enabled if both `subscribed` and `jamaat` were true, with default delay configs +4. Uses UPSERT to insert/update records in the new database + +## Idempotency + +The script uses UPSERT statements, making it safe to run multiple times. Subsequent runs will update existing records rather than failing or creating duplicates. + +## Transformation Details + +### Reminder Field + +The reminder JSON structure: + +```json +{ + "tomorrow": { + "offset": 0, + "message_id": 0, + "last_at": "2025-10-26T12:00:00+03:00" + }, + "soon": { + "offset": 600000000000, + "message_id": 12345, + "last_at": "2025-10-26T12:00:00+03:00" + }, + "arrive": { + "offset": 0, + "message_id": 67890, + "last_at": "2025-10-26T12:00:00+03:00" + }, + "jamaat": { + "enabled": true, + "delay": { + "fajr": 600000000000, + "shuruq": 600000000000, + "dhuhr": 600000000000, + "asr": 600000000000, + "maghrib": 600000000000, + "isha": 1200000000000 + } + } +} +``` + +Note: All durations are in nanoseconds (Go's default). For example, 10 minutes = 600000000000 nanoseconds. diff --git a/_scripts/migrate/go.mod b/_scripts/migrate/go.mod new file mode 100644 index 0000000..2d51b58 --- /dev/null +++ b/_scripts/migrate/go.mod @@ -0,0 +1,20 @@ +module migrate + +go 1.23 + +require github.com/ydb-platform/ydb-go-sdk/v3 v3.86.1 + +require ( + github.com/golang-jwt/jwt/v4 v4.4.1 // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/jonboulle/clockwork v0.3.0 // indirect + github.com/ydb-platform/ydb-go-genproto v0.0.0-20241022174402-dd276c7f197b // indirect + golang.org/x/net v0.23.0 // indirect + golang.org/x/sync v0.6.0 // indirect + golang.org/x/sys v0.18.0 // indirect + golang.org/x/text v0.14.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240123012728-ef4313101c80 // indirect + google.golang.org/grpc v1.62.1 // indirect + google.golang.org/protobuf v1.33.0 // indirect +) diff --git a/_scripts/migrate/go.sum b/_scripts/migrate/go.sum new file mode 100644 index 0000000..8ca2f5e --- /dev/null +++ b/_scripts/migrate/go.sum @@ -0,0 +1,157 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI= +github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= +github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go.mod h1:KJwIaB5Mv44NWtYuAOFCVOjcI94vtpEz2JU/D2v6IjE= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/golang-jwt/jwt/v4 v4.4.1 h1:pC5DB52sCeK48Wlb9oPcdhnjkz1TKt1D/P7WKJ0kUcQ= +github.com/golang-jwt/jwt/v4 v4.4.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= +github.com/jonboulle/clockwork v0.3.0 h1:9BSCMi8C+0qdApAp4auwX0RkLGUjs956h0EkuQymUhg= +github.com/jonboulle/clockwork v0.3.0/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/rekby/fixenv v0.6.1 h1:jUFiSPpajT4WY2cYuc++7Y1zWrnCxnovGCIX72PZniM= +github.com/rekby/fixenv v0.6.1/go.mod h1:/b5LRc06BYJtslRtHKxsPWFT/ySpHV+rWvzTg+XWk4c= +github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/ydb-platform/ydb-go-genproto v0.0.0-20241022174402-dd276c7f197b h1:8yiv/W+1xTdifJh1Stkck0gFJjys9kg0/r86Buljuss= +github.com/ydb-platform/ydb-go-genproto v0.0.0-20241022174402-dd276c7f197b/go.mod h1:Er+FePu1dNUieD+XTMDduGpQuCPssK5Q4BjF+IIXJ3I= +github.com/ydb-platform/ydb-go-sdk/v3 v3.86.1 h1:NsDWGBn5albfKM+Dp9iysKRQLWw6k69VezsX+Uw5ok8= +github.com/ydb-platform/ydb-go-sdk/v3 v3.86.1/go.mod h1:ah+n4KaEcANFM+CBKnJ5VrmR0IvWfYFvkofW/56076c= +go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= +go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU= +go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= +golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240123012728-ef4313101c80 h1:AjyfHzEPEFp/NpvfN5g+KDla3EMojjhRVZc1i7cj+oM= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240123012728-ef4313101c80/go.mod h1:PAREbraiVEVGVdTZsVWjSbbTtSyGbAgIIvni8a8CD5s= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= +google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= +google.golang.org/grpc v1.47.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= +google.golang.org/grpc v1.62.1 h1:B4n+nfKzOICUXMgyrNd19h/I9oH0L1pizfk1d4zSgTk= +google.golang.org/grpc v1.62.1/go.mod h1:IWTG0VlJLCh1SkC58F7np9ka9mx/WNkjl4PGJaiq+QE= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0 h1:hjy8E9ON/egN1tAYqKb61G10WtihqetD4sz2H+8nIeA= +gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/_scripts/migrate/main.go b/_scripts/migrate/main.go new file mode 100644 index 0000000..a6a0914 --- /dev/null +++ b/_scripts/migrate/main.go @@ -0,0 +1,352 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "log" + "os" + "time" + + "github.com/ydb-platform/ydb-go-sdk/v3" + "github.com/ydb-platform/ydb-go-sdk/v3/table" + "github.com/ydb-platform/ydb-go-sdk/v3/table/result/named" + "github.com/ydb-platform/ydb-go-sdk/v3/table/types" +) + +// export OLD_DB_CONNECTION_STRING="your-old-db-connection-string" +// export NEW_DB_CONNECTION_STRING="your-new-db-connection-string" +// export YDB_TOKEN="your-access-token" + +// cd _scripts/migrate +// go run main.go + +type Duration time.Duration + +func (d Duration) MarshalJSON() ([]byte, error) { + return json.Marshal(time.Duration(d).String()) +} + +func (d *Duration) UnmarshalJSON(b []byte) error { + var s string + if err := json.Unmarshal(b, &s); err == nil { + dur, err := time.ParseDuration(s) + if err != nil { + return fmt.Errorf("invalid duration string %q: %w", s, err) + } + *d = Duration(dur) + return nil + } + return fmt.Errorf("unexpected duration format: %s", string(b)) +} + +// Domain types matching the new schema +type JamaatDelayConfig struct { + Fajr Duration `json:"fajr"` + Dhuhr Duration `json:"dhuhr"` + Asr Duration `json:"asr"` + Maghrib Duration `json:"maghrib"` + Isha Duration `json:"isha"` +} + +type JamaatConfig struct { + Enabled bool `json:"enabled"` + Delay *JamaatDelayConfig `json:"delay"` +} + +type ReminderConfig struct { + Offset Duration `json:"offset"` + MessageID int `json:"message_id"` + LastAt string `json:"last_at"` // RFC3339 format +} + +type Reminder struct { + Tomorrow *ReminderConfig `json:"tomorrow"` + Soon *ReminderConfig `json:"soon"` + Arrive *ReminderConfig `json:"arrive"` + Jamaat *JamaatConfig `json:"jamaat"` +} + +// Old table row structure +type OldChat struct { + ChatID int64 + BotID int64 + LanguageCode *string + State *string + ReminderOffset *int32 + ReminderMessageID *int32 + Jamaat *bool + JamaatMessageID *int32 + Subscribed *bool + SubscribedAt *time.Time + CreatedAt *time.Time +} + +// New table row structure +type NewChat struct { + ChatID int64 + BotID int64 + LanguageCode *string + State *string + Reminder string // JSON string + Subscribed *bool + SubscribedAt *time.Time + CreatedAt *time.Time +} + +func main() { + ctx := context.Background() + + // Get environment variables + oldDBConnectionString := os.Getenv("OLD_DB_CONNECTION_STRING") + newDBConnectionString := os.Getenv("NEW_DB_CONNECTION_STRING") + ydbToken := os.Getenv("YDB_TOKEN") + + if oldDBConnectionString == "" || newDBConnectionString == "" || ydbToken == "" { + log.Fatal("Missing required environment variables: OLD_DB_CONNECTION_STRING, NEW_DB_CONNECTION_STRING, YDB_TOKEN") + } + + // Connect to old database + log.Println("Connecting to old database...") + oldDB, err := ydb.Open(ctx, oldDBConnectionString, + ydb.WithAccessTokenCredentials(ydbToken), + ) + if err != nil { + log.Fatalf("Failed to connect to old database: %v", err) + } + defer func() { _ = oldDB.Close(ctx) }() + + // Connect to new database + log.Println("Connecting to new database...") + newDB, err := ydb.Open(ctx, newDBConnectionString, + ydb.WithAccessTokenCredentials(ydbToken), + ) + if err != nil { + log.Fatalf("Failed to connect to new database: %v", err) + } + defer func() { _ = newDB.Close(ctx) }() + + // Fetch all chats from old database + log.Println("Fetching chats from old database...") + oldChats, err := fetchOldChats(ctx, oldDB) + if err != nil { + log.Fatalf("Failed to fetch old chats: %v", err) + } + log.Printf("Fetched %d chats from old database", len(oldChats)) + + // Transform and migrate chats + log.Println("Migrating chats to new database...") + moscowLocation, err := time.LoadLocation("Europe/Moscow") + if err != nil { + log.Fatalf("Failed to load Moscow timezone: %v", err) + } + + newChats := make([]NewChat, 0, len(oldChats)) + for _, oldChat := range oldChats { + newChat, err := transformChat(oldChat, moscowLocation) + if err != nil { + log.Printf("Failed to transform chat (bot_id=%d, chat_id=%d): %v", oldChat.BotID, oldChat.ChatID, err) + continue + } + newChats = append(newChats, newChat) + } + + // Insert chats into new database + if err := upsertNewChats(ctx, newDB, newChats); err != nil { + log.Fatalf("Failed to upsert new chats: %v", err) + } + + log.Printf("Successfully migrated %d chats", len(newChats)) +} + +func fetchOldChats(ctx context.Context, db *ydb.Driver) ([]OldChat, error) { + var chats []OldChat + + err := db.Table().Do(ctx, func(ctx context.Context, s table.Session) error { + _, res, err := s.Execute(ctx, table.DefaultTxControl(), + `SELECT chat_id, bot_id, language_code, state, reminder_offset, + reminder_message_id, jamaat, jamaat_message_id, subscribed, + subscribed_at, created_at + FROM chats`, + nil, + ) + if err != nil { + return err + } + defer func() { _ = res.Close() }() + + for res.NextResultSet(ctx) { + for res.NextRow() { + var chat OldChat + err := res.ScanNamed( + named.Required("chat_id", &chat.ChatID), + named.Required("bot_id", &chat.BotID), + named.Optional("language_code", &chat.LanguageCode), + named.Optional("state", &chat.State), + named.Optional("reminder_offset", &chat.ReminderOffset), + named.Optional("reminder_message_id", &chat.ReminderMessageID), + named.Optional("jamaat", &chat.Jamaat), + named.Optional("jamaat_message_id", &chat.JamaatMessageID), + named.Optional("subscribed", &chat.Subscribed), + named.Optional("subscribed_at", &chat.SubscribedAt), + named.Optional("created_at", &chat.CreatedAt), + ) + if err != nil { + return err + } + chats = append(chats, chat) + } + } + + return res.Err() + }) + + return chats, err +} + +func transformChat(oldChat OldChat, moscowLocation *time.Location) (NewChat, error) { + now := time.Now().In(moscowLocation) + + // Build reminder object + reminderOffset := int32(20) + if oldChat.ReminderOffset != nil && *oldChat.ReminderOffset != 0 { + reminderOffset = *oldChat.ReminderOffset + } + + reminderMessageID := 0 + if oldChat.ReminderMessageID != nil { + reminderMessageID = int(*oldChat.ReminderMessageID) + } + + jamaatMessageID := 0 + if oldChat.JamaatMessageID != nil { + jamaatMessageID = int(*oldChat.JamaatMessageID) + } + + jamaat := false + if oldChat.Jamaat != nil { + jamaat = *oldChat.Jamaat + } + + subscribed := false + if oldChat.Subscribed != nil { + subscribed = *oldChat.Subscribed + } + + reminder := Reminder{ + Tomorrow: &ReminderConfig{ + Offset: Duration(3 * time.Hour), + LastAt: now.Format(time.RFC3339), + }, + Soon: &ReminderConfig{ + Offset: Duration(reminderOffset) * Duration(time.Minute), + MessageID: reminderMessageID, + LastAt: now.Format(time.RFC3339), + }, + Arrive: &ReminderConfig{ + MessageID: jamaatMessageID, + LastAt: now.Format(time.RFC3339), + }, + Jamaat: &JamaatConfig{ + Enabled: subscribed && jamaat, + Delay: &JamaatDelayConfig{ + Fajr: Duration(10 * time.Minute), + Dhuhr: Duration(10 * time.Minute), + Asr: Duration(10 * time.Minute), + Maghrib: Duration(10 * time.Minute), + Isha: Duration(20 * time.Minute), + }, + }, + } + + // Marshal reminder to JSON + reminderJSON, err := json.Marshal(reminder) + if err != nil { + return NewChat{}, fmt.Errorf("failed to marshal reminder: %w", err) + } + + return NewChat{ + ChatID: oldChat.ChatID, + BotID: oldChat.BotID, + LanguageCode: oldChat.LanguageCode, + State: oldChat.State, + Reminder: string(reminderJSON), + Subscribed: oldChat.Subscribed, + SubscribedAt: oldChat.SubscribedAt, + CreatedAt: oldChat.CreatedAt, + }, nil +} + +func upsertNewChats(ctx context.Context, db *ydb.Driver, chats []NewChat) error { + return db.Table().Do(ctx, func(ctx context.Context, s table.Session) error { + for _, chat := range chats { + err := upsertChat(ctx, s, chat) + if err != nil { + log.Printf("Failed to upsert chat (bot_id=%d, chat_id=%d): %v", chat.BotID, chat.ChatID, err) + return err + } + } + return nil + }) +} + +func upsertChat(ctx context.Context, s table.Session, chat NewChat) error { + query := ` + DECLARE $chat_id AS Int64; + DECLARE $bot_id AS Int64; + DECLARE $language_code AS Utf8?; + DECLARE $state AS Utf8?; + DECLARE $reminder AS Json; + DECLARE $subscribed AS Bool?; + DECLARE $subscribed_at AS Datetime?; + DECLARE $created_at AS Datetime?; + + UPSERT INTO chats (chat_id, bot_id, language_code, state, reminder, subscribed, subscribed_at, created_at) + VALUES ($chat_id, $bot_id, $language_code, $state, $reminder, $subscribed, $subscribed_at, $created_at); + ` + + params := table.NewQueryParameters( + table.ValueParam("$chat_id", types.Int64Value(chat.ChatID)), + table.ValueParam("$bot_id", types.Int64Value(chat.BotID)), + table.ValueParam("$reminder", types.JSONValue(chat.Reminder)), + ) + + // Handle optional language_code + if chat.LanguageCode != nil { + params.Add(table.ValueParam("$language_code", types.OptionalValue(types.UTF8Value(*chat.LanguageCode)))) + } else { + params.Add(table.ValueParam("$language_code", types.NullValue(types.TypeUTF8))) + } + + // Handle optional state + if chat.State != nil { + params.Add(table.ValueParam("$state", types.OptionalValue(types.UTF8Value(*chat.State)))) + } else { + params.Add(table.ValueParam("$state", types.NullValue(types.TypeUTF8))) + } + + // Handle optional subscribed + if chat.Subscribed != nil { + params.Add(table.ValueParam("$subscribed", types.OptionalValue(types.BoolValue(*chat.Subscribed)))) + } else { + params.Add(table.ValueParam("$subscribed", types.NullValue(types.TypeBool))) + } + + // Handle optional subscribed_at + if chat.SubscribedAt != nil { + params.Add(table.ValueParam("$subscribed_at", types.OptionalValue(types.DatetimeValueFromTime(*chat.SubscribedAt)))) + } else { + params.Add(table.ValueParam("$subscribed_at", types.NullValue(types.TypeDatetime))) + } + + // Handle optional created_at + if chat.CreatedAt != nil { + params.Add(table.ValueParam("$created_at", types.OptionalValue(types.DatetimeValueFromTime(*chat.CreatedAt)))) + } else { + params.Add(table.ValueParam("$created_at", types.NullValue(types.TypeDatetime))) + } + + _, _, err := s.Execute(ctx, table.DefaultTxControl(), query, params) + + return err +} diff --git a/_scripts/sync.sh b/_scripts/sync.sh deleted file mode 100644 index c24587f..0000000 --- a/_scripts/sync.sh +++ /dev/null @@ -1,26 +0,0 @@ -#!/bin/bash -set -euo pipefail - -TERRAFORM_WORKSPACE=$(terraform workspace show) -REGION=$(terraform output -raw region) - -BUCKET_NAME="prayer-bot-bucket-${TERRAFORM_WORKSPACE}" -S3_ENDPOINT="https://storage.yandexcloud.net" - -LOCAL_DIR="_config/${TERRAFORM_WORKSPACE}" - -mkdir -p "$LOCAL_DIR" - -echo "[INFO] Syncing bucket '${BUCKET_NAME}' into '${LOCAL_DIR}'..." - -export AWS_ACCESS_KEY_ID="${AWS_ACCESS_KEY_ID:-}" -export AWS_SECRET_ACCESS_KEY="${AWS_SECRET_ACCESS_KEY:-}" - -if [[ -z "$AWS_ACCESS_KEY_ID" || -z "$AWS_SECRET_ACCESS_KEY" ]]; then - echo "[ERROR] AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY must be set as environment variables or have default values." - exit 1 -fi - -aws --endpoint-url "$S3_ENDPOINT" --region "$REGION" s3 sync "s3://${BUCKET_NAME}/" "$LOCAL_DIR/" - -echo "[INFO] sync complete!" diff --git a/_scripts/update_serverless_packages.sh b/_scripts/update_serverless_packages.sh new file mode 100755 index 0000000..50b6cd9 --- /dev/null +++ b/_scripts/update_serverless_packages.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Base module path +REPO="github.com/escalopa/prayer-bot" + +# Current commit hash +HASH=$(git rev-parse HEAD) +echo "Using commit hash: $HASH" + +# Ensure serverless directory exists +if [ ! -d "serverless" ]; then + echo "❌ Error: 'serverless' directory not found." + exit 1 +fi + +# Iterate over first-level subdirectories +for dir in serverless/*/; do + [ -d "$dir" ] || continue + echo "➡️ Entering $dir" + + # Run go get inside the subdirectory + (cd "$dir" && go get "$REPO@$HASH") + +done + +echo "✅ All serverless subprojects updated to $HASH" diff --git a/config/config.go b/config/config.go index 55e78dc..f017f13 100644 --- a/config/config.go +++ b/config/config.go @@ -1,6 +1,7 @@ package config import ( + "encoding/base64" "encoding/json" "fmt" "os" @@ -9,9 +10,15 @@ import ( ) func Load() (botConfig map[int64]*domain.BotConfig, _ error) { - data := os.Getenv("APP_CONFIG") + encodedData := os.Getenv("APP_CONFIG") - err := json.Unmarshal([]byte(data), &botConfig) + // Decode base64-encoded config + data, err := base64.StdEncoding.DecodeString(encodedData) + if err != nil { + return nil, fmt.Errorf("decode base64 config: %v", err) + } + + err = json.Unmarshal(data, &botConfig) if err != nil { return nil, fmt.Errorf("unmarshal bot config: %v", err) } diff --git a/domain/chat.go b/domain/chat.go index 614a56e..c78f186 100644 --- a/domain/chat.go +++ b/domain/chat.go @@ -5,53 +5,27 @@ import ( ) var ( + ErrUnmarshalJSON = errors.New("unmarshal json") ErrInvalidArgument = errors.New("invalid argument") ErrNotFound = errors.New("not found") ErrAlreadyExists = errors.New("already exists") ErrInternal = errors.New("internal") ) -type reminderOffset int32 - -const ( - reminderOffset5m reminderOffset = 5 - reminderOffset10m reminderOffset = 10 - reminderOffset15m reminderOffset = 15 - reminderOffset20m reminderOffset = 20 - reminderOffset30m reminderOffset = 30 - reminderOffset45m reminderOffset = 45 - reminderOffset60m reminderOffset = 60 - reminderOffset90m reminderOffset = 90 -) - -func ReminderOffsets() []int32 { - return []int32{ - int32(reminderOffset5m), - int32(reminderOffset10m), - int32(reminderOffset15m), - int32(reminderOffset20m), - int32(reminderOffset30m), - int32(reminderOffset45m), - int32(reminderOffset60m), - int32(reminderOffset90m), - } -} - type ( Stats struct { Users uint64 // users using the bot Subscribed uint64 // subscribed users Unsubscribed uint64 // unsubscribed users - LanguagesGrouped map[string]uint64 // users using a language + LanguagesGrouped map[string]uint64 // users grouped by language } Chat struct { - BotID int64 - ChatID int64 - State string - LanguageCode string - ReminderMessageID int32 - Jamaat bool - JamaatMessageID int32 + BotID int64 + ChatID int64 + State string + LanguageCode string + Subscribed bool + Reminder *Reminder } ) diff --git a/domain/duration.go b/domain/duration.go new file mode 100644 index 0000000..e723d85 --- /dev/null +++ b/domain/duration.go @@ -0,0 +1,34 @@ +package domain + +import ( + "encoding/json" + "fmt" + "time" +) + +type Duration time.Duration + +func (d Duration) String() string { + return d.Duration().String() +} + +func (d Duration) Duration() time.Duration { + return time.Duration(d) +} + +func (d Duration) MarshalJSON() ([]byte, error) { + return json.Marshal(time.Duration(d).String()) +} + +func (d *Duration) UnmarshalJSON(b []byte) error { + var s string + if err := json.Unmarshal(b, &s); err == nil { + dur, err := time.ParseDuration(s) + if err != nil { + return fmt.Errorf("invalid duration string %q: %w", s, err) + } + *d = Duration(dur) + return nil + } + return fmt.Errorf("unexpected duration format: %s", string(b)) +} diff --git a/domain/prayer.go b/domain/prayer.go index 230380d..77cbf5d 100644 --- a/domain/prayer.go +++ b/domain/prayer.go @@ -17,16 +17,56 @@ const ( PrayerIDIsha PrayerID = 6 ) +func (p PrayerID) String() string { + switch p { + case PrayerIDFajr: + return "fajr" + case PrayerIDShuruq: + return "shuruq" + case PrayerIDDhuhr: + return "dhuhr" + case PrayerIDAsr: + return "asr" + case PrayerIDMaghrib: + return "maghrib" + case PrayerIDIsha: + return "isha" + default: + return "unknown" + } +} + +func ParsePrayerID(prayerName string) PrayerID { + switch prayerName { + case "fajr": + return PrayerIDFajr + case "shuruq": + return PrayerIDShuruq + case "dhuhr": + return PrayerIDDhuhr + case "asr": + return PrayerIDAsr + case "maghrib": + return PrayerIDMaghrib + case "isha": + return PrayerIDIsha + default: + return PrayerIDUnknown + } +} + type PrayerDay struct { - Date time.Time `json:"date"` - Fajr time.Time `json:"fajr"` - Shuruq time.Time `json:"shuruq"` - Dhuhr time.Time `json:"dhuhr"` - Asr time.Time `json:"asr"` - Maghrib time.Time `json:"maghrib"` - Isha time.Time `json:"isha"` + Date time.Time `json:"date"` + Fajr time.Time `json:"fajr"` + Shuruq time.Time `json:"shuruq"` + Dhuhr time.Time `json:"dhuhr"` + Asr time.Time `json:"asr"` + Maghrib time.Time `json:"maghrib"` + Isha time.Time `json:"isha"` + NextDay *PrayerDay `json:"next_day"` } +//revive:disable:argument-limit func NewPrayerDay( date time.Time, fajr time.Time, diff --git a/domain/reminder.go b/domain/reminder.go new file mode 100644 index 0000000..2b6ad77 --- /dev/null +++ b/domain/reminder.go @@ -0,0 +1,75 @@ +package domain + +import "time" + +type ReminderType string + +const ( + ReminderTypeTomorrow ReminderType = "tomorrow" + ReminderTypeSoon ReminderType = "soon" + ReminderTypeArrive ReminderType = "arrive" +) + +func (rt ReminderType) String() string { + return string(rt) +} + +type ( + JamaatDelayConfig struct { + Fajr Duration `json:"fajr"` + Dhuhr Duration `json:"dhuhr"` + Asr Duration `json:"asr"` + Maghrib Duration `json:"maghrib"` + Isha Duration `json:"isha"` + } + + JamaatConfig struct { + Enabled bool `json:"enabled"` + Delay *JamaatDelayConfig `json:"delay"` + } + + ReminderConfig struct { + Offset Duration `json:"offset"` + MessageID int `json:"message_id"` + LastAt time.Time `json:"last_at"` + } + + Reminder struct { + Tomorrow *ReminderConfig `json:"tomorrow"` + Soon *ReminderConfig `json:"soon"` + Arrive *ReminderConfig `json:"arrive"` + Jamaat *JamaatConfig `json:"jamaat"` + } +) + +func (j *JamaatDelayConfig) GetDelayByPrayerID(prayerID PrayerID) time.Duration { + switch prayerID { + case PrayerIDFajr: + return time.Duration(j.Fajr) + case PrayerIDDhuhr: + return time.Duration(j.Dhuhr) + case PrayerIDAsr: + return time.Duration(j.Asr) + case PrayerIDMaghrib: + return time.Duration(j.Maghrib) + case PrayerIDIsha: + return time.Duration(j.Isha) + default: + return 0 + } +} + +func (j *JamaatDelayConfig) SetDelayByPrayerID(prayerID PrayerID, delay time.Duration) { + switch prayerID { + case PrayerIDFajr: + j.Fajr = Duration(delay) + case PrayerIDDhuhr: + j.Dhuhr = Duration(delay) + case PrayerIDAsr: + j.Asr = Duration(delay) + case PrayerIDMaghrib: + j.Maghrib = Duration(delay) + case PrayerIDIsha: + j.Isha = Duration(delay) + } +} diff --git a/main.tf b/main.tf index 4476924..0836952 100644 --- a/main.tf +++ b/main.tf @@ -4,12 +4,31 @@ terraform { source = "yandex-cloud/yandex" } } + + backend "s3" { + endpoints = { + s3 = "https://storage.yandexcloud.net" + } + region = "ru-central1" + + bucket = "escalopa-tfstate" + key = "prayer-bot/terraform.tfstate" + + workspace_key_prefix = "" + + skip_region_validation = true + skip_credentials_validation = true + skip_requesting_account_id = true + skip_metadata_api_check = true + skip_s3_checksum = true + } + required_version = ">= 0.13" } -variable "access_token" { - type = string - sensitive = true +locals { + env = terraform.workspace + app_config_base64 = sensitive(base64encode(file("${path.module}/config.json"))) } variable "cloud_id" { @@ -25,15 +44,11 @@ variable "region" { default = "ru-central1-d" } -output "region" { - value = var.region -} - provider "yandex" { - token = var.access_token - cloud_id = var.cloud_id - folder_id = var.folder_id - zone = var.region + service_account_key_file = "iam.json" + cloud_id = var.cloud_id + folder_id = var.folder_id + zone = var.region } ########################### @@ -41,7 +56,7 @@ provider "yandex" { ########################### resource "yandex_storage_bucket" "bucket" { - bucket = "prayer-bot-bucket-${terraform.workspace}" + bucket = "prayer-bot-bucket-${local.env}" folder_id = var.folder_id max_size = 1073741824 # 1 GB in bytes } @@ -51,7 +66,7 @@ resource "yandex_storage_bucket" "bucket" { ########################### resource "yandex_ydb_database_serverless" "ydb" { - name = "prayer-bot-ydb-${terraform.workspace}" + name = "prayer-bot-ydb-${local.env}" location_id = "ru-central1" folder_id = var.folder_id deletion_protection = true @@ -62,110 +77,6 @@ resource "yandex_ydb_database_serverless" "ydb" { } } -resource "yandex_ydb_table" "ydb_table_chats" { - connection_string = yandex_ydb_database_serverless.ydb.ydb_full_endpoint - path = "chats" - - primary_key = ["bot_id", "chat_id"] - - column { - name = "chat_id" - type = "Int64" - } - - column { - name = "bot_id" - type = "Int64" - } - - column { - name = "language_code" - type = "Utf8" - } - - column { - name = "state" - type = "Utf8" - } - - column { - name = "reminder_offset" - type = "Int32" - } - - column { - name = "reminder_message_id" - type = "Int32" - } - - column { - name = "jamaat" - type = "Bool" - } - - column { - name = "jamaat_message_id" - type = "Int32" - } - - column { - name = "subscribed" - type = "Bool" - } - - column { - name = "subscribed_at" - type = "Datetime" - } - - column { - name = "created_at" - type = "Datetime" - } -} - -resource "yandex_ydb_table" "ydb_table_prayers" { - connection_string = yandex_ydb_database_serverless.ydb.ydb_full_endpoint - path = "prayers" - - primary_key = ["bot_id", "prayer_date"] - - column { - name = "bot_id" - type = "Int64" - } - - column { - name = "prayer_date" - type = "Date" - } - - column { - name = "fajr" - type = "Datetime" - } - column { - name = "shuruq" - type = "Datetime" - } - column { - name = "dhuhr" - type = "Datetime" - } - column { - name = "asr" - type = "Datetime" - } - column { - name = "maghrib" - type = "Datetime" - } - column { - name = "isha" - type = "Datetime" - } -} - ########################### ### trigger ########################### @@ -222,10 +133,10 @@ resource "yandex_function" "loader_fn" { execution_timeout = 5 service_account_id = yandex_iam_service_account.loader_sa.id folder_id = var.folder_id - user_hash = "v1" + user_hash = filemd5(data.archive_file.loader_zip.output_path) environment = { - APP_CONFIG = file("${path.module}/_config/${terraform.workspace}/config.json") + APP_CONFIG = local.app_config_base64 S3_ENDPOINT = "https://storage.yandexcloud.net" YDB_ENDPOINT = yandex_ydb_database_serverless.ydb.ydb_full_endpoint @@ -299,10 +210,10 @@ resource "yandex_function" "dispatcher_fn" { execution_timeout = 5 service_account_id = yandex_iam_service_account.dispatcher_sa.id folder_id = var.folder_id - user_hash = "v3" + user_hash = filemd5(data.archive_file.dispatcher_zip.output_path) environment = { - APP_CONFIG = file("${path.module}/_config/${terraform.workspace}/config.json") + APP_CONFIG = local.app_config_base64 YDB_ENDPOINT = yandex_ydb_database_serverless.ydb.ydb_full_endpoint @@ -316,7 +227,7 @@ resource "yandex_function" "dispatcher_fn" { } } -output "dispatcher_fn_id" { +output "dispatcher_function_id" { value = yandex_function.dispatcher_fn.id // used to set Webhook URL on Telegram } @@ -356,10 +267,10 @@ resource "yandex_function" "reminder_fn" { execution_timeout = 5 service_account_id = yandex_iam_service_account.reminder_sa.id folder_id = var.folder_id - user_hash = "v1" + user_hash = filemd5(data.archive_file.reminder_zip.output_path) environment = { - APP_CONFIG = file("${path.module}/_config/${terraform.workspace}/config.json") + APP_CONFIG = local.app_config_base64 YDB_ENDPOINT = yandex_ydb_database_serverless.ydb.ydb_full_endpoint diff --git a/migrations/20251025195706_create_chats_table.sql b/migrations/20251025195706_create_chats_table.sql new file mode 100644 index 0000000..34fd8e1 --- /dev/null +++ b/migrations/20251025195706_create_chats_table.sql @@ -0,0 +1,15 @@ +-- +goose Up +CREATE TABLE chats ( + chat_id Int64 NOT NULL, + bot_id Int64 NOT NULL, + language_code Utf8, + state Utf8, + reminder Json, + subscribed Bool, + subscribed_at Datetime, + created_at Datetime, + PRIMARY KEY (bot_id, chat_id) +); + +-- +goose Down +DROP TABLE IF EXISTS chats; diff --git a/migrations/20251025195728_create_prayers_table.sql b/migrations/20251025195728_create_prayers_table.sql new file mode 100644 index 0000000..88bc5ba --- /dev/null +++ b/migrations/20251025195728_create_prayers_table.sql @@ -0,0 +1,15 @@ +-- +goose Up +CREATE TABLE prayers ( + bot_id Int64 NOT NULL, + prayer_date Date NOT NULL, + fajr Datetime, + shuruq Datetime, + dhuhr Datetime, + asr Datetime, + maghrib Datetime, + isha Datetime, + PRIMARY KEY (bot_id, prayer_date) +); + +-- +goose Down +DROP TABLE IF EXISTS prayers; \ No newline at end of file diff --git a/revive.toml b/revive.toml new file mode 100644 index 0000000..d500e70 --- /dev/null +++ b/revive.toml @@ -0,0 +1,118 @@ +# Revive configuration file for Go linting +# Place this file in the root of your repository + +# When set to false, ignores files with "GENERATED" header, similar to golint +ignoreGeneratedHeader = true + +# Sets the default severity to "warning" +severity = "warning" + +# Sets the default confidence to 0.8 +confidence = 0.8 + +# Sets the error code for failures with severity "error" +errorCode = 1 + +# Sets the error code for failures with severity "warning" +warningCode = 0 + +# Formatting rules +[rule.blank-imports] +[rule.context-as-argument] +[rule.context-keys-type] +[rule.dot-imports] +[rule.error-return] +[rule.error-strings] +[rule.error-naming] +[rule.exported] +disabled = true +[rule.if-return] +[rule.increment-decrement] +[rule.var-naming] +[rule.var-declaration] +[rule.package-comments] +disabled = true +[rule.range] +[rule.receiver-naming] +[rule.time-naming] +[rule.unexported-return] +[rule.indent-error-flow] +[rule.errorf] +[rule.empty-block] +[rule.superfluous-else] +[rule.unused-parameter] +[rule.unreachable-code] +[rule.redefines-builtin-id] + +# Code complexity rules +[rule.function-result-limit] +arguments = [3] + +[rule.argument-limit] +arguments = [5] + +[rule.cyclomatic] +arguments = [15] + +[rule.max-public-structs] +arguments = [10] + +[rule.file-header] +disabled = true + +# Naming conventions +[rule.confusing-naming] +severity = "warning" + +[rule.confusing-results] +severity = "warning" + +[rule.bool-literal-in-expr] +[rule.constant-logical-expr] + +# Comments and documentation +[rule.comment-spacings] +arguments = ["//"] + +# Security and best practices +[rule.defer] +arguments = [["call-chain", "loop"]] + +[rule.string-of-int] + +# Code style +[rule.modifies-parameter] +[rule.modifies-value-receiver] +[rule.early-return] +[rule.unconditional-recursion] +[rule.identical-branches] +[rule.struct-tag] +[rule.range-val-in-closure] +[rule.range-val-address] +[rule.waitgroup-by-value] +[rule.atomic] +[rule.empty-lines] +[rule.cognitive-complexity] +arguments = [20] + +# Performance +[rule.string-format] +[rule.deep-exit] +[rule.unnecessary-stmt] + +# Optional rules (uncomment to enable) +# [rule.line-length-limit] +# arguments = [120] + +# [rule.function-length] +# arguments = [50, 0] + +# [rule.imports-blacklist] +# arguments = ["crypto/md5", "crypto/sha1"] + +# [rule.add-constant] +# arguments = [{maxLitCount = "3", allowStrs = "\"\"", allowInts = "0,1,2", allowFloats = "0.0,1.0"}] + +# Exclude patterns (if needed) +# [rule.exclude] +# arguments = ["^//go:generate"] \ No newline at end of file diff --git a/serverless/dispatcher/go.mod b/serverless/dispatcher/go.mod index 93be88f..a100a98 100644 --- a/serverless/dispatcher/go.mod +++ b/serverless/dispatcher/go.mod @@ -3,7 +3,7 @@ module github.com/escalopa/prayer-bot/dispatcher go 1.21 require ( - github.com/escalopa/prayer-bot v0.0.0-20250924182821-90acd5bcfc24 + github.com/escalopa/prayer-bot v0.0.0-20251102114226-e7cacf22daf1 github.com/go-telegram/bot v1.15.0 github.com/ydb-platform/ydb-go-genproto v0.0.0-20241112172322-ea1f63298f77 github.com/ydb-platform/ydb-go-sdk/v3 v3.108.0 diff --git a/serverless/dispatcher/go.sum b/serverless/dispatcher/go.sum index 6ee1216..abe6175 100644 --- a/serverless/dispatcher/go.sum +++ b/serverless/dispatcher/go.sum @@ -569,8 +569,30 @@ github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7 github.com/envoyproxy/protoc-gen-validate v0.6.7/go.mod h1:dyJXwwfPK2VSqiB9Klm1J6romD608Ba7Hij42vrOBCo= github.com/envoyproxy/protoc-gen-validate v0.9.1/go.mod h1:OKNgG7TCp5pF4d6XftA0++PMirau2/yoOwVac3AbF2w= github.com/envoyproxy/protoc-gen-validate v0.10.0/go.mod h1:DRjgyB0I43LtJapqN6NiRwroiAU2PaFuvk/vjgh61ss= -github.com/escalopa/prayer-bot v0.0.0-20250924182821-90acd5bcfc24 h1:Kl4X9Jikl6TJLx97TWGTF99aJxALsJZBIJ+E2lVvo0E= -github.com/escalopa/prayer-bot v0.0.0-20250924182821-90acd5bcfc24/go.mod h1:HOpIxziLKMTC/a+Q6z40Rn5tJDEZsFyzhniLA4iB2i0= +github.com/escalopa/prayer-bot v0.0.0-20251026155439-6034664856a3 h1:3ze/gQCQs+qIzkU+sB1zd5opatlkHCdeERuWC72/4Ic= +github.com/escalopa/prayer-bot v0.0.0-20251026155439-6034664856a3/go.mod h1:HOpIxziLKMTC/a+Q6z40Rn5tJDEZsFyzhniLA4iB2i0= +github.com/escalopa/prayer-bot v0.0.0-20251026172843-9eead151801a h1:ymjQDDn1JBdL95DkBVkUxwphZxSTPAfP4Pf1ysikfmQ= +github.com/escalopa/prayer-bot v0.0.0-20251026172843-9eead151801a/go.mod h1:HOpIxziLKMTC/a+Q6z40Rn5tJDEZsFyzhniLA4iB2i0= +github.com/escalopa/prayer-bot v0.0.0-20251026192818-d6bccce30ecf h1:OLDTjLdG0mNigMk9sNNh+fJ5LBWkYboNKu2pFX61WSo= +github.com/escalopa/prayer-bot v0.0.0-20251026192818-d6bccce30ecf/go.mod h1:HOpIxziLKMTC/a+Q6z40Rn5tJDEZsFyzhniLA4iB2i0= +github.com/escalopa/prayer-bot v0.0.0-20251026194015-725e9cfbd15d h1:+Dv3qCKHoRcfFQVjEteZWTaLOkyS6B5OlrKr8CmroC4= +github.com/escalopa/prayer-bot v0.0.0-20251026194015-725e9cfbd15d/go.mod h1:HOpIxziLKMTC/a+Q6z40Rn5tJDEZsFyzhniLA4iB2i0= +github.com/escalopa/prayer-bot v0.0.0-20251026200218-6f6adf300279 h1:qc+7zybygYoH1yz948TjHe/uQ6QliqSwEVEd8O9rxug= +github.com/escalopa/prayer-bot v0.0.0-20251026200218-6f6adf300279/go.mod h1:HOpIxziLKMTC/a+Q6z40Rn5tJDEZsFyzhniLA4iB2i0= +github.com/escalopa/prayer-bot v0.0.0-20251026201902-b5254b301c89 h1:+jbrcX4CpaKUZs0gG+1QbpVENyN9NVbdZLQJOO+d/q0= +github.com/escalopa/prayer-bot v0.0.0-20251026201902-b5254b301c89/go.mod h1:HOpIxziLKMTC/a+Q6z40Rn5tJDEZsFyzhniLA4iB2i0= +github.com/escalopa/prayer-bot v0.0.0-20251026202753-f09b49eedd92 h1:CZ80HIK0Duq/Rjo1Q+zzFFRNM3mER2fbxBryYu4SFaE= +github.com/escalopa/prayer-bot v0.0.0-20251026202753-f09b49eedd92/go.mod h1:HOpIxziLKMTC/a+Q6z40Rn5tJDEZsFyzhniLA4iB2i0= +github.com/escalopa/prayer-bot v0.0.0-20251026205320-11cb2dcd014e h1:rnAVK8yqKdvUJu0jwp8RyZwGXNgq1BWLUWTNGgMIPCs= +github.com/escalopa/prayer-bot v0.0.0-20251026205320-11cb2dcd014e/go.mod h1:HOpIxziLKMTC/a+Q6z40Rn5tJDEZsFyzhniLA4iB2i0= +github.com/escalopa/prayer-bot v0.0.0-20251026211435-73f9e46a72f7 h1:DO0ejIbuZJJxoxareSryEpp1HGNHRDzCkNUtKwlvhFg= +github.com/escalopa/prayer-bot v0.0.0-20251026211435-73f9e46a72f7/go.mod h1:HOpIxziLKMTC/a+Q6z40Rn5tJDEZsFyzhniLA4iB2i0= +github.com/escalopa/prayer-bot v0.0.0-20251026213512-5af98b73d4ad h1:bWmc3gk++my6XDOXRQKfER/xmXkl70Yobw2nQQdcroY= +github.com/escalopa/prayer-bot v0.0.0-20251026213512-5af98b73d4ad/go.mod h1:HOpIxziLKMTC/a+Q6z40Rn5tJDEZsFyzhniLA4iB2i0= +github.com/escalopa/prayer-bot v0.0.0-20251102105549-ebc4c681afa2 h1:JqgNa7R7hRsEV2uNslRcbioATTwlVBAJh9vqsdwYR9c= +github.com/escalopa/prayer-bot v0.0.0-20251102105549-ebc4c681afa2/go.mod h1:HOpIxziLKMTC/a+Q6z40Rn5tJDEZsFyzhniLA4iB2i0= +github.com/escalopa/prayer-bot v0.0.0-20251102114226-e7cacf22daf1 h1:s9sqt4CtdExFGlZz1k2SDB2rA2elyMHR7I59MJ9c1lg= +github.com/escalopa/prayer-bot v0.0.0-20251102114226-e7cacf22daf1/go.mod h1:HOpIxziLKMTC/a+Q6z40Rn5tJDEZsFyzhniLA4iB2i0= github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= diff --git a/serverless/dispatcher/internal/handler/command.go b/serverless/dispatcher/internal/handler/command.go index 1ee6920..ed78eaa 100644 --- a/serverless/dispatcher/internal/handler/command.go +++ b/serverless/dispatcher/internal/handler/command.go @@ -21,11 +21,11 @@ const ( prayerText = ` 🗓 %s, %s -🕊 %s — %s -🌤 %s — %s -☀️ %s — %s -🌇 %s — %s -🌅 %s — %s +🕊 %s — %s +🌤 %s — %s +☀️ %s — %s +🌇 %s — %s +🌅 %s — %s 🌙 %s — %s ` ) @@ -40,6 +40,7 @@ const ( startCommand command = "start" helpCommand command = "help" todayCommand command = "today" + tomorrowCommand command = "tomorrow" dateCommand command = "date" // 2 stages nextCommand command = "next" remindCommand command = "remind" // 1 stage @@ -53,6 +54,7 @@ const ( // admin commands adminCommand command = "admin" + infoCommand command = "info" replyCommand command = "reply" // 1 stage statsCommand command = "stats" announceCommand command = "announce" // 1 stage @@ -66,7 +68,7 @@ func (h *Handler) start(ctx context.Context, b *bot.Bot, update *models.Update) return h.help(ctx, b, update) } -func (h *Handler) help(ctx context.Context, b *bot.Bot, update *models.Update) error { +func (h *Handler) help(ctx context.Context, b *bot.Bot, _ *models.Update) error { chat := getContextChat(ctx) _, err := b.SendMessage(ctx, &bot.SendMessageParams{ @@ -84,10 +86,10 @@ func (h *Handler) help(ctx context.Context, b *bot.Bot, update *models.Update) e return nil } -func (h *Handler) today(ctx context.Context, b *bot.Bot, update *models.Update) error { +func (h *Handler) today(ctx context.Context, b *bot.Bot, _ *models.Update) error { chat := getContextChat(ctx) - prayerDay, err := h.db.GetPrayerDay(ctx, chat.BotID, h.nowUTC(chat.BotID)) + prayerDay, err := h.db.GetPrayerDay(ctx, chat.BotID, h.nowDateUTC(chat.BotID)) if err != nil { log.Error("today: get prayer day", log.Err(err), log.BotID(chat.BotID), log.ChatID(chat.ChatID)) return domain.ErrInternal @@ -106,7 +108,30 @@ func (h *Handler) today(ctx context.Context, b *bot.Bot, update *models.Update) return nil } -func (h *Handler) date(ctx context.Context, b *bot.Bot, update *models.Update) error { +func (h *Handler) tomorrow(ctx context.Context, b *bot.Bot, _ *models.Update) error { + chat := getContextChat(ctx) + + tomorrow := h.nowDateUTC(chat.BotID).Add(24 * time.Hour) + prayerDay, err := h.db.GetPrayerDay(ctx, chat.BotID, tomorrow) + if err != nil { + log.Error("tomorrow: get prayer day", log.Err(err), log.BotID(chat.BotID), log.ChatID(chat.ChatID)) + return domain.ErrInternal + } + + _, err = b.SendMessage(ctx, &bot.SendMessageParams{ + ChatID: chat.ChatID, + Text: h.formatPrayerDay(chat.BotID, prayerDay, chat.LanguageCode), + }) + if err != nil { + log.Error("tomorrow: send message", log.Err(err), log.BotID(chat.BotID), log.ChatID(chat.ChatID)) + return domain.ErrInternal + } + + h.resetState(ctx, chat) + return nil +} + +func (h *Handler) date(ctx context.Context, b *bot.Bot, _ *models.Update) error { chat := getContextChat(ctx) _, err := b.SendMessage(ctx, &bot.SendMessageParams{ @@ -123,12 +148,12 @@ func (h *Handler) date(ctx context.Context, b *bot.Bot, update *models.Update) e return nil } -func (h *Handler) next(ctx context.Context, b *bot.Bot, update *models.Update) error { +func (h *Handler) next(ctx context.Context, b *bot.Bot, _ *models.Update) error { chat := getContextChat(ctx) var ( now = h.now(chat.BotID) - date = h.nowUTC(chat.BotID) + date = h.nowDateUTC(chat.BotID) ) prayerDay, err := h.db.GetPrayerDay(ctx, chat.BotID, date) @@ -151,16 +176,19 @@ func (h *Handler) next(ctx context.Context, b *bot.Bot, update *models.Update) e prayerID, duration = domain.PrayerIDMaghrib, prayerDay.Maghrib.Sub(now) case prayerDay.Isha.After(now): prayerID, duration = domain.PrayerIDIsha, prayerDay.Isha.Sub(now) - } - - // when no prayer time is found, return the first prayer of the next day - if prayerID == domain.PrayerIDUnknown || duration == 0 { - prayerDay, err = h.db.GetPrayerDay(ctx, chat.BotID, date.AddDate(0, 0, 1)) - if err != nil { - log.Error("next: get prayer day", log.Err(err), log.BotID(chat.BotID), log.ChatID(chat.ChatID)) - return domain.ErrInternal - } - prayerID, duration = domain.PrayerIDFajr, prayerDay.Fajr.Sub(now) + case prayerDay.NextDay.Fajr.After(now): + prayerID, duration = domain.PrayerIDFajr, prayerDay.NextDay.Fajr.Sub(now) + case prayerDay.NextDay.Shuruq.After(now): + prayerID, duration = domain.PrayerIDShuruq, prayerDay.NextDay.Shuruq.Sub(now) + default: + log.Error("next: no prayer time found", + log.BotID(chat.BotID), + log.ChatID(chat.ChatID), + log.String("date", date.String()), + log.String("prayer_day", fmt.Sprintf("%+v", prayerDay)), + log.String("now", now.String()), + ) + return nil } text := h.lp.GetText(chat.LanguageCode) @@ -184,13 +212,21 @@ func (h *Handler) next(ctx context.Context, b *bot.Bot, update *models.Update) e return nil } -func (h *Handler) remind(ctx context.Context, b *bot.Bot, update *models.Update) error { +func (h *Handler) remind(ctx context.Context, b *bot.Bot, _ *models.Update) error { chat := getContextChat(ctx) + text := h.lp.GetText(chat.LanguageCode) + + var messageText string + if chat.Subscribed { + messageText = text.RemindMenu.TitleEnabled + } else { + messageText = text.RemindMenu.TitleDisabled + } _, err := b.SendMessage(ctx, &bot.SendMessageParams{ ChatID: chat.ChatID, - Text: h.lp.GetText(chat.LanguageCode).Remind.Start, - ReplyMarkup: h.remindKeyboard(), + Text: messageText, + ReplyMarkup: h.remindMenuKeyboard(chat), }) if err != nil { log.Error("remind: send message", log.Err(err), log.BotID(chat.BotID), log.ChatID(chat.ChatID)) @@ -201,7 +237,7 @@ func (h *Handler) remind(ctx context.Context, b *bot.Bot, update *models.Update) return nil } -func (h *Handler) bug(ctx context.Context, b *bot.Bot, update *models.Update) error { +func (h *Handler) bug(ctx context.Context, b *bot.Bot, _ *models.Update) error { chat := getContextChat(ctx) _, err := b.SendMessage(ctx, &bot.SendMessageParams{ @@ -222,7 +258,7 @@ func (h *Handler) bug(ctx context.Context, b *bot.Bot, update *models.Update) er return nil } -func (h *Handler) feedback(ctx context.Context, b *bot.Bot, update *models.Update) error { +func (h *Handler) feedback(ctx context.Context, b *bot.Bot, _ *models.Update) error { chat := getContextChat(ctx) _, err := b.SendMessage(ctx, &bot.SendMessageParams{ @@ -243,7 +279,7 @@ func (h *Handler) feedback(ctx context.Context, b *bot.Bot, update *models.Updat return nil } -func (h *Handler) language(ctx context.Context, b *bot.Bot, update *models.Update) error { +func (h *Handler) language(ctx context.Context, b *bot.Bot, _ *models.Update) error { chat := getContextChat(ctx) _, err := b.SendMessage(ctx, &bot.SendMessageParams{ @@ -260,21 +296,16 @@ func (h *Handler) language(ctx context.Context, b *bot.Bot, update *models.Updat return nil } -func (h *Handler) subscribe(ctx context.Context, b *bot.Bot, update *models.Update) error { +func (h *Handler) admin(ctx context.Context, b *bot.Bot, _ *models.Update) error { chat := getContextChat(ctx) - err := h.db.SetSubscribed(ctx, chat.BotID, chat.ChatID, true) - if err != nil { - log.Error("subscribe: set subscribed", log.Err(err), log.BotID(chat.BotID), log.ChatID(chat.ChatID)) - return domain.ErrInternal - } - - _, err = b.SendMessage(ctx, &bot.SendMessageParams{ - ChatID: chat.ChatID, - Text: h.lp.GetText(chat.LanguageCode).SubscriptionSuccess, + _, err := b.SendMessage(ctx, &bot.SendMessageParams{ + ChatID: chat.ChatID, + Text: h.lp.GetText(chat.LanguageCode).HelpAdmin, + ParseMode: models.ParseModeMarkdown, }) if err != nil { - log.Error("subscribe: send message", log.Err(err), log.BotID(chat.BotID), log.ChatID(chat.ChatID)) + log.Error("admin: send message", log.Err(err), log.BotID(chat.BotID), log.ChatID(chat.ChatID)) return domain.ErrInternal } @@ -282,38 +313,54 @@ func (h *Handler) subscribe(ctx context.Context, b *bot.Bot, update *models.Upda return nil } -func (h *Handler) unsubscribe(ctx context.Context, b *bot.Bot, update *models.Update) error { +func (h *Handler) info(ctx context.Context, b *bot.Bot, _ *models.Update) error { chat := getContextChat(ctx) + text := h.lp.GetText(chat.LanguageCode) - err := h.db.SetSubscribed(ctx, chat.BotID, chat.ChatID, false) - if err != nil { - log.Error("unsubscribe: set subscribed", log.Err(err), log.BotID(chat.BotID), log.ChatID(chat.ChatID)) - return domain.ErrInternal + chatType := text.Info.Type.Private + if isChatGroup(chat.ChatID) { + chatType = text.Info.Type.Group } - _, err = b.SendMessage(ctx, &bot.SendMessageParams{ - ChatID: chat.ChatID, - Text: h.lp.GetText(chat.LanguageCode).UnsubscriptionSuccess, - }) - if err != nil { - log.Error("unsubscribe: send message", log.Err(err), log.BotID(chat.BotID), log.ChatID(chat.ChatID)) - return domain.ErrInternal + subscriptionStatus := text.Info.Disabled + if chat.Subscribed { + subscriptionStatus = text.Info.Enabled } - h.resetState(ctx, chat) - return nil -} + jamaatInfo := "" + if isChatGroup(chat.ChatID) { + jamaatStatus := text.Info.Disabled + if chat.Reminder.Jamaat.Enabled { + jamaatStatus = text.Info.Enabled + } + jamaatInfo = fmt.Sprintf(text.Info.Jamaat, + jamaatStatus, + domain.FormatDuration(chat.Reminder.Jamaat.Delay.Fajr.Duration()), + domain.FormatDuration(chat.Reminder.Jamaat.Delay.Dhuhr.Duration()), + domain.FormatDuration(chat.Reminder.Jamaat.Delay.Asr.Duration()), + domain.FormatDuration(chat.Reminder.Jamaat.Delay.Maghrib.Duration()), + domain.FormatDuration(chat.Reminder.Jamaat.Delay.Isha.Duration()), + ) + } -func (h *Handler) admin(ctx context.Context, b *bot.Bot, update *models.Update) error { - chat := getContextChat(ctx) + message := fmt.Sprintf(text.Info.Default, + fmt.Sprintf("%d", chat.ChatID), + chatType, + chat.LanguageCode, + chat.State, + subscriptionStatus, + domain.FormatDuration(chat.Reminder.Tomorrow.Offset.Duration()), + domain.FormatDuration(chat.Reminder.Soon.Offset.Duration()), + jamaatInfo, + ) _, err := b.SendMessage(ctx, &bot.SendMessageParams{ ChatID: chat.ChatID, - Text: h.lp.GetText(chat.LanguageCode).HelpAdmin, + Text: message, ParseMode: models.ParseModeMarkdown, }) if err != nil { - log.Error("admin: send message", log.Err(err), log.BotID(chat.BotID), log.ChatID(chat.ChatID)) + log.Error("info: send message", log.Err(err), log.BotID(chat.BotID), log.ChatID(chat.ChatID)) return domain.ErrInternal } @@ -321,7 +368,7 @@ func (h *Handler) admin(ctx context.Context, b *bot.Bot, update *models.Update) return nil } -func (h *Handler) reply(ctx context.Context, b *bot.Bot, update *models.Update) error { +func (h *Handler) reply(ctx context.Context, b *bot.Bot, _ *models.Update) error { chat := getContextChat(ctx) err := h.db.SetState(ctx, chat.BotID, chat.ChatID, string(replyState)) @@ -343,7 +390,7 @@ func (h *Handler) reply(ctx context.Context, b *bot.Bot, update *models.Update) return nil } -func (h *Handler) stats(ctx context.Context, b *bot.Bot, update *models.Update) error { +func (h *Handler) stats(ctx context.Context, b *bot.Bot, _ *models.Update) error { chat := getContextChat(ctx) stats, err := h.db.GetStats(ctx, chat.BotID) @@ -377,7 +424,7 @@ func (h *Handler) stats(ctx context.Context, b *bot.Bot, update *models.Update) return nil } -func (h *Handler) announce(ctx context.Context, b *bot.Bot, update *models.Update) error { +func (h *Handler) announce(ctx context.Context, b *bot.Bot, _ *models.Update) error { chat := getContextChat(ctx) err := h.db.SetState(ctx, chat.BotID, chat.ChatID, string(announceState)) @@ -399,7 +446,7 @@ func (h *Handler) announce(ctx context.Context, b *bot.Bot, update *models.Updat return nil } -func (h *Handler) cancel(ctx context.Context, b *bot.Bot, update *models.Update) error { +func (h *Handler) cancel(ctx context.Context, b *bot.Bot, _ *models.Update) error { chat := getContextChat(ctx) if chat.State == string(defaultState) { diff --git a/serverless/dispatcher/internal/handler/handler.go b/serverless/dispatcher/internal/handler/handler.go index 3914db1..7579a8b 100644 --- a/serverless/dispatcher/internal/handler/handler.go +++ b/serverless/dispatcher/internal/handler/handler.go @@ -17,7 +17,7 @@ import ( type ( DB interface { - CreateChat(ctx context.Context, botID int64, chatID int64, languageCode string, reminderOffset int32, state string, jamaat bool) error + CreateChat(ctx context.Context, botID int64, chatID int64, languageCode string, state string, reminder *domain.Reminder) error GetChat(ctx context.Context, botID int64, chatID int64) (chat *domain.Chat, _ error) GetChats(ctx context.Context, botID int64) (chats []*domain.Chat, _ error) SetState(ctx context.Context, botID int64, chatID int64, state string) error @@ -27,7 +27,9 @@ type ( SetSubscribed(ctx context.Context, botID int64, chatID int64, subscribed bool) error SetLanguageCode(ctx context.Context, botID int64, chatID int64, languageCode string) error - SetReminderOffset(ctx context.Context, botID int64, chatID int64, reminderOffset int32) error + SetReminderOffset(ctx context.Context, botID int64, chatID int64, reminderType domain.ReminderType, offset time.Duration) error + SetJamaatEnabled(ctx context.Context, botID int64, chatID int64, enabled bool) error + SetJamaatDelay(ctx context.Context, botID int64, chatID int64, prayerID domain.PrayerID, delay time.Duration) error } Handler struct { @@ -61,26 +63,35 @@ func (h *Handler) opts() []bot.Option { bot.WithMessageTextHandler(startCommand.String(), bot.MatchTypeCommandStartOnly, h.errorH(h.chatH(h.start))), bot.WithMessageTextHandler(helpCommand.String(), bot.MatchTypeCommand, h.errorH(h.chatH(h.help))), bot.WithMessageTextHandler(todayCommand.String(), bot.MatchTypeCommand, h.errorH(h.chatH(h.today))), + bot.WithMessageTextHandler(tomorrowCommand.String(), bot.MatchTypeCommand, h.errorH(h.chatH(h.tomorrow))), bot.WithMessageTextHandler(dateCommand.String(), bot.MatchTypeCommand, h.errorH(h.chatH(h.date))), bot.WithMessageTextHandler(nextCommand.String(), bot.MatchTypeCommand, h.errorH(h.chatH(h.next))), bot.WithMessageTextHandler(remindCommand.String(), bot.MatchTypeCommand, h.errorH(h.chatH(h.remind))), bot.WithMessageTextHandler(bugCommand.String(), bot.MatchTypeCommand, h.errorH(h.chatH(h.bug))), bot.WithMessageTextHandler(feedbackCommand.String(), bot.MatchTypeCommand, h.errorH(h.chatH(h.feedback))), bot.WithMessageTextHandler(languageCommand.String(), bot.MatchTypeCommand, h.errorH(h.chatH(h.language))), - bot.WithMessageTextHandler(subscribeCommand.String(), bot.MatchTypeCommand, h.errorH(h.chatH(h.subscribe))), - bot.WithMessageTextHandler(unsubscribeCommand.String(), bot.MatchTypeCommand, h.errorH(h.chatH(h.unsubscribe))), bot.WithMessageTextHandler(cancelCommand.String(), bot.MatchTypeCommand, h.errorH(h.chatH(h.cancel))), bot.WithMessageTextHandler(adminCommand.String(), bot.MatchTypeCommand, h.errorH(h.chatH(h.authorizeH(h.admin)))), + bot.WithMessageTextHandler(infoCommand.String(), bot.MatchTypeCommand, h.errorH(h.chatH(h.authorizeH(h.info)))), bot.WithMessageTextHandler(replyCommand.String(), bot.MatchTypeCommand, h.errorH(h.chatH(h.authorizeH(h.reply)))), bot.WithMessageTextHandler(statsCommand.String(), bot.MatchTypeCommand, h.errorH(h.chatH(h.authorizeH(h.stats)))), bot.WithMessageTextHandler(announceCommand.String(), bot.MatchTypeCommand, h.errorH(h.chatH(h.authorizeH(h.announce)))), bot.WithCallbackQueryDataHandler(monthQuery.String(), bot.MatchTypePrefix, h.errorH(h.chatH(h.monthQuery))), bot.WithCallbackQueryDataHandler(dayQuery.String(), bot.MatchTypePrefix, h.errorH(h.chatH(h.dayQuery))), - bot.WithCallbackQueryDataHandler(remindQuery.String(), bot.MatchTypePrefix, h.errorH(h.chatH(h.remindQuery))), bot.WithCallbackQueryDataHandler(languageQuery.String(), bot.MatchTypePrefix, h.errorH(h.chatH(h.languageQuery))), bot.WithCallbackQueryDataHandler(emptyQuery.String(), bot.MatchTypePrefix, h.errorH(h.chatH(h.emptyQuery))), + bot.WithCallbackQueryDataHandler(remindMenuQuery.String(), bot.MatchTypePrefix, h.errorH(h.chatH(h.remindMenuQuery))), + bot.WithCallbackQueryDataHandler(remindToggleQuery.String(), bot.MatchTypePrefix, h.errorH(h.chatH(h.remindToggleQuery))), + bot.WithCallbackQueryDataHandler(remindEditQuery.String(), bot.MatchTypePrefix, h.errorH(h.chatH(h.remindEditQuery))), + bot.WithCallbackQueryDataHandler(remindAdjustQuery.String(), bot.MatchTypePrefix, h.errorH(h.chatH(h.remindAdjustQuery))), + bot.WithCallbackQueryDataHandler(remindJamaatMenuQuery.String(), bot.MatchTypePrefix, h.errorH(h.chatH(h.remindJamaatMenuQuery))), + bot.WithCallbackQueryDataHandler(remindJamaatToggleQuery.String(), bot.MatchTypePrefix, h.errorH(h.chatH(h.remindJamaatToggleQuery))), + bot.WithCallbackQueryDataHandler(remindJamaatEditQuery.String(), bot.MatchTypePrefix, h.errorH(h.chatH(h.remindJamaatEditQuery))), + bot.WithCallbackQueryDataHandler(remindJamaatAdjustQuery.String(), bot.MatchTypePrefix, h.errorH(h.chatH(h.remindJamaatAdjustQuery))), + bot.WithCallbackQueryDataHandler(remindBackQuery.String(), bot.MatchTypePrefix, h.errorH(h.chatH(h.remindBackQuery))), + bot.WithCallbackQueryDataHandler(remindCloseQuery.String(), bot.MatchTypePrefix, h.errorH(h.chatH(h.remindCloseQuery))), } } @@ -123,12 +134,8 @@ func (h *Handler) authorizeH(fn func(ctx context.Context, b *bot.Bot, update *mo return func(ctx context.Context, b *bot.Bot, update *models.Update) error { chat := getContextChat(ctx) - var ( - isAdminUser = h.cfg[chat.BotID].OwnerID == update.Message.From.ID - isAdminChat = h.cfg[chat.BotID].OwnerID == update.Message.Chat.ID - ) - - if isAdminUser && isAdminChat { + isAdminUser := h.cfg[chat.BotID].OwnerID == update.Message.From.ID + if isAdminUser { return fn(ctx, b, update) } @@ -200,15 +207,12 @@ func (h *Handler) getBot(botID int64) (*bot.Bot, error) { func (h *Handler) getChat(ctx context.Context, update *models.Update) (*domain.Chat, error) { botID := getContextBotID(ctx) chatID := int64(0) - jamaat := false switch { case update.Message != nil: chatID = update.Message.Chat.ID - jamaat = isJamaat(update.Message.Chat) case update.CallbackQuery != nil && update.CallbackQuery.Message.Message != nil: chatID = update.CallbackQuery.Message.Message.Chat.ID - jamaat = isJamaat(update.CallbackQuery.Message.Message.Chat) default: bytes, _ := json.Marshal(update) log.Info("ignore message", log.BotID(botID), log.String("update", string(bytes))) @@ -232,20 +236,40 @@ func (h *Handler) getChat(ctx context.Context, update *models.Update) (*domain.C languageCode = update.Message.From.LanguageCode } - err = h.db.CreateChat(ctx, botID, chatID, languageCode, int32(0), string(defaultState), jamaat) + now := h.now(botID) + reminder := &domain.Reminder{ + Tomorrow: &domain.ReminderConfig{ + LastAt: now, + Offset: domain.Duration(3 * time.Hour), + }, + Soon: &domain.ReminderConfig{ + LastAt: now, + Offset: domain.Duration(20 * time.Minute), + }, + Arrive: &domain.ReminderConfig{LastAt: now}, + Jamaat: &domain.JamaatConfig{ + Delay: &domain.JamaatDelayConfig{ + Fajr: domain.Duration(10 * time.Minute), + Dhuhr: domain.Duration(10 * time.Minute), + Asr: domain.Duration(10 * time.Minute), + Maghrib: domain.Duration(10 * time.Minute), + Isha: domain.Duration(20 * time.Minute), + }, + }, + } + + err = h.db.CreateChat(ctx, botID, chatID, languageCode, string(defaultState), reminder) if err != nil { log.Error("create chat", log.Err(err), log.BotID(botID), log.ChatID(chatID), "update", update) return nil, domain.ErrInternal } chat = &domain.Chat{ - BotID: botID, - ChatID: chatID, - State: string(defaultState), - LanguageCode: languageCode, - ReminderMessageID: 0, - Jamaat: jamaat, - JamaatMessageID: 0, + BotID: botID, + ChatID: chatID, + State: string(defaultState), + LanguageCode: languageCode, + Reminder: reminder, } return chat, nil diff --git a/serverless/dispatcher/internal/handler/helper.go b/serverless/dispatcher/internal/handler/helper.go index c4ed3be..cc308c0 100644 --- a/serverless/dispatcher/internal/handler/helper.go +++ b/serverless/dispatcher/internal/handler/helper.go @@ -3,10 +3,11 @@ package handler import ( "context" "fmt" + "strconv" + "strings" "time" "github.com/escalopa/prayer-bot/domain" - "github.com/go-telegram/bot/models" ) type ( @@ -49,15 +50,12 @@ func (h *Handler) formatPrayerDay(botID int64, date *domain.PrayerDay, languageC // now returns the current time with seconds and nanoseconds set to 0 func (h *Handler) now(botID int64) time.Time { - t := time.Now().In(h.cfg[botID].Location.V()) - return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), 0, 0, t.Location()) + return time.Now().In(h.cfg[botID].Location.V()).Truncate(time.Minute) } -// nowUTC returns the current time in UTC with seconds and nanoseconds set to 0 -// Use this function to get prayerDay or current year for a specific botID timezone. -func (h *Handler) nowUTC(botID int64) time.Time { - t := time.Now().In(h.cfg[botID].Location.V()) - return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), 0, 0, time.UTC) +func (h *Handler) nowDateUTC(botID int64) time.Time { + now := h.now(botID) + return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) } // daysInMonth returns the number of days in a month. @@ -67,14 +65,29 @@ func daysInMonth(month time.Month, year int) int { } // layoutRowsInfo calculates number of rows needed to display input count and number of empty cells in the last row. -func layoutRowsInfo(totalItems, itemsPerRow int) (int, int) { +func layoutRowsInfo(totalItems, itemsPerRow int) (filled int, empty int) { if totalItems%itemsPerRow == 0 { return totalItems / itemsPerRow, 0 } - empty := itemsPerRow - (totalItems % itemsPerRow) - return (totalItems / itemsPerRow) + 1, empty + empty = itemsPerRow - (totalItems % itemsPerRow) + filled = (totalItems / itemsPerRow) + 1 + return } -func isJamaat(chat models.Chat) bool { - return chat.Type == models.ChatTypeGroup || chat.Type == models.ChatTypeSupergroup +// parseAdjustment parses the adjustment string and returns the time duration. +func parseAdjustment(adj string) time.Duration { + if strings.HasSuffix(adj, "m") { + val, _ := strconv.Atoi(strings.TrimSuffix(adj, "m")) + return time.Duration(val) * time.Minute + } + if strings.HasSuffix(adj, "h") { + val, _ := strconv.Atoi(strings.TrimSuffix(adj, "h")) + return time.Duration(val) * time.Hour + } + return 0 +} + +// isChatGroup checks if the chat is a group or supergroup. +func isChatGroup(chatID int64) bool { + return chatID < 0 } diff --git a/serverless/dispatcher/internal/handler/keyboard.go b/serverless/dispatcher/internal/handler/keyboard.go index 97ae430..921fa86 100644 --- a/serverless/dispatcher/internal/handler/keyboard.go +++ b/serverless/dispatcher/internal/handler/keyboard.go @@ -14,6 +14,8 @@ const ( monthsPerRow = 3 remindPerRow = 4 daysPerRow = 5 + + buttonBack = "🔙" ) func (h *Handler) languagesKeyboard() *models.InlineKeyboardMarkup { @@ -90,25 +92,137 @@ func (h *Handler) daysKeyboard(now time.Time, month int) *models.InlineKeyboardM return kb } -func (h *Handler) remindKeyboard() *models.InlineKeyboardMarkup { - reminderOffsets := domain.ReminderOffsets() - rows, empty := layoutRowsInfo(len(reminderOffsets), remindPerRow) +func (h *Handler) remindMenuKeyboard(chat *domain.Chat) *models.InlineKeyboardMarkup { + text := h.lp.GetText(chat.LanguageCode) - kb := &models.InlineKeyboardMarkup{InlineKeyboard: make([][]models.InlineKeyboardButton, rows)} + // Calculate number of rows based on whether it's a group + numRows := 4 + if isChatGroup(chat.ChatID) { + numRows = 5 + } + kb := &models.InlineKeyboardMarkup{InlineKeyboard: make([][]models.InlineKeyboardButton, numRows)} - row := 0 - for i, offset := range reminderOffsets { - if i%remindPerRow == 0 && i != 0 { - row++ + rowIndex := 0 + + if chat.Subscribed { + kb.InlineKeyboard[rowIndex] = []models.InlineKeyboardButton{ + {Text: text.RemindMenu.Disable, CallbackData: "remind:toggle|"}, + } + } else { + kb.InlineKeyboard[rowIndex] = []models.InlineKeyboardButton{ + {Text: text.RemindMenu.Enable, CallbackData: "remind:toggle|"}, } - kb.InlineKeyboard[row] = append(kb.InlineKeyboard[row], models.InlineKeyboardButton{ - Text: strconv.Itoa(int(offset)), - CallbackData: fmt.Sprintf("%s%d", remindQuery, offset), - }) } + rowIndex++ - for i := 0; i < empty; i++ { - kb.InlineKeyboard[row] = append(kb.InlineKeyboard[row], emptyButton()) + tomorrowOffset := domain.FormatDuration(chat.Reminder.Tomorrow.Offset.Duration()) + kb.InlineKeyboard[rowIndex] = []models.InlineKeyboardButton{ + {Text: fmt.Sprintf("%s (%s)", text.RemindMenu.Tomorrow, tomorrowOffset), CallbackData: "remind:edit:tomorrow|"}, + } + rowIndex++ + + soonOffset := domain.FormatDuration(chat.Reminder.Soon.Offset.Duration()) + kb.InlineKeyboard[rowIndex] = []models.InlineKeyboardButton{ + {Text: fmt.Sprintf("%s (%s)", text.RemindMenu.Soon, soonOffset), CallbackData: "remind:edit:soon|"}, + } + rowIndex++ + + // Only show Jamaat Settings for group chats + if isChatGroup(chat.ChatID) { + kb.InlineKeyboard[rowIndex] = []models.InlineKeyboardButton{ + {Text: text.RemindMenu.JamaatSettings, CallbackData: "remind:jamaat:menu|"}, + } + rowIndex++ + } + + kb.InlineKeyboard[rowIndex] = []models.InlineKeyboardButton{ + {Text: text.RemindMenu.Close, CallbackData: "remind:close|"}, + } + + return kb +} + +func (h *Handler) remindEditKeyboard(reminderType domain.ReminderType, languageCode string) *models.InlineKeyboardMarkup { + kb := &models.InlineKeyboardMarkup{InlineKeyboard: make([][]models.InlineKeyboardButton, 3)} + + kb.InlineKeyboard[0] = []models.InlineKeyboardButton{ + {Text: "+1m", CallbackData: fmt.Sprintf("remind:adjust:%s:+1m|", reminderType)}, + {Text: "+10m", CallbackData: fmt.Sprintf("remind:adjust:%s:+10m|", reminderType)}, + {Text: "+1h", CallbackData: fmt.Sprintf("remind:adjust:%s:+1h|", reminderType)}, + } + + kb.InlineKeyboard[1] = []models.InlineKeyboardButton{ + {Text: "-1m", CallbackData: fmt.Sprintf("remind:adjust:%s:-1m|", reminderType)}, + {Text: "-10m", CallbackData: fmt.Sprintf("remind:adjust:%s:-10m|", reminderType)}, + {Text: "-1h", CallbackData: fmt.Sprintf("remind:adjust:%s:-1h|", reminderType)}, + } + + kb.InlineKeyboard[2] = []models.InlineKeyboardButton{ + {Text: buttonBack, CallbackData: "remind:back:menu|"}, + } + + return kb +} + +func (h *Handler) jammatMenuKeyboard(chat *domain.Chat) *models.InlineKeyboardMarkup { + text := h.lp.GetText(chat.LanguageCode) + + prayerIDs := []domain.PrayerID{ + domain.PrayerIDFajr, + domain.PrayerIDDhuhr, + domain.PrayerIDAsr, + domain.PrayerIDMaghrib, + domain.PrayerIDIsha, + } + + kb := &models.InlineKeyboardMarkup{InlineKeyboard: make([][]models.InlineKeyboardButton, len(prayerIDs)+2)} + + if chat.Reminder.Jamaat.Enabled { + kb.InlineKeyboard[0] = []models.InlineKeyboardButton{ + {Text: text.JamaatMenu.Disable, CallbackData: "remind:jamaat:toggle|"}, + } + } else { + kb.InlineKeyboard[0] = []models.InlineKeyboardButton{ + {Text: text.JamaatMenu.Enable, CallbackData: "remind:jamaat:toggle|"}, + } + } + + for i, prayerID := range prayerIDs { + delay := chat.Reminder.Jamaat.Delay.GetDelayByPrayerID(prayerID) + kb.InlineKeyboard[i+1] = []models.InlineKeyboardButton{ + { + Text: fmt.Sprintf("%s (%s)", text.Prayer[int(prayerID)], domain.FormatDuration(delay)), + CallbackData: fmt.Sprintf("remind:jamaat:edit:%s|", prayerID.String()), + }, + } + } + + kb.InlineKeyboard[len(prayerIDs)+1] = []models.InlineKeyboardButton{ + {Text: buttonBack, CallbackData: "remind:back:menu|"}, + } + + return kb +} + +func (h *Handler) jammatEditKeyboard(prayerID domain.PrayerID, languageCode string) *models.InlineKeyboardMarkup { + kb := &models.InlineKeyboardMarkup{InlineKeyboard: make([][]models.InlineKeyboardButton, 3)} + + prayerName := prayerID.String() + + kb.InlineKeyboard[0] = []models.InlineKeyboardButton{ + {Text: "+1m", CallbackData: fmt.Sprintf("remind:jamaat:adjust:%s:+1m|", prayerName)}, + {Text: "+10m", CallbackData: fmt.Sprintf("remind:jamaat:adjust:%s:+10m|", prayerName)}, + {Text: "+1h", CallbackData: fmt.Sprintf("remind:jamaat:adjust:%s:+1h|", prayerName)}, + } + + kb.InlineKeyboard[1] = []models.InlineKeyboardButton{ + {Text: "-1m", CallbackData: fmt.Sprintf("remind:jamaat:adjust:%s:-1m|", prayerName)}, + {Text: "-10m", CallbackData: fmt.Sprintf("remind:jamaat:adjust:%s:-10m|", prayerName)}, + {Text: "-1h", CallbackData: fmt.Sprintf("remind:jamaat:adjust:%s:-1h|", prayerName)}, + } + + kb.InlineKeyboard[2] = []models.InlineKeyboardButton{ + {Text: buttonBack, CallbackData: "remind:back:jamaat|"}, } return kb diff --git a/serverless/dispatcher/internal/handler/languages.go b/serverless/dispatcher/internal/handler/languages.go index f09d2cd..1868cea 100644 --- a/serverless/dispatcher/internal/handler/languages.go +++ b/serverless/dispatcher/internal/handler/languages.go @@ -25,6 +25,51 @@ type ( Success string `yaml:"success"` } + RemindMenuText struct { + TitleEnabled string `yaml:"title_enabled"` + TitleDisabled string `yaml:"title_disabled"` + Enable string `yaml:"enable"` + Disable string `yaml:"disable"` + Tomorrow string `yaml:"tomorrow"` + Soon string `yaml:"soon"` + JamaatSettings string `yaml:"jamaat_settings"` + Close string `yaml:"close"` + } + + RemindEditText struct { + TitleTomorrow string `yaml:"title_tomorrow"` + TitleSoon string `yaml:"title_soon"` + } + + JamaatMenuText struct { + TitleEnabled string `yaml:"title_enabled"` + TitleDisabled string `yaml:"title_disabled"` + Enable string `yaml:"enable"` + Disable string `yaml:"disable"` + } + + JamaatEditText struct { + Title string `yaml:"title"` + } + + ButtonsText struct { + Save string `yaml:"save"` + Back string `yaml:"back"` + } + + InfoTypeText struct { + Private string `yaml:"private"` + Group string `yaml:"group"` + } + + InfoText struct { + Default string `yaml:"default"` + Jamaat string `yaml:"jamaat"` + Type InfoTypeText `yaml:"type"` + Enabled string `yaml:"enabled"` + Disabled string `yaml:"disabled"` + } + Text struct { Name string `yaml:"name"` @@ -37,8 +82,12 @@ type ( Remind InteractiveMessage `yaml:"remind"` Language InteractiveMessage `yaml:"language"` - SubscriptionSuccess string `yaml:"subscription_success"` - UnsubscriptionSuccess string `yaml:"unsubscription_success"` + RemindMenu RemindMenuText `yaml:"remind_menu"` + RemindEdit RemindEditText `yaml:"remind_edit"` + JamaatMenu JamaatMenuText `yaml:"jamaat_menu"` + JamaatEdit JamaatEditText `yaml:"jamaat_edit"` + Buttons ButtonsText `yaml:"buttons"` + Info InfoText `yaml:"info"` PrayerSoon string `yaml:"prayer_soon"` diff --git a/serverless/dispatcher/internal/handler/languages/ar.yaml b/serverless/dispatcher/internal/handler/languages/ar.yaml index ccd9913..1802fb8 100644 --- a/serverless/dispatcher/internal/handler/languages/ar.yaml +++ b/serverless/dispatcher/internal/handler/languages/ar.yaml @@ -42,29 +42,48 @@ language: start: "🌐 اختر لغتك المفضلة" success: "✅ تم تغيير اللغة إلى *%s*" -subscription_success: "✅ تم الاشتراك في تذكيرات الصلاة" -unsubscription_success: "✅ تم إلغاء الاشتراك في تذكيرات الصلاة" +remind_menu: + title_enabled: "⚙️ التذكيرات - مفعّل ✅" + title_disabled: "⚙️ التذكيرات - معطّل 🔕" + enable: "🔔 تفعيل" + disable: "🔕 تعطيل" + tomorrow: "🌅 معاينة الغد" + soon: "⏰ تنبيه الصلاة" + jamaat_settings: "🕌 مواعيد الجماعة" + close: "❌ إغلاق" + +remind_edit: + title_tomorrow: "🌅 معاينة الغد\n⏰ إرسال مواقيت صلاة الغد قبل منتصف الليل" + title_soon: "⏰ تنبيه الصلاة\n⏱ التذكير قبل كل وقت صلاة" + +jamaat_menu: + title_enabled: "🕌 مواعيد الجماعة - مفعّل ✅\n(للمجموعات فقط)" + title_disabled: "🕌 مواعيد الجماعة - معطّل 🔕\n(للمجموعات فقط)" + enable: "🔔 تفعيل" + disable: "🔕 تعطيل" + +jamaat_edit: + title: "🕌 موعد جماعة %s\n⏱ الوقت بعد الأذان" help: | هل تحتاج مساعدة؟ 😊 إليك ما يمكنني فعله: 👇 - + *الصلاة:* - ⏰ /today – مواقيت اليوم - 📅 /date – مواقيت يوم محدد - ⏳ /next – متى الصلاة القادمة؟ - 📨 /remind – ضبط تذكير قبل الصلاة - 🔔 /subscribe – استقبال تذكيرات يومية - 🔕 /unsubscribe – إيقاف التذكيرات اليومية + ⏰ /today – مواقيت اليوم + 🌅 /tomorrow – مواقيت الغد + 📅 /date – مواقيت يوم محدد + ⏳ /next – متى الصلاة القادمة؟ + 📨 /remind – إعدادات التذكير *الإعدادات:* - 🌐 /language – تغيير اللغة + 🌐 /language – تغيير اللغة *الدعم:* - 📖 /help – عرض هذا الدليل - 🐞 /bug – الإبلاغ عن مشكلة + 📖 /help – عرض هذا الدليل + 🐞 /bug – الإبلاغ عن مشكلة 💬 /feedback – إرسال ملاحظات feedback: @@ -76,10 +95,39 @@ bug: success: "✅ شكراً! تم استلام تقريرك" help_admin: | + 📊 /info – عرض معلومات وإعدادات المحادثة 📩 /reply – الرد على المستخدمين - 📊 /stats – إحصائيات استخدام البوت + 📈 /stats – إحصائيات استخدام البوت 📢 /announce – إرسال رسالة للجميع +info: + default: | + 📊 *معلومات المحادثة* + + 🆔 *معرف المحادثة:* `%s` + 💬 *النوع:* %s + 🌐 *اللغة:* %s + 📍 *الحالة:* %s + + ⏰ *إعدادات التذكير:* + الحالة: %s + • 🌅 معاينة الغد: %s + • ⏰ تنبيه الصلاة: %s%s + jamaat: | + + 🕌 *إعدادات الجماعة:* + الحالة: %s + • الفجر: %s + • الظهر: %s + • العصر: %s + • المغرب: %s + • العشاء: %s + type: + private: "خاص" + group: "مجموعة" + enabled: "✅ مفعّل" + disabled: "❌ معطّل" + reply: start: "✍️ اكتب ردك أدناه" success: "✅ تم إرسال الرد" @@ -90,13 +138,13 @@ announce: stats: | 📊 إحصائيات البوت: - - • عدد المستخدمين: %d - • المشتركين: %d - • غير المشتركين: %d + + • عدد المستخدمين: %d + • المشتركين: %d + • غير المشتركين: %d 🌍 اللغات المستخدمة: - + %s cancel: "✅ تم إلغاء العملية" diff --git a/serverless/dispatcher/internal/handler/languages/en.yaml b/serverless/dispatcher/internal/handler/languages/en.yaml index 9441938..2e8e4d2 100644 --- a/serverless/dispatcher/internal/handler/languages/en.yaml +++ b/serverless/dispatcher/internal/handler/languages/en.yaml @@ -42,29 +42,48 @@ language: start: "🌐 Pick your preferred language" success: "✅ Language changed to *%s*" -subscription_success: "✅ You’re now subscribed to prayer reminders" -unsubscription_success: "✅ You’ve unsubscribed from prayer reminders" +remind_menu: + title_enabled: "⚙️ Reminders - Enabled ✅" + title_disabled: "⚙️ Reminders - Disabled 🔕" + enable: "🔔 Enable" + disable: "🔕 Disable" + tomorrow: "🌅 Tomorrow Preview" + soon: "⏰ Prayer Alert" + jamaat_settings: "🕌 Jamaat Times" + close: "❌ Close" + +remind_edit: + title_tomorrow: "🌅 Tomorrow Preview\n⏰ Send tomorrow's prayer times before midnight" + title_soon: "⏰ Prayer Alert\n⏱ Remind before each prayer time" + +jamaat_menu: + title_enabled: "🕌 Jamaat Times - Enabled ✅\n(Group Only)" + title_disabled: "🕌 Jamaat Times - Disabled 🔕\n(Group Only)" + enable: "🔔 Enable" + disable: "🔕 Disable" + +jamaat_edit: + title: "🕌 %s Jamaat Time\n⏱ Time after Adhan" help: | - Need help? 😊 Here’s what I can do: 👇 - + Need help? 😊 Here's what I can do: 👇 + *Prayers:* - ⏰ /today – Today’s times - 📅 /date – Times for a chosen day - ⏳ /next – When’s the next prayer? - 📨 /remind – Set a reminder before prayer - 🔔 /subscribe – Get daily reminders - 🔕 /unsubscribe – Stop daily reminders + ⏰ /today – Today's times + 🌅 /tomorrow – Tomorrow's times + 📅 /date – Times for a chosen day + ⏳ /next – When's the next prayer? + 📨 /remind – Configure reminders *Settings:* - 🌐 /language – Change language + 🌐 /language – Change language *Support:* - 📖 /help – Show this guide - 🐞 /bug – Report a problem + 📖 /help – Show this guide + 🐞 /bug – Report a problem 💬 /feedback – Send feedback feedback: @@ -76,10 +95,39 @@ bug: success: "✅ Thanks! Your report has been received" help_admin: | + 📊 /info – View chat info & settings 📩 /reply – Reply to users - 📊 /stats – Bot usage stats + 📈 /stats – Bot usage stats 📢 /announce – Send message to everyone +info: + default: | + 📊 *Chat Information* + + 🆔 *Chat ID:* `%s` + 💬 *Type:* %s + 🌐 *Language:* %s + 📍 *State:* %s + + ⏰ *Reminder Settings:* + Status: %s + • 🌅 Tomorrow Preview: %s + • ⏰ Prayer Alert: %s%s + jamaat: | + + 🕌 *Jamaat Settings:* + Status: %s + • Fajr: %s + • Dhuhr: %s + • Asr: %s + • Maghrib: %s + • Isha: %s + type: + private: "Private" + group: "Group" + enabled: "✅ Enabled" + disabled: "❌ Disabled" + reply: start: "✍️ Type your reply below (or /cancel to stop)" success: "✅ Reply sent" @@ -90,13 +138,13 @@ announce: stats: | 📊 Bot Stats: - - • Users: %d - • Subscribed: %d - • Unsubscribed: %d + + • Users: %d + • Subscribed: %d + • Unsubscribed: %d 🌍 Languages used: - + %s cancel: "✅ Process cancelled" diff --git a/serverless/dispatcher/internal/handler/languages/es.yaml b/serverless/dispatcher/internal/handler/languages/es.yaml index f1a6b19..d9865b7 100644 --- a/serverless/dispatcher/internal/handler/languages/es.yaml +++ b/serverless/dispatcher/internal/handler/languages/es.yaml @@ -42,29 +42,48 @@ language: start: "🌐 Elige tu idioma preferido" success: "✅ Idioma cambiado a *%s*" -subscription_success: "✅ Te has suscrito a los recordatorios de oración" -unsubscription_success: "✅ Te has dado de baja de los recordatorios de oración" +remind_menu: + title_enabled: "⚙️ Recordatorios - Habilitado ✅" + title_disabled: "⚙️ Recordatorios - Deshabilitado 🔕" + enable: "🔔 Habilitar" + disable: "🔕 Deshabilitar" + tomorrow: "🌅 Vista Previa de Mañana" + soon: "⏰ Alerta de Oración" + jamaat_settings: "🕌 Horarios de Jamaat" + close: "❌ Cerrar" + +remind_edit: + title_tomorrow: "🌅 Vista Previa de Mañana\n⏰ Enviar horarios de mañana antes de medianoche" + title_soon: "⏰ Alerta de Oración\n⏱ Recordar antes de cada oración" + +jamaat_menu: + title_enabled: "🕌 Horarios de Jamaat - Habilitado ✅\n(Solo Grupos)" + title_disabled: "🕌 Horarios de Jamaat - Deshabilitado 🔕\n(Solo Grupos)" + enable: "🔔 Habilitar" + disable: "🔕 Deshabilitar" + +jamaat_edit: + title: "🕌 Horario de Jamaat %s\n⏱ Tiempo después del Adhan" help: | ¿Necesitas ayuda? 😊 Aquí está lo que puedo hacer: 👇 - + *Oraciones:* - ⏰ /today – Horarios de hoy - 📅 /date – Horarios de un día específico - ⏳ /next – ¿Cuándo es la próxima oración? - 📨 /remind – Configura un recordatorio antes de la oración - 🔔 /subscribe – Recibir recordatorios diarios - 🔕 /unsubscribe – Detener recordatorios diarios + ⏰ /today – Horarios de hoy + 🌅 /tomorrow – Horarios de mañana + 📅 /date – Horarios de un día específico + ⏳ /next – ¿Cuándo es la próxima oración? + 📨 /remind – Configurar recordatorios *Configuraciones:* - 🌐 /language – Cambiar idioma + 🌐 /language – Cambiar idioma *Soporte:* - 📖 /help – Mostrar esta guía - 🐞 /bug – Reportar un problema + 📖 /help – Mostrar esta guía + 🐞 /bug – Reportar un problema 💬 /feedback – Enviar retroalimentación feedback: @@ -76,10 +95,39 @@ bug: success: "✅ ¡Gracias! Tu reporte ha sido recibido" help_admin: | + 📊 /info – Ver información y configuración del chat 📩 /reply – Responder a los usuarios - 📊 /stats – Estadísticas del bot + 📈 /stats – Estadísticas del bot 📢 /announce – Enviar un mensaje a todos +info: + default: | + 📊 *Información del Chat* + + 🆔 *ID del Chat:* `%s` + 💬 *Tipo:* %s + 🌐 *Idioma:* %s + 📍 *Estado:* %s + + ⏰ *Configuración de Recordatorios:* + Estado: %s + • 🌅 Vista Previa de Mañana: %s + • ⏰ Alerta de Oración: %s%s + jamaat: | + + 🕌 *Configuración de Jamaat:* + Estado: %s + • Fajr: %s + • Dhuhr: %s + • Asr: %s + • Maghrib: %s + • Isha: %s + type: + private: "Privado" + group: "Grupo" + enabled: "✅ Habilitado" + disabled: "❌ Deshabilitado" + reply: start: "✍️ Escribe tu respuesta abajo (o escribe /cancel para cancelar)" success: "✅ Respuesta enviada" @@ -90,13 +138,13 @@ announce: stats: | 📊 Estadísticas del bot: - - • Usuarios: %d - • Suscritos: %d - • No suscritos: %d + + • Usuarios: %d + • Suscritos: %d + • No suscritos: %d 🌍 Idiomas usados: - + %s cancel: "✅ Proceso cancelado" diff --git a/serverless/dispatcher/internal/handler/languages/fr.yaml b/serverless/dispatcher/internal/handler/languages/fr.yaml index b99e64b..f0c4e51 100644 --- a/serverless/dispatcher/internal/handler/languages/fr.yaml +++ b/serverless/dispatcher/internal/handler/languages/fr.yaml @@ -42,44 +42,92 @@ language: start: "🌐 Choisissez votre langue préférée" success: "✅ Langue changée en *%s*" -subscription_success: "✅ Vous êtes maintenant abonné aux rappels de prière" -unsubscription_success: "✅ Vous vous êtes désabonné des rappels de prière" +remind_menu: + title_enabled: "⚙️ Rappels - Activé ✅" + title_disabled: "⚙️ Rappels - Désactivé 🔕" + enable: "🔔 Activer" + disable: "🔕 Désactiver" + tomorrow: "🌅 Aperçu de Demain" + soon: "⏰ Alerte de Prière" + jamaat_settings: "🕌 Horaires Jamaat" + close: "❌ Fermer" + +remind_edit: + title_tomorrow: "🌅 Aperçu de Demain\n⏰ Envoyer les horaires de demain avant minuit" + title_soon: "⏰ Alerte de Prière\n⏱ Rappel avant chaque prière" + +jamaat_menu: + title_enabled: "🕌 Horaires Jamaat - Activé ✅\n(Groupes Seulement)" + title_disabled: "🕌 Horaires Jamaat - Désactivé 🔕\n(Groupes Seulement)" + enable: "🔔 Activer" + disable: "🔕 Désactiver" + +jamaat_edit: + title: "🕌 Horaire Jamaat %s\n⏱ Temps après l'Adhan" help: | - Besoin d'aide ? 😊 Voici ce que je peux faire : 👇 - + Besoin d'aide ? 😊 Voici ce que je peux faire : 👇 + *Prières:* - ⏰ /today – Horaires d'aujourd'hui - 📅 /date – Horaires pour une date choisie - ⏳ /next – Quand est la prochaine prière ? - 📨 /remind – Définir un rappel avant la prière - 🔔 /subscribe – Recevoir des rappels quotidiens - 🔕 /unsubscribe – Arrêter les rappels quotidiens + ⏰ /today – Horaires d'aujourd'hui + 🌅 /tomorrow – Horaires de demain + 📅 /date – Horaires pour une date choisie + ⏳ /next – Quand est la prochaine prière ? + 📨 /remind – Configurer les rappels *Paramètres:* - 🌐 /language – Changer de langue + 🌐 /language – Changer de langue *Support:* - 📖 /help – Afficher ce guide - 🐞 /bug – Signaler un problème + 📖 /help – Afficher ce guide + 🐞 /bug – Signaler un problème 💬 /feedback – Envoyer un retour feedback: - start: "💬 Partagez vos commentaires ! (ou tapez /cancel pour arrêter)" - success: "✅ Merci ! Votre commentaire a été envoyé" + start: "💬 Partagez vos commentaires ! (ou tapez /cancel pour arrêter)" + success: "✅ Merci ! Votre commentaire a été envoyé" bug: start: "🐞 Décrivez le bug rencontré (ou tapez /cancel pour arrêter)" - success: "✅ Merci ! Votre rapport a été reçu" + success: "✅ Merci ! Votre rapport a été reçu" help_admin: | + 📊 /info – Voir les infos et paramètres du chat 📩 /reply – Répondre aux utilisateurs - 📊 /stats – Statistiques du bot + 📈 /stats – Statistiques du bot 📢 /announce – Envoyer un message à tout le monde +info: + default: | + 📊 *Informations du Chat* + + 🆔 *ID du Chat:* `%s` + 💬 *Type:* %s + 🌐 *Langue:* %s + 📍 *État:* %s + + ⏰ *Paramètres de Rappel:* + Statut: %s + • 🌅 Aperçu de Demain: %s + • ⏰ Alerte de Prière: %s%s + jamaat: | + + 🕌 *Paramètres Jamaat:* + Statut: %s + • Fajr: %s + • Dhuhr: %s + • Asr: %s + • Maghrib: %s + • Icha: %s + type: + private: "Privé" + group: "Groupe" + enabled: "✅ Activé" + disabled: "❌ Désactivé" + reply: start: "✍️ Tapez votre réponse ci-dessous (/cancel pour annuler)" success: "✅ Réponse envoyée" @@ -91,9 +139,9 @@ announce: stats: | 📊 Statistiques du bot: - • Utilisateurs: %d - • Abonnés: %d - • Désabonnés: %d + • Utilisateurs: %d + • Abonnés: %d + • Désabonnés: %d 🌍 Langues utilisées: diff --git a/serverless/dispatcher/internal/handler/languages/ru.yaml b/serverless/dispatcher/internal/handler/languages/ru.yaml index f69a49b..24563ea 100644 --- a/serverless/dispatcher/internal/handler/languages/ru.yaml +++ b/serverless/dispatcher/internal/handler/languages/ru.yaml @@ -42,29 +42,48 @@ language: start: "🌐 Выберите предпочтительный язык" success: "✅ Язык изменён на *%s*" -subscription_success: "✅ Вы подписались на напоминания о молитвах" -unsubscription_success: "✅ Вы отписались от напоминаний о молитвах" +remind_menu: + title_enabled: "⚙️ Напоминания - Включено ✅" + title_disabled: "⚙️ Напоминания - Выключено 🔕" + enable: "🔔 Включить" + disable: "🔕 Выключить" + tomorrow: "🌅 Предпросмотр Завтра" + soon: "⏰ Напоминание о Молитве" + jamaat_settings: "🕌 Время Джамаата" + close: "❌ Закрыть" + +remind_edit: + title_tomorrow: "🌅 Предпросмотр Завтра\n⏰ Отправить время завтра до полуночи" + title_soon: "⏰ Напоминание о Молитве\n⏱ Напомнить перед каждой молитвой" + +jamaat_menu: + title_enabled: "🕌 Время Джамаата - Включено ✅\n(Только Группы)" + title_disabled: "🕌 Время Джамаата - Выключено 🔕\n(Только Группы)" + enable: "🔔 Включить" + disable: "🔕 Выключить" + +jamaat_edit: + title: "🕌 Время Джамаата %s\n⏱ Время после Азана" help: | Нужна помощь? 😊 Вот что я умею: 👇 - + *Молитвы:* - ⏰ /today – Время молитв на сегодня - 📅 /date – Время молитв на выбранный день - ⏳ /next – Когда следующая молитва? - 📨 /remind – Установить напоминание - 🔔 /subscribe – Подписаться на ежедневные напоминания - 🔕 /unsubscribe – Отключить напоминания + ⏰ /today – Время молитв на сегодня + 🌅 /tomorrow – Время молитв на завтра + 📅 /date – Время молитв на выбранный день + ⏳ /next – Когда следующая молитва? + 📨 /remind – Настроить напоминания *Настройки:* - 🌐 /language – Изменить язык + 🌐 /language – Изменить язык *Поддержка:* - 📖 /help – Показать эту инструкцию - 🐞 /bug – Сообщить о проблеме + 📖 /help – Показать эту инструкцию + 🐞 /bug – Сообщить о проблеме 💬 /feedback – Оставить отзыв feedback: @@ -76,10 +95,39 @@ bug: success: "✅ Спасибо! Ваш отчет получен" help_admin: | + 📊 /info – Просмотр информации и настроек чата 📩 /reply – Ответить пользователю - 📊 /stats – Статистика использования бота + 📈 /stats – Статистика использования бота 📢 /announce – Отправить сообщение всем +info: + default: | + 📊 *Информация о Чате* + + 🆔 *ID Чата:* `%s` + 💬 *Тип:* %s + 🌐 *Язык:* %s + 📍 *Состояние:* %s + + ⏰ *Настройки Напоминаний:* + Статус: %s + • 🌅 Предпросмотр Завтра: %s + • ⏰ Напоминание о Молитве: %s%s + jamaat: | + + 🕌 *Настройки Джамаата:* + Статус: %s + • Фаджр: %s + • Зухр: %s + • Аср: %s + • Магриб: %s + • Иша: %s + type: + private: "Личный" + group: "Группа" + enabled: "✅ Включено" + disabled: "❌ Выключено" + reply: start: "✍️ Напишите ваш ответ ниже (или /cancel для отмены)" success: "✅ Ответ отправлен" @@ -91,9 +139,9 @@ announce: stats: | 📊 Статистика бота: - • Пользователей: %d - • Подписчиков: %d - • Отписавшихся: %d + • Пользователей: %d + • Подписчиков: %d + • Отписавшихся: %d 🌍 Используемые языки: diff --git a/serverless/dispatcher/internal/handler/languages/tr.yaml b/serverless/dispatcher/internal/handler/languages/tr.yaml index 07f94a3..783f40b 100644 --- a/serverless/dispatcher/internal/handler/languages/tr.yaml +++ b/serverless/dispatcher/internal/handler/languages/tr.yaml @@ -42,29 +42,48 @@ language: start: "🌐 Halaýan diliňizi saýlaň" success: "✅ Dil *%s* diýip üýtgedildi" -subscription_success: "✅ Namaz duýduryşlaryna abuna boldyňyz" -unsubscription_success: "✅ Namaz duýduryşlaryna abuna bolmagyňyz bes edildi" +remind_menu: + title_enabled: "⚙️ Duýduryşlar - Açyk ✅" + title_disabled: "⚙️ Duýduryşlar - Ýapyk 🔕" + enable: "🔔 Açmak" + disable: "🔕 Ýapmak" + tomorrow: "🌅 Ertir Syn" + soon: "⏰ Namaz Duýduryşy" + jamaat_settings: "🕌 Jemgyýet Wagty" + close: "❌ Ýapmak" + +remind_edit: + title_tomorrow: "🌅 Ertir Syn\n⏰ Ertirki namaz wagtlaryny gijäniň ýary gijeden öň iberiň" + title_soon: "⏰ Namaz Duýduryşy\n⏱ Her namaz wagtyndan öň ýatlatma" + +jamaat_menu: + title_enabled: "🕌 Jemgyýet Wagty - Açyk ✅\n(Diňe Toparlar)" + title_disabled: "🕌 Jemgyýet Wagty - Ýapyk 🔕\n(Diňe Toparlar)" + enable: "🔔 Açmak" + disable: "🔕 Ýapmak" + +jamaat_edit: + title: "🕌 %s Jemgyýet Wagty\n⏱ Ezandan soňky wagt" help: | Kömek gerekmi? 😊 Men şulary edip bilýärin: 👇 - + *Namazlar:* - ⏰ /today – Şu günki wagtlar - 📅 /date – Saýlanan gün üçin wagtlar - ⏳ /next – Indiki namaz haçan? - 📨 /remind – Namazdan öň duýduryş goýmak - 🔔 /subscribe – Her gün duýduryş almak - 🔕 /unsubscribe – Duýduryşlary bes etmek + ⏰ /today – Şu günki wagtlar + 🌅 /tomorrow – Ertirki wagtlar + 📅 /date – Saýlanan gün üçin wagtlar + ⏳ /next – Indiki namaz haçan? + 📨 /remind – Duýduryşlary sazlamak *Sazlamalar:* - 🌐 /language – Dili üýtgetmek + 🌐 /language – Dili üýtgetmek *Goldaw:* - 📖 /help – Bu görkezmäni görkez - 🐞 /bug – Nädogrulygy habar ber + 📖 /help – Bu görkezmäni görkez + 🐞 /bug – Nädogrulygy habar ber 💬 /feedback – Pikiriňizi iberiň feedback: @@ -76,10 +95,39 @@ bug: success: "✅ Sag boluň! Nädogrulyk hasaba alyndy" help_admin: | + 📊 /info – Söhbetdeşlik maglumatlary we sazlamalary görmek 📩 /reply – Ulanyjylara jogap bermek - 📊 /stats – Baryň statistikasyny görmek + 📈 /stats – Baryň statistikasyny görmek 📢 /announce – Ähli ulanyjylara habar ugratmak +info: + default: | + 📊 *Söhbetdeşlik Maglumatlary* + + 🆔 *Söhbetdeşlik ID:* `%s` + 💬 *Görnüşi:* %s + 🌐 *Dil:* %s + 📍 *Ýagdaý:* %s + + ⏰ *Duýduryş Sazlamalary:* + Ýagdaý: %s + • 🌅 Ertir Syn: %s + • ⏰ Namaz Duýduryşy: %s%s + jamaat: | + + 🕌 *Jemgyýet Sazlamalary:* + Ýagdaý: %s + • Fäjir: %s + • Gündiz: %s + • Ikindi: %s + • Agşam: %s + • Gije: %s + type: + private: "Şahsy" + group: "Topar" + enabled: "✅ Açyk" + disabled: "❌ Ýapyk" + reply: start: "✍️ Jogabyňyzy aşakda ýazyň (ýa-da /cancel ýazyp duruzuň)" success: "✅ Jogap ugradyldy" @@ -91,9 +139,9 @@ announce: stats: | 📊 Bot statistikalary: - • Ulanyjylar: %d - • Abuna bolanlar: %d - • Abuna bolmadyklar: %d + • Ulanyjylar: %d + • Abuna bolanlar: %d + • Abuna bolmadyklar: %d 🌍 Ulanylýan diller: diff --git a/serverless/dispatcher/internal/handler/languages/tt.yaml b/serverless/dispatcher/internal/handler/languages/tt.yaml index d58126a..c99947a 100644 --- a/serverless/dispatcher/internal/handler/languages/tt.yaml +++ b/serverless/dispatcher/internal/handler/languages/tt.yaml @@ -42,29 +42,48 @@ language: start: "🌐 Тел сайлагыз" success: "✅ Тел *%s* итеп үзгәртелде" -subscription_success: "✅ Намаз хәтерләтүләренә язылдыгыз" -unsubscription_success: "✅ Намаз хәтерләтүләреннән язылдыгыз" +remind_menu: + title_enabled: "⚙️ Хәтерләтүләр - Кабызылган ✅" + title_disabled: "⚙️ Хәтерләтүләр - Сүндерелгән 🔕" + enable: "🔔 Кабызу" + disable: "🔕 Сүндерү" + tomorrow: "🌅 Иртәгә Карау" + soon: "⏰ Намаз Искәрмәсе" + jamaat_settings: "🕌 Җәмәгать Вакыты" + close: "❌ Ябу" + +remind_edit: + title_tomorrow: "🌅 Иртәгә Карау\n⏰ Иртәгә намаз вакытларын төн уртасында җибәрергә" + title_soon: "⏰ Намаз Искәрмәсе\n⏱ Һәр намазга кадәр хәтерләтергә" + +jamaat_menu: + title_enabled: "🕌 Җәмәгать Вакыты - Кабызылган ✅\n(Төркемнәр Өчен)" + title_disabled: "🕌 Җәмәгать Вакыты - Сүндерелгән 🔕\n(Төркемнәр Өчен)" + enable: "🔔 Кабызу" + disable: "🔕 Сүндерү" + +jamaat_edit: + title: "🕌 %s Җәмәгать Вакыты\n⏱ Азаннан соң вакыт" help: | Ярдәм кирәкме? 😊 Менә нәрсәләр эшли алам: 👇 - + *Намазлар:* - ⏰ /today – Бүгенге вакытлар - 📅 /date – Күрсәтелгән көн өчен вакытлар - ⏳ /next – Киләсе намаз кайчан? - 📨 /remind – Намаз алдыннан хәтерләтү куярга - 🔔 /subscribe – Көндәлек хәтерләтүләрне кабул итү - 🔕 /unsubscribe – Хәтерләтүләрне сүндерү + ⏰ /today – Бүгенге вакытлар + 🌅 /tomorrow – Иртәгәге вакытлар + 📅 /date – Күрсәтелгән көн өчен вакытлар + ⏳ /next – Киләсе намаз кайчан? + 📨 /remind – Хәтерләтүләрне көйләргә *Көйләүләр:* - 🌐 /language – Телне үзгәртү + 🌐 /language – Телне үзгәртү *Ярдәм:* - 📖 /help – Бу кулланманы күрсәтү - 🐞 /bug – Хата турында хәбәр итү + 📖 /help – Бу кулланманы күрсәтү + 🐞 /bug – Хата турында хәбәр итү 💬 /feedback – Фикер җибәрү feedback: @@ -76,10 +95,39 @@ bug: success: "✅ Рәхмәт! Хатагыз кабул ителде" help_admin: | + 📊 /info – Чат мәгълүматы һәм көйләүләр карау 📩 /reply – Кулланучыларга җавап бирү - 📊 /stats – Бот куллану статистикасы + 📈 /stats – Бот куллану статистикасы 📢 /announce – Бөтен кулланучыларга хәбәр җибәрү +info: + default: | + 📊 *Чат Мәгълүматы* + + 🆔 *Чат ID:* `%s` + 💬 *Төр:* %s + 🌐 *Тел:* %s + 📍 *Халәт:* %s + + ⏰ *Хәтерләтү Көйләүләре:* + Халәт: %s + • 🌅 Иртәгә Карау: %s + • ⏰ Намаз Искәрмәсе: %s%s + jamaat: | + + 🕌 *Җәмәгать Көйләүләре:* + Халәт: %s + • Фәҗр: %s + • Зөһр: %s + • Әср: %s + • Мәгъриб: %s + • Иша: %s + type: + private: "Шәхси" + group: "Төркем" + enabled: "✅ Кабызылган" + disabled: "❌ Сүндерелгән" + reply: start: "✍️ Җавапны түбәндә языгыз (яки /cancel язарга)" success: "✅ Җавап җибәрелде" @@ -91,9 +139,9 @@ announce: stats: | 📊 Бот статистикасы: - • Кулланучылар саны: %d - • Язылганнар саны: %d - • Язылудан чыкканнар саны: %d + • Кулланучылар саны: %d + • Язылганнар саны: %d + • Язылудан чыкканнар саны: %d 🌍 Кулланылган телләр: diff --git a/serverless/dispatcher/internal/handler/languages/uz.yaml b/serverless/dispatcher/internal/handler/languages/uz.yaml index a451df7..9e8ed7b 100644 --- a/serverless/dispatcher/internal/handler/languages/uz.yaml +++ b/serverless/dispatcher/internal/handler/languages/uz.yaml @@ -42,29 +42,49 @@ language: start: "🌐 O‘zingizga qulay tilni tanlang" success: "✅ Til *%s* ga o‘zgartirildi" -subscription_success: "✅ Namoz eslatmalariga obuna bo‘ldingiz" -unsubscription_success: "✅ Namoz eslatmalaridan obuna bekor qilindi" + +remind_menu: + title_enabled: "⚙️ Eslatmalar - Yoqilgan ✅" + title_disabled: "⚙️ Eslatmalar - O'chirilgan 🔕" + enable: "🔔 Yoqish" + disable: "🔕 O'chirish" + tomorrow: "🌅 Ertangi Ko'rinish" + soon: "⏰ Namoz Eslatmasi" + jamaat_settings: "🕌 Jamoat Vaqtlari" + close: "❌ Yopish" + +remind_edit: + title_tomorrow: "🌅 Ertangi Ko'rinish\n⏰ Ertangi namoz vaqtlarini yarim tundan oldin yuborish" + title_soon: "⏰ Namoz Eslatmasi\n⏱ Har bir namoz vaqtidan oldin eslatma" + +jamaat_menu: + title_enabled: "🕌 Jamoat Vaqtlari - Yoqilgan ✅\n(Faqat Guruhlar)" + title_disabled: "🕌 Jamoat Vaqtlari - O'chirilgan 🔕\n(Faqat Guruhlar)" + enable: "🔔 Yoqish" + disable: "🔕 O'chirish" + +jamaat_edit: + title: "🕌 %s Jamoat Vaqti\n⏱ Azon vaqtidan keyingi vaqt" help: | Yordam kerakmi? 😊 Men quyidagilarni bajara olaman: 👇 - + *Namozlar:* - ⏰ /today – Bugungi vaqtlar - 📅 /date – Tanlangan kun uchun vaqtlar - ⏳ /next – Keyingi namoz qachon? - 📨 /remind – Namozdan oldin eslatma o‘rnatish - 🔔 /subscribe – Har kungi eslatmalarni olish - 🔕 /unsubscribe – Eslatmalarni to‘xtatish + ⏰ /today – Bugungi vaqtlar + 🌅 /tomorrow – Ertangi vaqtlar + 📅 /date – Tanlangan kun uchun vaqtlar + ⏳ /next – Keyingi namoz qachon? + 📨 /remind – Eslatmalarni sozlash *Sozlamalar:* - 🌐 /language – Tilni o‘zgartirish + 🌐 /language – Tilni o'zgartirish *Yordam:* - 📖 /help – Ushbu qo‘llanmani ko‘rsatish - 🐞 /bug – Xatolik haqida xabar berish + 📖 /help – Ushbu qo'llanmani ko'rsatish + 🐞 /bug – Xatolik haqida xabar berish 💬 /feedback – Fikr-mulohaza yuborish feedback: @@ -76,10 +96,39 @@ bug: success: "✅ Rahmat! Xatolik qabul qilindi" help_admin: | + 📊 /info – Chat ma'lumotlari va sozlamalarini ko'rish 📩 /reply – Foydalanuvchilarga javob berish - 📊 /stats – Bot statistikasi + 📈 /stats – Bot statistikasi 📢 /announce – Hamma foydalanuvchilarga xabar yuborish +info: + default: | + 📊 *Chat Ma'lumotlari* + + 🆔 *Chat ID:* `%s` + 💬 *Turi:* %s + 🌐 *Til:* %s + 📍 *Holat:* %s + + ⏰ *Eslatma Sozlamalari:* + Holat: %s + • 🌅 Ertangi Ko'rinish: %s + • ⏰ Namoz Eslatmasi: %s%s + jamaat: | + + 🕌 *Jamoat Sozlamalari:* + Holat: %s + • Bomdod: %s + • Peshin: %s + • Asr: %s + • Shom: %s + • Xufton: %s + type: + private: "Shaxsiy" + group: "Guruh" + enabled: "✅ Yoqilgan" + disabled: "❌ O'chirilgan" + reply: start: "✍️ Javobni quyida yozing (/cancel yozib to‘xtatish mumkin)" success: "✅ Javob yuborildi" @@ -91,9 +140,9 @@ announce: stats: | 📊 Bot statistikasi: - • Foydalanuvchilar: %d - • Obuna bo‘lganlar: %d - • Obunani bekor qilganlar: %d + • Foydalanuvchilar: %d + • Obuna bo‘lganlar: %d + • Obunani bekor qilganlar: %d 🌍 Foydalanilayotgan tillar: diff --git a/serverless/dispatcher/internal/handler/query.go b/serverless/dispatcher/internal/handler/query.go index 899c48f..bb9940e 100644 --- a/serverless/dispatcher/internal/handler/query.go +++ b/serverless/dispatcher/internal/handler/query.go @@ -22,14 +22,45 @@ const ( dayQuery query = "date:day|" monthQuery query = "date:month|" - remindQuery query = "remind|" languageQuery query = "language|" + + remindMenuQuery query = "remind:menu|" + remindToggleQuery query = "remind:toggle|" + remindEditQuery query = "remind:edit:" + remindAdjustQuery query = "remind:adjust:" + remindJamaatMenuQuery query = "remind:jamaat:menu|" + remindJamaatToggleQuery query = "remind:jamaat:toggle|" + remindJamaatEditQuery query = "remind:jamaat:edit:" + remindJamaatAdjustQuery query = "remind:jamaat:adjust:" + remindBackQuery query = "remind:back:" + remindCloseQuery query = "remind:close|" +) + +const ( + TomorrowMinOffset = 0 * time.Minute + TomorrowMaxOffset = 6 * time.Hour + + SoonMinOffset = 5 * time.Minute + SoonMaxOffset = 1 * time.Hour + + JamaatMinDelay = 5 * time.Minute + JamaatMaxDelay = 6 * time.Hour ) func (q query) String() string { return string(q) } +func clampOffset(value, min, max time.Duration) time.Duration { + if value < min { + return min + } + if value > max { + return max + } + return value +} + func (h *Handler) emptyQuery(ctx context.Context, b *bot.Bot, update *models.Update) error { _, _ = b.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{ CallbackQueryID: update.CallbackQuery.ID, @@ -45,7 +76,7 @@ func (h *Handler) monthQuery(ctx context.Context, b *bot.Bot, update *models.Upd ChatID: chat.ChatID, MessageID: update.CallbackQuery.Message.Message.ID, Text: h.lp.GetText(chat.LanguageCode).PrayerDate, - ReplyMarkup: h.daysKeyboard(h.nowUTC(chat.BotID), month), + ReplyMarkup: h.daysKeyboard(h.nowDateUTC(chat.BotID), month), }) if err != nil { log.Error("monthQuery: edit message", log.Err(err), log.BotID(chat.BotID), log.ChatID(chat.ChatID)) @@ -67,7 +98,7 @@ func (h *Handler) dayQuery(ctx context.Context, b *bot.Bot, update *models.Updat parts := strings.Split(update.CallbackQuery.Data, dataSplitterQuery) month, _ := strconv.Atoi(parts[1]) day, _ := strconv.Atoi(parts[2]) - date := domain.DateUTC(day, time.Month(month), h.nowUTC(chat.BotID).Year()) + date := domain.DateUTC(day, time.Month(month), h.nowDateUTC(chat.BotID).Year()) prayerDay, err := h.db.GetPrayerDay(ctx, chat.BotID, date) if err != nil { @@ -95,38 +126,39 @@ func (h *Handler) dayQuery(ctx context.Context, b *bot.Bot, update *models.Updat return nil } -func (h *Handler) remindQuery(ctx context.Context, b *bot.Bot, update *models.Update) error { +func (h *Handler) languageQuery(ctx context.Context, b *bot.Bot, update *models.Update) error { chat := getContextChat(ctx) - reminderOffset, _ := strconv.Atoi(strings.TrimPrefix(update.CallbackQuery.Data, remindQuery.String())) - - err := h.db.SetReminderOffset(ctx, chat.BotID, chat.ChatID, int32(reminderOffset)) - if err != nil { - log.Error("remindQuery: set remind offset", log.Err(err), log.BotID(chat.BotID), log.ChatID(chat.ChatID)) - return domain.ErrInternal + languageCode := strings.TrimPrefix(update.CallbackQuery.Data, languageQuery.String()) + if !h.lp.IsSupportedCode(languageCode) { + log.Error("languageQuery: unsupported language code", + log.BotID(chat.BotID), + log.ChatID(chat.ChatID), + log.String("language_code", languageCode), + ) + return fmt.Errorf("languageQuery: unsupported language code: %s", languageCode) } - err = h.db.SetSubscribed(ctx, chat.BotID, chat.ChatID, true) + err := h.db.SetLanguageCode(ctx, chat.BotID, chat.ChatID, languageCode) if err != nil { - log.Error("remindQuery: set subscribed", log.Err(err), log.BotID(chat.BotID), log.ChatID(chat.ChatID)) + log.Error("languageQuery: set language code", log.Err(err), log.BotID(chat.BotID), log.ChatID(chat.ChatID)) return domain.ErrInternal } - message := fmt.Sprintf(h.lp.GetText(chat.LanguageCode).Remind.Success, reminderOffset) _, err = b.EditMessageText(ctx, &bot.EditMessageTextParams{ ChatID: chat.ChatID, MessageID: update.CallbackQuery.Message.Message.ID, - Text: message, + Text: fmt.Sprintf(h.lp.GetText(languageCode).Language.Success, languageCode), ParseMode: models.ParseModeMarkdown, }) if err != nil { - log.Error("remindQuery: edit message", log.Err(err), log.BotID(chat.BotID), log.ChatID(chat.ChatID)) + log.Error("languageQuery: send message", log.Err(err), log.BotID(chat.BotID), log.ChatID(chat.ChatID)) return domain.ErrInternal } _, err = b.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: update.CallbackQuery.ID}) if err != nil { - log.Error("remindQuery: answer query query", log.Err(err), log.BotID(chat.BotID), log.ChatID(chat.ChatID)) + log.Error("languageQuery: answer query query", log.Err(err), log.BotID(chat.BotID), log.ChatID(chat.ChatID)) return domain.ErrInternal } @@ -134,42 +166,367 @@ func (h *Handler) remindQuery(ctx context.Context, b *bot.Bot, update *models.Up return nil } -func (h *Handler) languageQuery(ctx context.Context, b *bot.Bot, update *models.Update) error { +func (h *Handler) remindMenuQuery(ctx context.Context, b *bot.Bot, update *models.Update) error { chat := getContextChat(ctx) + text := h.lp.GetText(chat.LanguageCode) - languageCode := strings.TrimPrefix(update.CallbackQuery.Data, languageQuery.String()) - if !h.lp.IsSupportedCode(languageCode) { - log.Error("languageQuery: unsupported language code", + var messageText string + if chat.Subscribed { + messageText = text.RemindMenu.TitleEnabled + } else { + messageText = text.RemindMenu.TitleDisabled + } + + _, err := b.EditMessageText(ctx, &bot.EditMessageTextParams{ + ChatID: chat.ChatID, + MessageID: update.CallbackQuery.Message.Message.ID, + Text: messageText, + ReplyMarkup: h.remindMenuKeyboard(chat), + }) + if err != nil { + log.Error("remindMenuQuery: edit message", log.Err(err), log.BotID(chat.BotID), log.ChatID(chat.ChatID)) + return domain.ErrInternal + } + + _, err = b.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: update.CallbackQuery.ID}) + if err != nil { + log.Error("remindMenuQuery: answer callback", log.Err(err), log.BotID(chat.BotID), log.ChatID(chat.ChatID)) + return domain.ErrInternal + } + + return nil +} + +func (h *Handler) remindToggleQuery(ctx context.Context, b *bot.Bot, update *models.Update) error { + chat := getContextChat(ctx) + + newSubscribed := !chat.Subscribed + err := h.db.SetSubscribed(ctx, chat.BotID, chat.ChatID, newSubscribed) + if err != nil { + log.Error("remindToggleQuery: set subscribed", log.Err(err), log.BotID(chat.BotID), log.ChatID(chat.ChatID)) + return domain.ErrInternal + } + + chat.Subscribed = newSubscribed + ctx = setContextChat(ctx, chat) + + h.remindMenuQuery(ctx, b, update) + return nil +} + +func (h *Handler) remindEditQuery(ctx context.Context, b *bot.Bot, update *models.Update) error { + chat := getContextChat(ctx) + text := h.lp.GetText(chat.LanguageCode) + + parts := strings.Split(update.CallbackQuery.Data, ":") + if len(parts) < 3 { + log.Error("remindEditQuery: invalid callback data", log.BotID(chat.BotID), log.ChatID(chat.ChatID)) + return domain.ErrInternal + } + + reminderType := domain.ReminderType(strings.TrimSuffix(parts[2], "|")) + + var messageText string + var offset time.Duration + switch reminderType { + case domain.ReminderTypeTomorrow: + offset = chat.Reminder.Tomorrow.Offset.Duration() + messageText = fmt.Sprintf("%s - %s", text.RemindEdit.TitleTomorrow, domain.FormatDuration(offset)) + case domain.ReminderTypeSoon: + offset = chat.Reminder.Soon.Offset.Duration() + messageText = fmt.Sprintf("%s - %s", text.RemindEdit.TitleSoon, domain.FormatDuration(offset)) + default: + log.Error("remindEditQuery: unknown reminder type", log.BotID(chat.BotID), log.ChatID(chat.ChatID), - log.String("language_code", languageCode), + log.String("type", reminderType.String()), ) - return fmt.Errorf("languageQuery: unsupported language code: %s", languageCode) + return domain.ErrInternal } - err := h.db.SetLanguageCode(ctx, chat.BotID, chat.ChatID, languageCode) + _, err := b.EditMessageText(ctx, &bot.EditMessageTextParams{ + ChatID: chat.ChatID, + MessageID: update.CallbackQuery.Message.Message.ID, + Text: messageText, + ReplyMarkup: h.remindEditKeyboard(reminderType, chat.LanguageCode), + }) if err != nil { - log.Error("languageQuery: set language code", log.Err(err), log.BotID(chat.BotID), log.ChatID(chat.ChatID)) + log.Error("remindEditQuery: edit message", log.Err(err), log.BotID(chat.BotID), log.ChatID(chat.ChatID)) return domain.ErrInternal } - _, err = b.EditMessageText(ctx, &bot.EditMessageTextParams{ + _, err = b.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: update.CallbackQuery.ID}) + if err != nil { + log.Error("remindEditQuery: answer callback", log.Err(err), log.BotID(chat.BotID), log.ChatID(chat.ChatID)) + return domain.ErrInternal + } + + return nil +} + +func (h *Handler) remindAdjustQuery(ctx context.Context, b *bot.Bot, update *models.Update) error { + chat := getContextChat(ctx) + + parts := strings.Split(update.CallbackQuery.Data, ":") + if len(parts) < 4 { + log.Error("remindAdjustQuery: invalid callback data", log.BotID(chat.BotID), log.ChatID(chat.ChatID)) + return domain.ErrInternal + } + + reminderType := domain.ReminderType(parts[2]) + adjustment := parseAdjustment(strings.TrimSuffix(parts[3], "|")) + + var currentOffset time.Duration + var minOffset, maxOffset time.Duration + + switch reminderType { + case domain.ReminderTypeTomorrow: + currentOffset = chat.Reminder.Tomorrow.Offset.Duration() + minOffset = TomorrowMinOffset + maxOffset = TomorrowMaxOffset + case domain.ReminderTypeSoon: + currentOffset = chat.Reminder.Soon.Offset.Duration() + minOffset = SoonMinOffset + maxOffset = SoonMaxOffset + default: + log.Error("remindAdjustQuery: unknown reminder type", log.BotID(chat.BotID), log.ChatID(chat.ChatID)) + return domain.ErrInternal + } + + newOffset := clampOffset(currentOffset+adjustment, minOffset, maxOffset) + + // If value didn't change (hit limit), just answer callback and return + if newOffset == currentOffset { + _, _ = b.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: update.CallbackQuery.ID}) + return nil + } + + err := h.db.SetReminderOffset(ctx, chat.BotID, chat.ChatID, reminderType, newOffset) + if err != nil { + log.Error("remindAdjustQuery: set reminder offset", log.Err(err), log.BotID(chat.BotID), log.ChatID(chat.ChatID)) + return domain.ErrInternal + } + + switch reminderType { + case domain.ReminderTypeTomorrow: + chat.Reminder.Tomorrow.Offset = domain.Duration(newOffset) + case domain.ReminderTypeSoon: + chat.Reminder.Soon.Offset = domain.Duration(newOffset) + } + + ctx = setContextChat(ctx, chat) + return h.remindEditQuery(ctx, b, update) +} + +func (h *Handler) remindJamaatMenuQuery(ctx context.Context, b *bot.Bot, update *models.Update) error { + chat := getContextChat(ctx) + + if !isChatGroup(chat.ChatID) { + _, _ = b.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: update.CallbackQuery.ID}) + return nil + } + + text := h.lp.GetText(chat.LanguageCode) + + var messageText string + if chat.Reminder.Jamaat.Enabled { + messageText = text.JamaatMenu.TitleEnabled + } else { + messageText = text.JamaatMenu.TitleDisabled + } + + _, err := b.EditMessageText(ctx, &bot.EditMessageTextParams{ + ChatID: chat.ChatID, + MessageID: update.CallbackQuery.Message.Message.ID, + Text: messageText, + ReplyMarkup: h.jammatMenuKeyboard(chat), + }) + if err != nil { + log.Error("remindJamaatMenuQuery: edit message", log.Err(err), log.BotID(chat.BotID), log.ChatID(chat.ChatID)) + return domain.ErrInternal + } + + _, err = b.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: update.CallbackQuery.ID}) + if err != nil { + log.Error("remindJamaatMenuQuery: answer callback", log.Err(err), log.BotID(chat.BotID), log.ChatID(chat.ChatID)) + return domain.ErrInternal + } + + return nil +} + +func (h *Handler) remindJamaatToggleQuery(ctx context.Context, b *bot.Bot, update *models.Update) error { + chat := getContextChat(ctx) + + if !isChatGroup(chat.ChatID) { + _, _ = b.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: update.CallbackQuery.ID}) + return nil + } + + newEnabled := !chat.Reminder.Jamaat.Enabled + err := h.db.SetJamaatEnabled(ctx, chat.BotID, chat.ChatID, newEnabled) + if err != nil { + log.Error("remindJamaatToggleQuery: set jamaat enabled", log.Err(err), log.BotID(chat.BotID), log.ChatID(chat.ChatID)) + return domain.ErrInternal + } + + chat.Reminder.Jamaat.Enabled = newEnabled + ctx = setContextChat(ctx, chat) + return h.remindJamaatMenuQuery(ctx, b, update) +} + +func (h *Handler) remindJamaatEditQuery(ctx context.Context, b *bot.Bot, update *models.Update) error { + chat := getContextChat(ctx) + + if !isChatGroup(chat.ChatID) { + _, _ = b.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: update.CallbackQuery.ID}) + return nil + } + + text := h.lp.GetText(chat.LanguageCode) + + parts := strings.Split(update.CallbackQuery.Data, ":") + if len(parts) < 4 { + log.Error("remindJamaatEditQuery: invalid callback data", log.BotID(chat.BotID), log.ChatID(chat.ChatID)) + return domain.ErrInternal + } + + prayerName := strings.TrimSuffix(parts[3], "|") + prayerID := domain.ParsePrayerID(prayerName) + + delay := chat.Reminder.Jamaat.Delay.GetDelayByPrayerID(prayerID) + messageText := fmt.Sprintf(text.JamaatEdit.Title, text.Prayer[int(prayerID)]) + " - " + domain.FormatDuration(delay) + + _, err := b.EditMessageText(ctx, &bot.EditMessageTextParams{ + ChatID: chat.ChatID, + MessageID: update.CallbackQuery.Message.Message.ID, + Text: messageText, + ReplyMarkup: h.jammatEditKeyboard(prayerID, chat.LanguageCode), + }) + if err != nil { + log.Error("remindJamaatEditQuery: edit message", log.Err(err), log.BotID(chat.BotID), log.ChatID(chat.ChatID)) + return domain.ErrInternal + } + + _, err = b.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: update.CallbackQuery.ID}) + if err != nil { + log.Error("remindJamaatEditQuery: answer callback", log.Err(err), log.BotID(chat.BotID), log.ChatID(chat.ChatID)) + return domain.ErrInternal + } + + return nil +} + +func (h *Handler) remindJamaatAdjustQuery(ctx context.Context, b *bot.Bot, update *models.Update) error { + chat := getContextChat(ctx) + + if !isChatGroup(chat.ChatID) { + _, _ = b.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: update.CallbackQuery.ID}) + return nil + } + + parts := strings.Split(update.CallbackQuery.Data, ":") + if len(parts) < 5 { + log.Error("remindJamaatAdjustQuery: invalid callback data", log.BotID(chat.BotID), log.ChatID(chat.ChatID)) + return domain.ErrInternal + } + + prayerName := parts[3] + prayerID := domain.ParsePrayerID(prayerName) + adjustment := parseAdjustment(strings.TrimSuffix(parts[4], "|")) + + currentDelay := chat.Reminder.Jamaat.Delay.GetDelayByPrayerID(prayerID) + newDelay := clampOffset(currentDelay+adjustment, JamaatMinDelay, JamaatMaxDelay) + + // If value didn't change (hit limit), just answer callback and return + if newDelay == currentDelay { + _, _ = b.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: update.CallbackQuery.ID}) + return nil + } + + err := h.db.SetJamaatDelay(ctx, chat.BotID, chat.ChatID, prayerID, newDelay) + if err != nil { + log.Error("remindJamaatAdjustQuery: set jamaat delay", log.Err(err), log.BotID(chat.BotID), log.ChatID(chat.ChatID)) + return domain.ErrInternal + } + + chat.Reminder.Jamaat.Delay.SetDelayByPrayerID(prayerID, newDelay) + ctx = setContextChat(ctx, chat) + return h.remindJamaatEditQuery(ctx, b, update) +} + +func (h *Handler) remindBackQuery(ctx context.Context, b *bot.Bot, update *models.Update) error { + chat := getContextChat(ctx) + text := h.lp.GetText(chat.LanguageCode) + + parts := strings.Split(update.CallbackQuery.Data, ":") + if len(parts) < 3 { + log.Error("remindBackQuery: invalid callback data", log.BotID(chat.BotID), log.ChatID(chat.ChatID)) + return domain.ErrInternal + } + + destination := strings.TrimSuffix(parts[2], "|") + + var messageText string + var keyboard *models.InlineKeyboardMarkup + + switch destination { + case "menu": + if chat.Subscribed { + messageText = text.RemindMenu.TitleEnabled + } else { + messageText = text.RemindMenu.TitleDisabled + } + keyboard = h.remindMenuKeyboard(chat) + case "jamaat": + if chat.Reminder.Jamaat.Enabled { + messageText = text.JamaatMenu.TitleEnabled + } else { + messageText = text.JamaatMenu.TitleDisabled + } + keyboard = h.jammatMenuKeyboard(chat) + default: + log.Error("remindBackQuery: unknown destination", log.BotID(chat.BotID), log.ChatID(chat.ChatID)) + return domain.ErrInternal + } + + _, err := b.EditMessageText(ctx, &bot.EditMessageTextParams{ + ChatID: chat.ChatID, + MessageID: update.CallbackQuery.Message.Message.ID, + Text: messageText, + ReplyMarkup: keyboard, + }) + if err != nil { + log.Error("remindBackQuery: edit message", log.Err(err), log.BotID(chat.BotID), log.ChatID(chat.ChatID)) + return domain.ErrInternal + } + + _, err = b.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: update.CallbackQuery.ID}) + if err != nil { + log.Error("remindBackQuery: answer callback", log.Err(err), log.BotID(chat.BotID), log.ChatID(chat.ChatID)) + return domain.ErrInternal + } + + return nil +} + +func (h *Handler) remindCloseQuery(ctx context.Context, b *bot.Bot, update *models.Update) error { + chat := getContextChat(ctx) + + _, err := b.DeleteMessage(ctx, &bot.DeleteMessageParams{ ChatID: chat.ChatID, MessageID: update.CallbackQuery.Message.Message.ID, - Text: fmt.Sprintf(h.lp.GetText(languageCode).Language.Success, languageCode), - ParseMode: models.ParseModeMarkdown, }) if err != nil { - log.Error("languageQuery: send message", log.Err(err), log.BotID(chat.BotID), log.ChatID(chat.ChatID)) + log.Error("remindCloseQuery: delete message", log.Err(err), log.BotID(chat.BotID), log.ChatID(chat.ChatID)) return domain.ErrInternal } _, err = b.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: update.CallbackQuery.ID}) if err != nil { - log.Error("languageQuery: answer query query", log.Err(err), log.BotID(chat.BotID), log.ChatID(chat.ChatID)) + log.Error("remindCloseQuery: answer callback", log.Err(err), log.BotID(chat.BotID), log.ChatID(chat.ChatID)) return domain.ErrInternal } - h.resetState(ctx, chat) return nil } diff --git a/serverless/dispatcher/internal/service/db.go b/serverless/dispatcher/internal/service/db.go index b657a40..94ef785 100644 --- a/serverless/dispatcher/internal/service/db.go +++ b/serverless/dispatcher/internal/service/db.go @@ -2,16 +2,17 @@ package service import ( "context" + "encoding/json" "os" "time" "github.com/escalopa/prayer-bot/domain" + "github.com/escalopa/prayer-bot/log" "github.com/ydb-platform/ydb-go-genproto/protos/Ydb" - "github.com/ydb-platform/ydb-go-sdk/v3/table/result" - "github.com/ydb-platform/ydb-go-sdk/v3/table/types" - "github.com/ydb-platform/ydb-go-sdk/v3" "github.com/ydb-platform/ydb-go-sdk/v3/table" + "github.com/ydb-platform/ydb-go-sdk/v3/table/result" + "github.com/ydb-platform/ydb-go-sdk/v3/table/types" yc "github.com/ydb-platform/ydb-go-yc" ) @@ -45,37 +46,42 @@ func NewDB(ctx context.Context) (*DB, error) { return &DB{client: sdk.Table()}, nil } +//revive:disable:argument-limit func (db *DB) CreateChat( ctx context.Context, botID int64, chatID int64, languageCode string, - reminderOffset int32, state string, - jamaat bool, + reminder *domain.Reminder, ) error { + + reminderJSON, err := json.Marshal(reminder) + if err != nil { + log.Error("marshal reminder json", log.Err(err), log.BotID(botID), log.ChatID(chatID)) + return domain.ErrInternal + } + query := ` DECLARE $bot_id AS Int64; DECLARE $chat_id AS Int64; DECLARE $language_code AS Utf8; - DECLARE $reminder_offset AS Int32; DECLARE $state AS Utf8; - DECLARE $jamaat AS Bool; + DECLARE $reminder AS Json; - INSERT INTO chats (bot_id, chat_id, language_code, reminder_offset, state, jamaat, created_at) - VALUES ($bot_id, $chat_id, $language_code, $reminder_offset, $state, $jamaat, CurrentUtcDatetime()); + INSERT INTO chats (bot_id, chat_id, language_code, state, reminder, created_at) + VALUES ($bot_id, $chat_id, $language_code, $state, $reminder, CurrentUtcDatetime()); ` params := table.NewQueryParameters( table.ValueParam("$bot_id", types.Int64Value(botID)), table.ValueParam("$chat_id", types.Int64Value(chatID)), table.ValueParam("$language_code", types.UTF8Value(languageCode)), - table.ValueParam("$reminder_offset", types.Int32Value(reminderOffset)), table.ValueParam("$state", types.UTF8Value(state)), - table.ValueParam("$jamaat", types.BoolValue(jamaat)), + table.ValueParam("$reminder", types.JSONValue(string(reminderJSON))), ) - err := db.client.Do(ctx, func(ctx context.Context, s table.Session) error { + err = db.client.Do(ctx, func(ctx context.Context, s table.Session) error { _, _, err := s.Execute(ctx, writeTx, query, params) return err }) @@ -84,7 +90,8 @@ func (db *DB) CreateChat( if ydb.IsOperationError(err, Ydb.StatusIds_PRECONDITION_FAILED) { // chat already exists return domain.ErrAlreadyExists } - return err + log.Error("create chat", log.Err(err), log.BotID(botID), log.ChatID(chatID)) + return domain.ErrInternal } return nil @@ -95,7 +102,7 @@ func (db *DB) GetChat(ctx context.Context, botID int64, chatID int64) (chat *dom DECLARE $bot_id AS Int64; DECLARE $chat_id AS int64; - SELECT bot_id, chat_id, state, language_code, reminder_message_id, jamaat, jamaat_message_id + SELECT bot_id, chat_id, state, language_code, subscribed, reminder FROM chats WHERE bot_id = $bot_id AND chat_id = $chat_id; ` @@ -108,24 +115,35 @@ func (db *DB) GetChat(ctx context.Context, botID int64, chatID int64) (chat *dom err := db.client.Do(ctx, func(ctx context.Context, s table.Session) error { _, res, err := s.Execute(ctx, readTx, query, params) if err != nil { - return err + log.Error("execute get chat query", log.Err(err), log.BotID(botID), log.ChatID(chatID)) + return domain.ErrInternal } defer func(res result.Result) { _ = res.Close() }(res) if res.NextResultSet(ctx) && res.NextRow() { chat = &domain.Chat{} + var reminderJSON string err = res.ScanWithDefaults( &chat.BotID, &chat.ChatID, &chat.State, &chat.LanguageCode, - &chat.ReminderMessageID, - &chat.Jamaat, - &chat.JamaatMessageID, + &chat.Subscribed, + &reminderJSON, ) if err != nil { - return err + log.Error("scan chat fields", log.Err(err), log.BotID(botID), log.ChatID(chatID)) + return domain.ErrInternal + } + + var reminder domain.Reminder + if err := json.Unmarshal([]byte(reminderJSON), &reminder); err != nil { + log.Error("unmarshal reminder json", log.Err(err), log.BotID(chat.BotID), log.ChatID(chat.ChatID)) + return domain.ErrUnmarshalJSON } + + chat.Reminder = &reminder + return nil } @@ -133,9 +151,6 @@ func (db *DB) GetChat(ctx context.Context, botID int64, chatID int64) (chat *dom }) if err != nil { - if ydb.IsOperationError(err, Ydb.StatusIds_NOT_FOUND) { - return nil, domain.ErrNotFound - } return nil, err } @@ -146,7 +161,7 @@ func (db *DB) GetChats(ctx context.Context, botID int64) (chats []*domain.Chat, query := ` DECLARE $bot_id AS Int64; - SELECT bot_id, chat_id, state, language_code, reminder_message_id, jamaat, jamaat_message_id + SELECT bot_id, chat_id, state, language_code, subscribed, reminder FROM chats WHERE bot_id = $bot_id; ` @@ -155,25 +170,35 @@ func (db *DB) GetChats(ctx context.Context, botID int64) (chats []*domain.Chat, err := db.client.Do(ctx, func(ctx context.Context, s table.Session) error { _, res, err := s.Execute(ctx, readTx, query, params) if err != nil { - return err + log.Error("execute get chats query", log.Err(err), log.BotID(botID)) + return domain.ErrInternal } defer func(res result.Result) { _ = res.Close() }(res) if res.NextResultSet(ctx) { for res.NextRow() { chat := &domain.Chat{} + var reminderJSON string err = res.ScanWithDefaults( &chat.BotID, &chat.ChatID, &chat.State, &chat.LanguageCode, - &chat.ReminderMessageID, - &chat.Jamaat, - &chat.JamaatMessageID, + &chat.Subscribed, + &reminderJSON, ) if err != nil { - return err + log.Error("scan chat fields", log.Err(err), log.BotID(botID)) + return domain.ErrInternal } + + var reminder domain.Reminder + if err := json.Unmarshal([]byte(reminderJSON), &reminder); err != nil { + log.Error("unmarshal reminder json", log.Err(err), log.BotID(chat.BotID), log.ChatID(chat.ChatID)) + return domain.ErrUnmarshalJSON + } + chat.Reminder = &reminder + chats = append(chats, chat) } } @@ -207,7 +232,11 @@ func (db *DB) SetLanguageCode(ctx context.Context, botID int64, chatID int64, la err := db.client.Do(ctx, func(ctx context.Context, s table.Session) error { _, _, err := s.Execute(ctx, writeTx, query, params) - return err + if err != nil { + log.Error("execute set language code query", log.Err(err), log.BotID(botID), log.ChatID(chatID)) + return domain.ErrInternal + } + return nil }) return err @@ -232,35 +261,39 @@ func (db *DB) SetSubscribed(ctx context.Context, botID int64, chatID int64, subs err := db.client.Do(ctx, func(ctx context.Context, s table.Session) error { _, _, err := s.Execute(ctx, writeTx, query, params) - return err + if err != nil { + log.Error("execute set subscribed query", log.Err(err), log.BotID(botID), log.ChatID(chatID)) + return domain.ErrInternal + } + return nil }) return err } -func (db *DB) SetReminderOffset(ctx context.Context, botID int64, chatID int64, offset int32) error { - query := ` - DECLARE $bot_id AS Int64; - DECLARE $chat_id AS Int64; - DECLARE $reminder_offset AS Int32; - - UPDATE chats - SET reminder_offset = $reminder_offset - WHERE bot_id = $bot_id AND chat_id = $chat_id; - ` - - params := table.NewQueryParameters( - table.ValueParam("$bot_id", types.Int64Value(botID)), - table.ValueParam("$chat_id", types.Int64Value(chatID)), - table.ValueParam("$reminder_offset", types.Int32Value(offset)), - ) +func (db *DB) SetReminderOffset(ctx context.Context, botID int64, chatID int64, reminderType domain.ReminderType, offset time.Duration) error { + return db.updateReminder(ctx, botID, chatID, func(reminder *domain.Reminder) { + switch reminderType { + case domain.ReminderTypeTomorrow: + reminder.Tomorrow.Offset = domain.Duration(offset) + reminder.Tomorrow.LastAt = time.Now() + case domain.ReminderTypeSoon: + reminder.Soon.Offset = domain.Duration(offset) + reminder.Soon.LastAt = time.Now() + } + }) +} - err := db.client.Do(ctx, func(ctx context.Context, s table.Session) error { - _, _, err := s.Execute(ctx, writeTx, query, params) - return err +func (db *DB) SetJamaatEnabled(ctx context.Context, botID int64, chatID int64, enabled bool) error { + return db.updateReminder(ctx, botID, chatID, func(reminder *domain.Reminder) { + reminder.Jamaat.Enabled = enabled }) +} - return err +func (db *DB) SetJamaatDelay(ctx context.Context, botID int64, chatID int64, prayerID domain.PrayerID, delay time.Duration) error { + return db.updateReminder(ctx, botID, chatID, func(reminder *domain.Reminder) { + reminder.Jamaat.Delay.SetDelayByPrayerID(prayerID, delay) + }) } func (db *DB) SetState(ctx context.Context, botID int64, chatID int64, state string) error { @@ -282,45 +315,78 @@ func (db *DB) SetState(ctx context.Context, botID int64, chatID int64, state str err := db.client.Do(ctx, func(ctx context.Context, s table.Session) error { _, _, err := s.Execute(ctx, writeTx, query, params) - return err + if err != nil { + log.Error("execute set state query", log.Err(err), log.BotID(botID), log.ChatID(chatID)) + return domain.ErrInternal + } + return nil }) return err } func (db *DB) GetPrayerDay(ctx context.Context, botID int64, date time.Time) (prayerDay *domain.PrayerDay, _ error) { + nextDate := date.Add(24 * time.Hour) + query := ` DECLARE $bot_id AS Int64; DECLARE $date AS Date; + DECLARE $next_date AS Date; SELECT prayer_date, fajr, shuruq, dhuhr, asr, maghrib, isha FROM prayers - WHERE bot_id = $bot_id AND prayer_date = $date; + WHERE bot_id = $bot_id AND prayer_date IN ($date, $next_date) + ORDER BY prayer_date; ` params := table.NewQueryParameters( table.ValueParam("$bot_id", types.Int64Value(botID)), table.ValueParam("$date", types.DateValueFromTime(date)), + table.ValueParam("$next_date", types.DateValueFromTime(nextDate)), ) err := db.client.Do(ctx, func(ctx context.Context, s table.Session) error { _, res, err := s.Execute(ctx, readTx, query, params) if err != nil { - return err + log.Error("execute get prayer day query", log.Err(err), log.BotID(botID)) + return domain.ErrInternal } defer func(res result.Result) { _ = res.Close() }(res) - if res.NextResultSet(ctx) && res.NextRow() { - prayerDay = &domain.PrayerDay{} - err = res.ScanWithDefaults( - &prayerDay.Date, - &prayerDay.Fajr, &prayerDay.Shuruq, - &prayerDay.Dhuhr, &prayerDay.Asr, - &prayerDay.Maghrib, &prayerDay.Isha, - ) - if err != nil { - return err + if res.NextResultSet(ctx) { + if res.NextRow() { + prayerDay = &domain.PrayerDay{} + err = res.ScanWithDefaults( + &prayerDay.Date, + &prayerDay.Fajr, &prayerDay.Shuruq, + &prayerDay.Dhuhr, &prayerDay.Asr, + &prayerDay.Maghrib, &prayerDay.Isha, + ) + if err != nil { + log.Error("scan prayer day fields", log.Err(err), log.BotID(botID)) + return domain.ErrInternal + } + } else { + return domain.ErrNotFound + } + + if res.NextRow() { + nextDay := &domain.PrayerDay{} + err = res.ScanWithDefaults( + &nextDay.Date, + &nextDay.Fajr, &nextDay.Shuruq, + &nextDay.Dhuhr, &nextDay.Asr, + &nextDay.Maghrib, &nextDay.Isha, + ) + if err != nil { + log.Error("scan next prayer day fields", log.Err(err), log.BotID(botID)) + return domain.ErrInternal + } + prayerDay.NextDay = nextDay + } else { + return domain.ErrNotFound // no next day found (cannot happen) } + return nil } @@ -328,9 +394,6 @@ func (db *DB) GetPrayerDay(ctx context.Context, botID int64, date time.Time) (pr }) if err != nil { - if ydb.IsOperationError(err, Ydb.StatusIds_NOT_FOUND) { - return nil, domain.ErrNotFound - } return nil, err } @@ -362,14 +425,16 @@ func (db *DB) GetStats(ctx context.Context, botID int64) (*domain.Stats, error) err := db.client.Do(ctx, func(ctx context.Context, s table.Session) error { _, res, err := s.Execute(ctx, readTx, query, params) if err != nil { - return err + log.Error("execute get stats query", log.Err(err), log.BotID(botID)) + return domain.ErrInternal } defer func(res result.Result) { _ = res.Close() }(res) if res.NextResultSet(ctx) && res.NextRow() { err = res.ScanWithDefaults(&stats.Users, &stats.Subscribed, &stats.Unsubscribed) if err != nil { - return err + log.Error("scan stats fields", log.Err(err), log.BotID(botID)) + return domain.ErrInternal } } @@ -382,7 +447,8 @@ func (db *DB) GetStats(ctx context.Context, botID int64) (*domain.Stats, error) err = res.ScanWithDefaults(&languageCode, &count) if err != nil { - return err + log.Error("scan language stats", log.Err(err), log.BotID(botID)) + return domain.ErrInternal } stats.LanguagesGrouped[languageCode] = count } @@ -396,3 +462,82 @@ func (db *DB) GetStats(ctx context.Context, botID int64) (*domain.Stats, error) return stats, nil } + +func (db *DB) updateReminder(ctx context.Context, botID int64, chatID int64, mutate func(*domain.Reminder)) error { + err := db.client.Do(ctx, func(ctx context.Context, s table.Session) error { + txBegin := table.TxControl(table.BeginTx(table.WithSerializableReadWrite())) + + selectQuery := ` + DECLARE $bot_id AS Int64; + DECLARE $chat_id AS Int64; + + SELECT reminder + FROM chats + WHERE bot_id = $bot_id AND chat_id = $chat_id; + ` + + selectParams := table.NewQueryParameters( + table.ValueParam("$bot_id", types.Int64Value(botID)), + table.ValueParam("$chat_id", types.Int64Value(chatID)), + ) + + txID, res, err := s.Execute(ctx, txBegin, selectQuery, selectParams) + if err != nil { + log.Error("execute select query", log.Err(err), log.BotID(botID), log.ChatID(chatID)) + return domain.ErrInternal + } + + defer func(res result.Result) { _ = res.Close() }(res) + + var reminderJSON string + if res.NextResultSet(ctx) && res.NextRow() { + err = res.ScanWithDefaults(&reminderJSON) + if err != nil { + log.Error("scan reminder json", log.Err(err), log.BotID(botID), log.ChatID(chatID)) + return domain.ErrInternal + } + } else { + return domain.ErrNotFound + } + + var reminder domain.Reminder + if err := json.Unmarshal([]byte(reminderJSON), &reminder); err != nil { + log.Error("unmarshal reminder json", log.Err(err), log.BotID(botID), log.ChatID(chatID)) + return domain.ErrUnmarshalJSON + } + + mutate(&reminder) + + updatedReminderJSON, err := json.Marshal(&reminder) + if err != nil { + log.Error("marshal updated reminder json", log.Err(err), log.BotID(botID), log.ChatID(chatID)) + return domain.ErrInternal + } + + updateQuery := ` + DECLARE $bot_id AS Int64; + DECLARE $chat_id AS Int64; + DECLARE $reminder AS Json; + + UPDATE chats + SET reminder = $reminder + WHERE bot_id = $bot_id AND chat_id = $chat_id; + ` + + updateParams := table.NewQueryParameters( + table.ValueParam("$bot_id", types.Int64Value(botID)), + table.ValueParam("$chat_id", types.Int64Value(chatID)), + table.ValueParam("$reminder", types.JSONValue(string(updatedReminderJSON))), + ) + + txCommit := table.TxControl(table.WithTx(txID), table.CommitTx()) + _, _, err = s.Execute(ctx, txCommit, updateQuery, updateParams) + if err != nil { + log.Error("execute update reminder query", log.Err(err), log.BotID(botID), log.ChatID(chatID)) + return domain.ErrInternal + } + return nil + }) + + return err +} diff --git a/serverless/loader/go.mod b/serverless/loader/go.mod index bacd459..46c3b34 100644 --- a/serverless/loader/go.mod +++ b/serverless/loader/go.mod @@ -4,7 +4,7 @@ go 1.21 require ( github.com/aws/aws-sdk-go v1.55.7 - github.com/escalopa/prayer-bot v0.0.0-20250924182821-90acd5bcfc24 + github.com/escalopa/prayer-bot v0.0.0-20251102114226-e7cacf22daf1 github.com/ydb-platform/ydb-go-sdk/v3 v3.108.0 github.com/ydb-platform/ydb-go-yc v0.12.3 ) diff --git a/serverless/loader/go.sum b/serverless/loader/go.sum index 16a0a10..beec113 100644 --- a/serverless/loader/go.sum +++ b/serverless/loader/go.sum @@ -571,8 +571,30 @@ github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7 github.com/envoyproxy/protoc-gen-validate v0.6.7/go.mod h1:dyJXwwfPK2VSqiB9Klm1J6romD608Ba7Hij42vrOBCo= github.com/envoyproxy/protoc-gen-validate v0.9.1/go.mod h1:OKNgG7TCp5pF4d6XftA0++PMirau2/yoOwVac3AbF2w= github.com/envoyproxy/protoc-gen-validate v0.10.0/go.mod h1:DRjgyB0I43LtJapqN6NiRwroiAU2PaFuvk/vjgh61ss= -github.com/escalopa/prayer-bot v0.0.0-20250924182821-90acd5bcfc24 h1:Kl4X9Jikl6TJLx97TWGTF99aJxALsJZBIJ+E2lVvo0E= -github.com/escalopa/prayer-bot v0.0.0-20250924182821-90acd5bcfc24/go.mod h1:HOpIxziLKMTC/a+Q6z40Rn5tJDEZsFyzhniLA4iB2i0= +github.com/escalopa/prayer-bot v0.0.0-20251026155439-6034664856a3 h1:3ze/gQCQs+qIzkU+sB1zd5opatlkHCdeERuWC72/4Ic= +github.com/escalopa/prayer-bot v0.0.0-20251026155439-6034664856a3/go.mod h1:HOpIxziLKMTC/a+Q6z40Rn5tJDEZsFyzhniLA4iB2i0= +github.com/escalopa/prayer-bot v0.0.0-20251026172843-9eead151801a h1:ymjQDDn1JBdL95DkBVkUxwphZxSTPAfP4Pf1ysikfmQ= +github.com/escalopa/prayer-bot v0.0.0-20251026172843-9eead151801a/go.mod h1:HOpIxziLKMTC/a+Q6z40Rn5tJDEZsFyzhniLA4iB2i0= +github.com/escalopa/prayer-bot v0.0.0-20251026192818-d6bccce30ecf h1:OLDTjLdG0mNigMk9sNNh+fJ5LBWkYboNKu2pFX61WSo= +github.com/escalopa/prayer-bot v0.0.0-20251026192818-d6bccce30ecf/go.mod h1:HOpIxziLKMTC/a+Q6z40Rn5tJDEZsFyzhniLA4iB2i0= +github.com/escalopa/prayer-bot v0.0.0-20251026194015-725e9cfbd15d h1:+Dv3qCKHoRcfFQVjEteZWTaLOkyS6B5OlrKr8CmroC4= +github.com/escalopa/prayer-bot v0.0.0-20251026194015-725e9cfbd15d/go.mod h1:HOpIxziLKMTC/a+Q6z40Rn5tJDEZsFyzhniLA4iB2i0= +github.com/escalopa/prayer-bot v0.0.0-20251026200218-6f6adf300279 h1:qc+7zybygYoH1yz948TjHe/uQ6QliqSwEVEd8O9rxug= +github.com/escalopa/prayer-bot v0.0.0-20251026200218-6f6adf300279/go.mod h1:HOpIxziLKMTC/a+Q6z40Rn5tJDEZsFyzhniLA4iB2i0= +github.com/escalopa/prayer-bot v0.0.0-20251026201902-b5254b301c89 h1:+jbrcX4CpaKUZs0gG+1QbpVENyN9NVbdZLQJOO+d/q0= +github.com/escalopa/prayer-bot v0.0.0-20251026201902-b5254b301c89/go.mod h1:HOpIxziLKMTC/a+Q6z40Rn5tJDEZsFyzhniLA4iB2i0= +github.com/escalopa/prayer-bot v0.0.0-20251026202753-f09b49eedd92 h1:CZ80HIK0Duq/Rjo1Q+zzFFRNM3mER2fbxBryYu4SFaE= +github.com/escalopa/prayer-bot v0.0.0-20251026202753-f09b49eedd92/go.mod h1:HOpIxziLKMTC/a+Q6z40Rn5tJDEZsFyzhniLA4iB2i0= +github.com/escalopa/prayer-bot v0.0.0-20251026205320-11cb2dcd014e h1:rnAVK8yqKdvUJu0jwp8RyZwGXNgq1BWLUWTNGgMIPCs= +github.com/escalopa/prayer-bot v0.0.0-20251026205320-11cb2dcd014e/go.mod h1:HOpIxziLKMTC/a+Q6z40Rn5tJDEZsFyzhniLA4iB2i0= +github.com/escalopa/prayer-bot v0.0.0-20251026211435-73f9e46a72f7 h1:DO0ejIbuZJJxoxareSryEpp1HGNHRDzCkNUtKwlvhFg= +github.com/escalopa/prayer-bot v0.0.0-20251026211435-73f9e46a72f7/go.mod h1:HOpIxziLKMTC/a+Q6z40Rn5tJDEZsFyzhniLA4iB2i0= +github.com/escalopa/prayer-bot v0.0.0-20251026213512-5af98b73d4ad h1:bWmc3gk++my6XDOXRQKfER/xmXkl70Yobw2nQQdcroY= +github.com/escalopa/prayer-bot v0.0.0-20251026213512-5af98b73d4ad/go.mod h1:HOpIxziLKMTC/a+Q6z40Rn5tJDEZsFyzhniLA4iB2i0= +github.com/escalopa/prayer-bot v0.0.0-20251102105549-ebc4c681afa2 h1:JqgNa7R7hRsEV2uNslRcbioATTwlVBAJh9vqsdwYR9c= +github.com/escalopa/prayer-bot v0.0.0-20251102105549-ebc4c681afa2/go.mod h1:HOpIxziLKMTC/a+Q6z40Rn5tJDEZsFyzhniLA4iB2i0= +github.com/escalopa/prayer-bot v0.0.0-20251102114226-e7cacf22daf1 h1:s9sqt4CtdExFGlZz1k2SDB2rA2elyMHR7I59MJ9c1lg= +github.com/escalopa/prayer-bot v0.0.0-20251102114226-e7cacf22daf1/go.mod h1:HOpIxziLKMTC/a+Q6z40Rn5tJDEZsFyzhniLA4iB2i0= github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= diff --git a/serverless/reminder/go.mod b/serverless/reminder/go.mod index 26846ac..0823bc1 100644 --- a/serverless/reminder/go.mod +++ b/serverless/reminder/go.mod @@ -3,9 +3,8 @@ module github.com/escalopa/prayer-bot/reminder go 1.21 require ( - github.com/escalopa/prayer-bot v0.0.0-20250924182821-90acd5bcfc24 + github.com/escalopa/prayer-bot v0.0.0-20251102114226-e7cacf22daf1 github.com/go-telegram/bot v1.15.0 - github.com/ydb-platform/ydb-go-genproto v0.0.0-20241112172322-ea1f63298f77 github.com/ydb-platform/ydb-go-sdk/v3 v3.108.0 github.com/ydb-platform/ydb-go-yc v0.12.3 golang.org/x/sync v0.10.0 @@ -18,6 +17,7 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/jonboulle/clockwork v0.3.0 // indirect github.com/yandex-cloud/go-genproto v0.0.0-20240819112322-98a264d392f6 // indirect + github.com/ydb-platform/ydb-go-genproto v0.0.0-20241112172322-ea1f63298f77 // indirect github.com/ydb-platform/ydb-go-yc-metadata v0.6.1 // indirect golang.org/x/net v0.33.0 // indirect golang.org/x/sys v0.28.0 // indirect diff --git a/serverless/reminder/go.sum b/serverless/reminder/go.sum index 6ee1216..5b81f7a 100644 --- a/serverless/reminder/go.sum +++ b/serverless/reminder/go.sum @@ -569,8 +569,8 @@ github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7 github.com/envoyproxy/protoc-gen-validate v0.6.7/go.mod h1:dyJXwwfPK2VSqiB9Klm1J6romD608Ba7Hij42vrOBCo= github.com/envoyproxy/protoc-gen-validate v0.9.1/go.mod h1:OKNgG7TCp5pF4d6XftA0++PMirau2/yoOwVac3AbF2w= github.com/envoyproxy/protoc-gen-validate v0.10.0/go.mod h1:DRjgyB0I43LtJapqN6NiRwroiAU2PaFuvk/vjgh61ss= -github.com/escalopa/prayer-bot v0.0.0-20250924182821-90acd5bcfc24 h1:Kl4X9Jikl6TJLx97TWGTF99aJxALsJZBIJ+E2lVvo0E= -github.com/escalopa/prayer-bot v0.0.0-20250924182821-90acd5bcfc24/go.mod h1:HOpIxziLKMTC/a+Q6z40Rn5tJDEZsFyzhniLA4iB2i0= +github.com/escalopa/prayer-bot v0.0.0-20251102114226-e7cacf22daf1 h1:s9sqt4CtdExFGlZz1k2SDB2rA2elyMHR7I59MJ9c1lg= +github.com/escalopa/prayer-bot v0.0.0-20251102114226-e7cacf22daf1/go.mod h1:HOpIxziLKMTC/a+Q6z40Rn5tJDEZsFyzhniLA4iB2i0= github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= diff --git a/serverless/reminder/internal/handler/handler.go b/serverless/reminder/internal/handler/handler.go index acc8a46..d3d3eb7 100644 --- a/serverless/reminder/internal/handler/handler.go +++ b/serverless/reminder/internal/handler/handler.go @@ -3,55 +3,29 @@ package handler import ( "context" "fmt" - "os" - "slices" - "strconv" - "strings" "sync" "time" "github.com/escalopa/prayer-bot/domain" "github.com/escalopa/prayer-bot/log" "github.com/go-telegram/bot" - "github.com/go-telegram/bot/models" "golang.org/x/sync/errgroup" ) -var ( - jamaatDelay time.Duration -) - -func init() { - const jamattDelayDefault = 10 * time.Minute - - var ( - jamaatEnvKey = "JAMAAT_DELAY" - jamaatEnvValue = os.Getenv(jamaatEnvKey) - ) - - if jamaatEnvValue == "" { // not set, use default value - jamaatDelay = jamattDelayDefault - return - } - - jamaatDelayDuration, err := time.ParseDuration(jamaatEnvValue) - if err != nil { - log.Error("parse jamaat delay", log.Err(err)) - jamaatDelayDuration = jamattDelayDefault // set to default value on error - } - - jamaatDelay = jamaatDelayDuration -} - type ( DB interface { GetChatsByIDs(ctx context.Context, botID int64, chatIDs []int64) (chats []*domain.Chat, _ error) GetSubscribers(ctx context.Context, botID int64) (chatIDs []int64, _ error) - GetSubscribersByOffset(ctx context.Context, botID int64, offset int32) (chatIDs []int64, _ error) GetPrayerDay(ctx context.Context, botID int64, date time.Time) (prayerDay *domain.PrayerDay, _ error) - SetReminderMessageID(ctx context.Context, botID int64, chatID int64, reminderMessageID int32) error - SetJamaatMessageID(ctx context.Context, botID int64, chatID int64, jamaatMessageID int32) error DeleteChat(ctx context.Context, botID int64, chatID int64) error + UpdateReminder( + ctx context.Context, + botID int64, + chatID int64, + reminderType domain.ReminderType, + messageID int, + lastAt time.Time, + ) error } Handler struct { @@ -65,7 +39,7 @@ type ( ) func New(cfg map[int64]*domain.BotConfig, db DB) (*Handler, error) { - lp, err := newLanguageProvider() + lp, err := newLanguagesProvider() if err != nil { return nil, err } @@ -104,117 +78,85 @@ func (h *Handler) getBot(botID int64) (*bot.Bot, error) { } func (h *Handler) Handel(ctx context.Context, botID int64) error { - cfg := h.cfg[botID] - prayerID, reminderOffset, err := h.getPrayer(ctx, botID, cfg.Location.V()) - if err != nil { - log.Error("get prayer", log.Err(err), log.BotID(botID)) - return domain.ErrInternal - } - - var chatIDs []int64 - switch { - case reminderOffset == 0: - chatIDs, err = h.db.GetSubscribers(ctx, botID) - case slices.Contains(domain.ReminderOffsets(), reminderOffset): - chatIDs, err = h.db.GetSubscribersByOffset(ctx, botID, reminderOffset) - } - + chatIDs, err := h.db.GetSubscribers(ctx, botID) if err != nil { log.Error("get subscribers", log.Err(err), log.BotID(botID)) return domain.ErrInternal } + if len(chatIDs) == 0 { return nil } - err = h.remindUsers(ctx, botID, chatIDs, prayerID, reminderOffset) + chats, err := h.db.GetChatsByIDs(ctx, botID, chatIDs) if err != nil { - log.Error("remindUsers", log.Err(err)) + log.Error("get chats", log.Err(err), log.BotID(botID)) return domain.ErrInternal } - return nil -} - -func (h *Handler) getPrayer(ctx context.Context, botID int64, loc *time.Location) (domain.PrayerID, int32, error) { - date := h.now(loc) - prayerDay, err := h.db.GetPrayerDay(ctx, botID, date) - if err != nil { - log.Error("get prayer day", - log.Err(err), - log.BotID(botID), - log.String("date", date.String()), - log.String("location", loc.String()), - ) - return 0, 0, domain.ErrInternal - } - - switch { - case prayerDay.Fajr.After(date) || prayerDay.Fajr.Equal(date): - return domain.PrayerIDFajr, int32(prayerDay.Fajr.Sub(date).Minutes()), nil - case prayerDay.Shuruq.After(date) || prayerDay.Shuruq.Equal(date): - return domain.PrayerIDShuruq, int32(prayerDay.Shuruq.Sub(date).Minutes()), nil - case prayerDay.Dhuhr.After(date) || prayerDay.Dhuhr.Equal(date): - return domain.PrayerIDDhuhr, int32(prayerDay.Dhuhr.Sub(date).Minutes()), nil - case prayerDay.Asr.After(date) || prayerDay.Asr.Equal(date): - return domain.PrayerIDAsr, int32(prayerDay.Asr.Sub(date).Minutes()), nil - case prayerDay.Maghrib.After(date) || prayerDay.Maghrib.Equal(date): - return domain.PrayerIDMaghrib, int32(prayerDay.Maghrib.Sub(date).Minutes()), nil - case prayerDay.Isha.After(date) || prayerDay.Isha.Equal(date): - return domain.PrayerIDIsha, int32(prayerDay.Isha.Sub(date).Minutes()), nil - } - - // if no prayer time is found, return the first prayer of the next day - prayerDay, err = h.db.GetPrayerDay(ctx, botID, date.AddDate(0, 0, 1)) + b, err := h.getBot(botID) if err != nil { - log.Error("get next prayer day", - log.Err(err), - log.BotID(botID), - log.String("date", date.String()), - log.String("location", loc.String()), - ) - return 0, 0, domain.ErrInternal + log.Error("get bot", log.Err(err), log.BotID(botID)) + return domain.ErrInternal } - return domain.PrayerIDFajr, int32(prayerDay.Fajr.Sub(date).Minutes()), nil -} - -func (h *Handler) remindUsers( - ctx context.Context, - botID int64, - chatIDs []int64, - prayerID domain.PrayerID, - reminderOffset int32, -) error { - chats, err := h.db.GetChatsByIDs(ctx, botID, chatIDs) + cfg := h.cfg[botID] + now := h.now(cfg.Location.V()) + date := now.Truncate(24 * time.Hour) + prayerDay, err := h.db.GetPrayerDay(ctx, botID, date) if err != nil { - log.Error("get chats", log.Err(err), log.BotID(botID)) + log.Error("get prayer day", log.Err(err), log.BotID(botID)) return domain.ErrInternal } - b, err := h.getBot(botID) - if err != nil { - log.Error("get bot", log.Err(err), log.BotID(botID)) - return domain.ErrInternal + reminders := []ReminderType{ + &TomorrowReminder{ + lp: h.lp, + botConfig: h.cfg, + formatPrayerDay: h.formatPrayerDay, + }, + &SoonReminder{lp: h.lp}, + &ArriveReminder{lp: h.lp}, } errG := &errgroup.Group{} for _, chat := range chats { chat := chat errG.Go(func() error { - fn := h.remindUser - if chat.Jamaat && prayerID != domain.PrayerIDShuruq /* shuruq isn't prayed in Jamaat */ { - fn = h.remindUserJamaat + if chat.Reminder == nil { // cannot happen but just in case + return nil } - err := fn(ctx, b, chat, prayerID, reminderOffset) - if err != nil { - log.Error("remindUsers", - log.Err(err), - log.BotID(chat.BotID), - log.ChatID(chat.ChatID), - log.String("prayer_id", strconv.Itoa(int(prayerID))), - log.String("reminder_offset", strconv.Itoa(int(reminderOffset))), - ) + + for _, reminder := range reminders { + shouldSend, prayerID := reminder.ShouldTrigger(ctx, chat, prayerDay, now) + if !shouldSend { + continue + } + + messageID, err := reminder.Send(ctx, b, chat, prayerID, prayerDay) + if err != nil { + if isBlockedErr(err) { + h.deleteChat(ctx, chat) + return nil + } + log.Error("send reminder", + log.Err(err), + log.BotID(chat.BotID), + log.ChatID(chat.ChatID), + log.String("reminder_type", reminder.Name().String()), + ) + continue + } + + err = h.db.UpdateReminder(ctx, chat.BotID, chat.ChatID, reminder.Name(), messageID, now) + if err != nil { + log.Error("update reminder state", + log.Err(err), + log.BotID(chat.BotID), + log.ChatID(chat.ChatID), + log.String("reminder_type", reminder.Name().String()), + ) + } } return nil }) @@ -223,179 +165,3 @@ func (h *Handler) remindUsers( _ = errG.Wait() return nil } - -func (h *Handler) remindUser( - ctx context.Context, - b *bot.Bot, - chat *domain.Chat, - prayerID domain.PrayerID, - reminderOffset int32, -) error { - var ( - text = h.lp.GetText(chat.LanguageCode) - duration = time.Duration(reminderOffset) * time.Minute - prayer, message = text.Prayer[int(prayerID)], "" - ) - - switch duration { - case 0: - message = fmt.Sprintf(text.PrayerArrived, prayer) - default: - message = fmt.Sprintf(text.PrayerSoon, prayer, domain.FormatDuration(duration)) - } - - params := &bot.SendMessageParams{ - ChatID: chat.ChatID, - Text: message, - ParseMode: models.ParseModeMarkdown, - } - - res, err := b.SendMessage(ctx, params) - - if err != nil { - if h.isBlockedErr(err) { - h.deleteChat(ctx, chat) - return nil - } - log.Error("remindUser: send message", log.Err(err), log.BotID(chat.BotID), log.ChatID(chat.ChatID)) - return domain.ErrInternal - } - - err = h.db.SetReminderMessageID(ctx, chat.BotID, chat.ChatID, int32(res.ID)) - if err != nil { - log.Error("remindUser: set remind_message_id", log.Err(err), log.BotID(chat.BotID), log.ChatID(chat.ChatID)) - return domain.ErrInternal - } - - h.deleteMessages(ctx, b, chat, chat.ReminderMessageID, chat.JamaatMessageID) - return nil -} - -func (h *Handler) remindUserJamaat( - ctx context.Context, - b *bot.Bot, - chat *domain.Chat, - prayerID domain.PrayerID, - reminderOffset int32, -) error { - var ( - text = h.lp.GetText(chat.LanguageCode) - duration = time.Duration(reminderOffset) * time.Minute - prayer, message = text.Prayer[int(prayerID)], "" - hasArrived = false - ) - - switch duration { - case 0: - hasArrived = true - message = fmt.Sprintf(text.PrayerArrived, prayer) - default: - message = fmt.Sprintf("%s\n%s", - fmt.Sprintf(text.PrayerSoon, prayer, domain.FormatDuration(duration)), - fmt.Sprintf(text.PrayerJamaat, domain.FormatDuration(duration+jamaatDelay)), - ) - } - - var ( - res *models.Message - err error - ) - - if hasArrived { - res, err = b.SendMessage(ctx, &bot.SendMessageParams{ - ChatID: chat.ChatID, - Text: message, - ParseMode: models.ParseModeMarkdown, - ReplyParameters: &models.ReplyParameters{ - MessageID: int(chat.JamaatMessageID), - ChatID: chat.ChatID, - AllowSendingWithoutReply: true, - }, - }) - } else { - isAnonymous := false - message = strings.ReplaceAll(message, "*", "") // remove markdown syntax cuz doesn't work on poll - res, err = b.SendPoll(ctx, &bot.SendPollParams{ - ChatID: chat.ChatID, - Question: message, - Options: []models.InputPollOption{ - { - Text: text.PrayerJoin, - TextParseMode: models.ParseModeMarkdown, - }, - { - Text: text.PrayerJoinDelay, - TextParseMode: models.ParseModeMarkdown, - }, - }, - IsAnonymous: &isAnonymous, - }) - } - - if err != nil { - if h.isBlockedErr(err) { - h.deleteChat(ctx, chat) - return nil - } - log.Error("remindUserJamaat: send message", log.Err(err), log.BotID(chat.BotID), log.ChatID(chat.ChatID)) - return domain.ErrInternal - } - - if hasArrived { - err = h.db.SetReminderMessageID(ctx, chat.BotID, chat.ChatID, int32(res.ID)) - if err != nil { - log.Error("remindUserJamaat: set remind_message_id", log.Err(err), log.BotID(chat.BotID), log.ChatID(chat.ChatID)) - return domain.ErrInternal - } - h.deleteMessages(ctx, b, chat, chat.ReminderMessageID) // prevent duplicates - return nil // no need to continue further - } - - err = h.db.SetJamaatMessageID(ctx, chat.BotID, chat.ChatID, int32(res.ID)) - if err != nil { - log.Error("remindUserJamaat: set jamaat_message_id", log.Err(err), log.BotID(chat.BotID), log.ChatID(chat.ChatID)) - return domain.ErrInternal - } - - h.deleteMessages(ctx, b, chat, chat.ReminderMessageID, chat.JamaatMessageID) - return nil -} - -func (h *Handler) deleteChat(ctx context.Context, chat *domain.Chat) { - err := h.db.DeleteChat(ctx, chat.BotID, chat.ChatID) - if err != nil { - log.Error("remindUserJamaat: delete chat", log.Err(err), log.BotID(chat.BotID), log.ChatID(chat.ChatID)) - return - } - log.Warn("remindUserJamaat: deleted chat", log.BotID(chat.BotID), log.ChatID(chat.ChatID)) -} - -func (h *Handler) deleteMessages(ctx context.Context, b *bot.Bot, chat *domain.Chat, ids ...int32) { - messageIDs := make([]int, 0, len(ids)) - for _, id := range ids { - if id == 0 { - continue - } - messageIDs = append(messageIDs, int(id)) - } - if len(messageIDs) == 0 { - return // nothing to do - } - - _, err := b.DeleteMessages(ctx, &bot.DeleteMessagesParams{ - ChatID: chat.ChatID, - MessageIDs: messageIDs, - }) - if err != nil { - log.Error("delete messages", log.Err(err), log.BotID(chat.BotID), log.ChatID(chat.ChatID), "ids", ids) - } -} - -func (h *Handler) isBlockedErr(err error) bool { - return strings.HasPrefix(err.Error(), bot.ErrorForbidden.Error()) -} - -func (h *Handler) now(loc *time.Location) time.Time { - now := time.Now().In(loc) - return time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), now.Minute(), 0, 0, loc) -} diff --git a/serverless/reminder/internal/handler/helper.go b/serverless/reminder/internal/handler/helper.go new file mode 100644 index 0000000..9f1c398 --- /dev/null +++ b/serverless/reminder/internal/handler/helper.go @@ -0,0 +1,86 @@ +package handler + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/escalopa/prayer-bot/domain" + "github.com/escalopa/prayer-bot/log" + "github.com/go-telegram/bot" +) + +const ( + prayerDayFormat = "02.01.2006" + prayerTimeFormat = "15:04" + + prayerText = ` +🗓 %s + +🕊 %s — %s +🌤 %s — %s +☀️ %s — %s +🌇 %s — %s +🌅 %s — %s +🌙 %s — %s +` +) + +func (h *Handler) now(loc *time.Location) time.Time { + return time.Now().In(loc).Truncate(time.Minute) +} + +func (h *Handler) formatPrayerDay(botID int64, prayerDay *domain.PrayerDay, languageCode string) string { + loc := h.cfg[botID].Location.V() + text := h.lp.GetText(languageCode) + + return fmt.Sprintf(prayerText, + prayerDay.Date.Format(prayerDayFormat), + text.Prayer[int(domain.PrayerIDFajr)], prayerDay.Fajr.In(loc).Format(prayerTimeFormat), + text.Prayer[int(domain.PrayerIDShuruq)], prayerDay.Shuruq.In(loc).Format(prayerTimeFormat), + text.Prayer[int(domain.PrayerIDDhuhr)], prayerDay.Dhuhr.In(loc).Format(prayerTimeFormat), + text.Prayer[int(domain.PrayerIDAsr)], prayerDay.Asr.In(loc).Format(prayerTimeFormat), + text.Prayer[int(domain.PrayerIDMaghrib)], prayerDay.Maghrib.In(loc).Format(prayerTimeFormat), + text.Prayer[int(domain.PrayerIDIsha)], prayerDay.Isha.In(loc).Format(prayerTimeFormat), + ) +} + +func (h *Handler) deleteChat(ctx context.Context, chat *domain.Chat) { + err := h.db.DeleteChat(ctx, chat.BotID, chat.ChatID) + if err != nil { + log.Error("remindUserJamaat: delete chat", log.Err(err), log.BotID(chat.BotID), log.ChatID(chat.ChatID)) + return + } + log.Warn("remindUserJamaat: deleted chat", log.BotID(chat.BotID), log.ChatID(chat.ChatID)) +} + +func deleteMessages(ctx context.Context, b *bot.Bot, chat *domain.Chat, ids ...int) { + messageIDs := make([]int, 0, len(ids)) + for _, id := range ids { + if id == 0 { + continue + } + messageIDs = append(messageIDs, id) + } + + if len(messageIDs) == 0 { + return // nothing to do + } + + _, err := b.DeleteMessages(ctx, &bot.DeleteMessagesParams{ + ChatID: chat.ChatID, + MessageIDs: messageIDs, + }) + if err != nil { + log.Error("delete messages", log.Err(err), log.BotID(chat.BotID), log.ChatID(chat.ChatID), "ids", ids) + } +} + +func isBlockedErr(err error) bool { + return strings.HasPrefix(err.Error(), bot.ErrorForbidden.Error()) +} + +func isGroupChat(chatID int64) bool { + return chatID < 0 +} diff --git a/serverless/reminder/internal/handler/languages.go b/serverless/reminder/internal/handler/languages.go index 7ef6fc5..cef8bd4 100644 --- a/serverless/reminder/internal/handler/languages.go +++ b/serverless/reminder/internal/handler/languages.go @@ -1,7 +1,7 @@ package handler import ( - "os" + _ "embed" "gopkg.in/yaml.v3" ) @@ -21,20 +21,15 @@ type ( } ) -func newLanguageProvider() (*languagesProvider, error) { - const filename = "internal/handler/languages/text.yaml" // relative to the `main.go` directory (source root) - - content, err := os.ReadFile(filename) - if err != nil { - return nil, err - } +//go:embed languages/text.yaml +var textData string +func newLanguagesProvider() (*languagesProvider, error) { var storage map[string]*Text - err = yaml.Unmarshal(content, &storage) + err := yaml.Unmarshal([]byte(textData), &storage) if err != nil { return nil, err } - return &languagesProvider{storage: storage}, nil } diff --git a/serverless/reminder/internal/handler/reminder.go b/serverless/reminder/internal/handler/reminder.go new file mode 100644 index 0000000..bf5f412 --- /dev/null +++ b/serverless/reminder/internal/handler/reminder.go @@ -0,0 +1,190 @@ +package handler + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/escalopa/prayer-bot/domain" + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" +) + +type ReminderType interface { + ShouldTrigger(ctx context.Context, chat *domain.Chat, prayerDay *domain.PrayerDay, now time.Time) (shouldSend bool, prayerID domain.PrayerID) + Send(ctx context.Context, b *bot.Bot, chat *domain.Chat, prayerID domain.PrayerID, prayerDay *domain.PrayerDay) (messageID int, err error) + Name() domain.ReminderType +} + +type TomorrowReminder struct { + lp *languagesProvider + botConfig map[int64]*domain.BotConfig + formatPrayerDay func(botID int64, prayerDay *domain.PrayerDay, languageCode string) string +} + +func (r *TomorrowReminder) ShouldTrigger(ctx context.Context, chat *domain.Chat, _ *domain.PrayerDay, now time.Time) (bool, domain.PrayerID) { + config := chat.Reminder.Tomorrow + + triggerTime := time.Date( + now.Year(), + now.Month(), + now.Day()+1, // move to next day + int(-config.Offset.Duration().Hours()), // get triggerTime by moving back in time + 0, + 0, + 0, + now.Location(), + ) + shouldSend := config.LastAt.Before(triggerTime) && (triggerTime.Before(now) || triggerTime.Equal(now)) + return shouldSend, domain.PrayerIDUnknown +} + +func (r *TomorrowReminder) Send(ctx context.Context, b *bot.Bot, chat *domain.Chat, _ domain.PrayerID, prayerDay *domain.PrayerDay) (int, error) { + deleteMessages(ctx, b, chat, chat.Reminder.Tomorrow.MessageID) + res, err := b.SendMessage(ctx, &bot.SendMessageParams{ + ChatID: chat.ChatID, + Text: r.formatPrayerDay(chat.BotID, prayerDay.NextDay, chat.LanguageCode), + }) + if err != nil { + return 0, err + } + + return res.ID, nil +} + +func (r *TomorrowReminder) Name() domain.ReminderType { return domain.ReminderTypeTomorrow } + +type SoonReminder struct { + lp *languagesProvider +} + +func (r *SoonReminder) ShouldTrigger(ctx context.Context, chat *domain.Chat, prayerDay *domain.PrayerDay, now time.Time) (bool, domain.PrayerID) { + prayers := []struct { + id domain.PrayerID + time time.Time + }{ + {domain.PrayerIDFajr, prayerDay.Fajr}, + {domain.PrayerIDShuruq, prayerDay.Shuruq}, + {domain.PrayerIDDhuhr, prayerDay.Dhuhr}, + {domain.PrayerIDAsr, prayerDay.Asr}, + {domain.PrayerIDMaghrib, prayerDay.Maghrib}, + {domain.PrayerIDIsha, prayerDay.Isha}, + {domain.PrayerIDFajr, prayerDay.NextDay.Fajr}, + {domain.PrayerIDShuruq, prayerDay.NextDay.Shuruq}, + } + + config := chat.Reminder.Soon + for _, p := range prayers { + // logic: last_at < (prayer_time - offset) AND (prayer_time - offset) <= now + trigger := p.time.Add(-config.Offset.Duration()) + if config.LastAt.Before(trigger) && (trigger.Before(now) || trigger.Equal(now)) { + // // Next LastAt should be incremented by 1 minute to avoid re-triggering + // nextLastAt := now.Add(1 * time.Minute) + return true, p.id + } + } + return false, 0 +} + +func (r *SoonReminder) Send(ctx context.Context, b *bot.Bot, chat *domain.Chat, prayerID domain.PrayerID, _ *domain.PrayerDay) (int, error) { + text := r.lp.GetText(chat.LanguageCode) + prayer := text.Prayer[int(prayerID)] + + deleteMessages(ctx, b, chat, chat.Reminder.Soon.MessageID, chat.Reminder.Arrive.MessageID) + + if chat.Reminder.Jamaat.Enabled && prayerID != domain.PrayerIDShuruq { + delay := chat.Reminder.Jamaat.Delay.GetDelayByPrayerID(prayerID) + message := fmt.Sprintf("%s\n%s", + fmt.Sprintf(strings.ReplaceAll(text.PrayerSoon, "*", ""), prayer, domain.FormatDuration(chat.Reminder.Soon.Offset.Duration())), + fmt.Sprintf(strings.ReplaceAll(text.PrayerJamaat, "*", ""), domain.FormatDuration(delay+chat.Reminder.Soon.Offset.Duration())), + ) + isAnonymous := false + + res, err := b.SendPoll(ctx, &bot.SendPollParams{ + ChatID: chat.ChatID, + Question: message, + Options: []models.InputPollOption{ + {Text: text.PrayerJoin, TextParseMode: models.ParseModeMarkdown}, + {Text: text.PrayerJoinDelay, TextParseMode: models.ParseModeMarkdown}, + }, + IsAnonymous: &isAnonymous, + }) + if err != nil { + return 0, err + } + + return res.ID, nil + } + + message := fmt.Sprintf(text.PrayerSoon, prayer, domain.FormatDuration(chat.Reminder.Soon.Offset.Duration())) + + res, err := b.SendMessage(ctx, &bot.SendMessageParams{ + ChatID: chat.ChatID, + Text: message, + ParseMode: models.ParseModeMarkdown, + }) + if err != nil { + return 0, err + } + + return res.ID, nil +} + +func (r *SoonReminder) Name() domain.ReminderType { return domain.ReminderTypeSoon } + +type ArriveReminder struct { + lp *languagesProvider +} + +func (r *ArriveReminder) ShouldTrigger(ctx context.Context, chat *domain.Chat, prayerDay *domain.PrayerDay, now time.Time) (bool, domain.PrayerID) { + config := chat.Reminder.Arrive + + prayers := []struct { + id domain.PrayerID + time time.Time + }{ + {domain.PrayerIDFajr, prayerDay.Fajr}, + {domain.PrayerIDShuruq, prayerDay.Shuruq}, + {domain.PrayerIDDhuhr, prayerDay.Dhuhr}, + {domain.PrayerIDAsr, prayerDay.Asr}, + {domain.PrayerIDMaghrib, prayerDay.Maghrib}, + {domain.PrayerIDIsha, prayerDay.Isha}, + } + + for _, p := range prayers { + // logic: last_at < prayer_time AND prayer_time <= now + if config.LastAt.Before(p.time) && (p.time.Before(now) || p.time.Equal(now)) { + return true, p.id + } + } + return false, 0 +} + +func (r *ArriveReminder) Send(ctx context.Context, b *bot.Bot, chat *domain.Chat, prayerID domain.PrayerID, _ *domain.PrayerDay) (int, error) { + deleteMessages(ctx, b, chat, chat.Reminder.Arrive.MessageID) + + text := r.lp.GetText(chat.LanguageCode) + prayer := text.Prayer[int(prayerID)] + message := fmt.Sprintf(text.PrayerArrived, prayer) + + params := &bot.SendMessageParams{ + ChatID: chat.ChatID, + Text: message, + ParseMode: models.ParseModeMarkdown, + ReplyParameters: &models.ReplyParameters{ + ChatID: chat.ChatID, + MessageID: chat.Reminder.Soon.MessageID, // reply to soon's message + AllowSendingWithoutReply: true, // allow sending without reply in case of soon's message is deleted + }, + } + + res, err := b.SendMessage(ctx, params) + if err != nil { + return 0, err + } + + return res.ID, nil +} + +func (r *ArriveReminder) Name() domain.ReminderType { return domain.ReminderTypeArrive } diff --git a/serverless/reminder/internal/service/db.go b/serverless/reminder/internal/service/db.go index bac98b5..666606e 100644 --- a/serverless/reminder/internal/service/db.go +++ b/serverless/reminder/internal/service/db.go @@ -2,16 +2,16 @@ package service import ( "context" + "encoding/json" "os" "time" "github.com/escalopa/prayer-bot/domain" - "github.com/ydb-platform/ydb-go-genproto/protos/Ydb" - "github.com/ydb-platform/ydb-go-sdk/v3/table/result" - "github.com/ydb-platform/ydb-go-sdk/v3/table/types" - + "github.com/escalopa/prayer-bot/log" "github.com/ydb-platform/ydb-go-sdk/v3" "github.com/ydb-platform/ydb-go-sdk/v3/table" + "github.com/ydb-platform/ydb-go-sdk/v3/table/result" + "github.com/ydb-platform/ydb-go-sdk/v3/table/types" yc "github.com/ydb-platform/ydb-go-yc" ) @@ -39,7 +39,8 @@ func NewDB(ctx context.Context) (*DB, error) { yc.WithInternalCA(), ) if err != nil { - return nil, err + log.Error("failed to open ydb connection", log.Err(err)) + return nil, domain.ErrInternal } return &DB{client: sdk.Table()}, nil @@ -50,7 +51,7 @@ func (db *DB) GetChatsByIDs(ctx context.Context, botID int64, chatIDs []int64) ( DECLARE $bot_id AS Int64; DECLARE $chat_ids AS List; - SELECT bot_id, chat_id, state, language_code, reminder_message_id, jamaat, jamaat_message_id + SELECT bot_id, chat_id, state, language_code, reminder FROM chats WHERE bot_id = $bot_id AND chat_id IN $chat_ids; ` @@ -68,25 +69,34 @@ func (db *DB) GetChatsByIDs(ctx context.Context, botID int64, chatIDs []int64) ( err := db.client.Do(ctx, func(ctx context.Context, s table.Session) error { _, res, err := s.Execute(ctx, readTx, query, params) if err != nil { - return err + log.Error("execute get chats by ids query", log.Err(err), log.BotID(botID)) + return domain.ErrInternal } defer func(res result.Result) { _ = res.Close() }(res) if res.NextResultSet(ctx) { for res.NextRow() { chat := &domain.Chat{} + var reminderJSON string err = res.ScanWithDefaults( &chat.BotID, &chat.ChatID, &chat.State, &chat.LanguageCode, - &chat.ReminderMessageID, - &chat.Jamaat, - &chat.JamaatMessageID, + &reminderJSON, ) if err != nil { - return err + log.Error("scan chat fields", log.Err(err), log.BotID(botID)) + return domain.ErrInternal } + + var reminder domain.Reminder + if err := json.Unmarshal([]byte(reminderJSON), &reminder); err != nil { + log.Error("unmarshal reminder json", log.Err(err), log.BotID(chat.BotID), log.ChatID(chat.ChatID)) + return domain.ErrUnmarshalJSON + } + chat.Reminder = &reminder + chats = append(chats, chat) } } @@ -114,7 +124,8 @@ func (db *DB) GetSubscribers(ctx context.Context, botID int64) (chatIDs []int64, err := db.client.Do(ctx, func(ctx context.Context, s table.Session) error { _, res, err := s.Execute(ctx, readTx, query, params) if err != nil { - return err + log.Error("execute get subscribers query", log.Err(err), log.BotID(botID)) + return domain.ErrInternal } defer func(res result.Result) { _ = res.Close() }(res) @@ -123,7 +134,8 @@ func (db *DB) GetSubscribers(ctx context.Context, botID int64) (chatIDs []int64, var chatID int64 err = res.ScanWithDefaults(&chatID) if err != nil { - return err + log.Error("scan subscriber chat id", log.Err(err), log.BotID(botID)) + return domain.ErrInternal } chatIDs = append(chatIDs, chatID) } @@ -139,143 +151,172 @@ func (db *DB) GetSubscribers(ctx context.Context, botID int64) (chatIDs []int64, return chatIDs, nil } -func (db *DB) GetSubscribersByOffset(ctx context.Context, botID int64, offset int32) (chatIDs []int64, _ error) { +func (db *DB) GetPrayerDay(ctx context.Context, botID int64, date time.Time) (prayerDay *domain.PrayerDay, _ error) { + nextDate := date.Add(24 * time.Hour) + query := ` DECLARE $bot_id AS Int64; - DECLARE $offset AS Int32; + DECLARE $date AS Date; + DECLARE $next_date AS Date; - SELECT chat_id - FROM chats - WHERE bot_id = $bot_id AND subscribed = true AND reminder_offset = $offset; + SELECT prayer_date, fajr, shuruq, dhuhr, asr, maghrib, isha + FROM prayers + WHERE bot_id = $bot_id AND prayer_date IN ($date, $next_date) + ORDER BY prayer_date; ` params := table.NewQueryParameters( table.ValueParam("$bot_id", types.Int64Value(botID)), - table.ValueParam("$offset", types.Int32Value(offset)), + table.ValueParam("$date", types.DateValueFromTime(date)), + table.ValueParam("$next_date", types.DateValueFromTime(nextDate)), ) err := db.client.Do(ctx, func(ctx context.Context, s table.Session) error { _, res, err := s.Execute(ctx, readTx, query, params) if err != nil { - return err + log.Error("execute get prayer day query", log.Err(err), log.BotID(botID)) + return domain.ErrInternal } defer func(res result.Result) { _ = res.Close() }(res) - for res.NextResultSet(ctx) { - for res.NextRow() { - var chatID int64 - err = res.ScanWithDefaults(&chatID) + if res.NextResultSet(ctx) { + if res.NextRow() { + prayerDay = &domain.PrayerDay{} + err = res.ScanWithDefaults( + &prayerDay.Date, + &prayerDay.Fajr, &prayerDay.Shuruq, + &prayerDay.Dhuhr, &prayerDay.Asr, + &prayerDay.Maghrib, &prayerDay.Isha, + ) if err != nil { - return err + log.Error("scan prayer day fields", log.Err(err), log.BotID(botID)) + return domain.ErrInternal } - chatIDs = append(chatIDs, chatID) + } else { + return domain.ErrNotFound } + + if res.NextRow() { + nextDay := &domain.PrayerDay{} + err = res.ScanWithDefaults( + &nextDay.Date, + &nextDay.Fajr, &nextDay.Shuruq, + &nextDay.Dhuhr, &nextDay.Asr, + &nextDay.Maghrib, &nextDay.Isha, + ) + if err != nil { + log.Error("scan next prayer day fields", log.Err(err), log.BotID(botID)) + return domain.ErrInternal + } + prayerDay.NextDay = nextDay + } else { + return domain.ErrNotFound // no next day found (cannot happen) + } + + return nil } - return nil + return domain.ErrNotFound }) if err != nil { return nil, err } - return chatIDs, nil + return prayerDay, nil } -func (db *DB) GetPrayerDay(ctx context.Context, botID int64, date time.Time) (prayerDay *domain.PrayerDay, _ error) { - query := ` - DECLARE $bot_id AS Int64; - DECLARE $date AS Date; +func (db *DB) UpdateReminder( + ctx context.Context, + botID int64, + chatID int64, + reminderType domain.ReminderType, + messageID int, + lastAt time.Time, +) error { + err := db.client.Do(ctx, func(ctx context.Context, s table.Session) error { + txBegin := table.TxControl(table.BeginTx(table.WithSerializableReadWrite())) - SELECT prayer_date, fajr, shuruq, dhuhr, asr, maghrib, isha - FROM prayers - WHERE bot_id = $bot_id AND prayer_date = $date; - ` + selectQuery := ` + DECLARE $bot_id AS Int64; + DECLARE $chat_id AS Int64; - params := table.NewQueryParameters( - table.ValueParam("$bot_id", types.Int64Value(botID)), - table.ValueParam("$date", types.DateValueFromTime(date)), - ) + SELECT reminder + FROM chats + WHERE bot_id = $bot_id AND chat_id = $chat_id; + ` - err := db.client.Do(ctx, func(ctx context.Context, s table.Session) error { - _, res, err := s.Execute(ctx, readTx, query, params) + selectParams := table.NewQueryParameters( + table.ValueParam("$bot_id", types.Int64Value(botID)), + table.ValueParam("$chat_id", types.Int64Value(chatID)), + ) + + txID, res, err := s.Execute(ctx, txBegin, selectQuery, selectParams) if err != nil { - return err + log.Error("execute select query", log.Err(err), log.BotID(botID), log.ChatID(chatID)) + return domain.ErrInternal } defer func(res result.Result) { _ = res.Close() }(res) + + var reminderJSON string if res.NextResultSet(ctx) && res.NextRow() { - prayerDay = &domain.PrayerDay{} - err = res.ScanWithDefaults( - &prayerDay.Date, - &prayerDay.Fajr, &prayerDay.Shuruq, - &prayerDay.Dhuhr, &prayerDay.Asr, - &prayerDay.Maghrib, &prayerDay.Isha, - ) + err = res.ScanWithDefaults(&reminderJSON) if err != nil { - return err + log.Error("scan reminder json", log.Err(err), log.BotID(botID), log.ChatID(chatID)) + return domain.ErrInternal } - return nil + } else { + return domain.ErrNotFound } - return domain.ErrNotFound - }) - - if err != nil { - if ydb.IsOperationError(err, Ydb.StatusIds_NOT_FOUND) { - return nil, domain.ErrNotFound + var reminder domain.Reminder + if err := json.Unmarshal([]byte(reminderJSON), &reminder); err != nil { + log.Error("unmarshal reminder json", log.Err(err), log.BotID(botID), log.ChatID(chatID)) + return domain.ErrUnmarshalJSON } - return nil, err - } - - return prayerDay, nil -} - -func (db *DB) SetReminderMessageID(ctx context.Context, botID int64, chatID int64, reminderMessageID int32) error { - query := ` - DECLARE $bot_id AS Int64; - DECLARE $chat_id AS Int64; - DECLARE $reminder_message_id AS Int32; - - UPDATE chats - SET reminder_message_id = $reminder_message_id - WHERE bot_id = $bot_id AND chat_id = $chat_id; - ` - - params := table.NewQueryParameters( - table.ValueParam("$bot_id", types.Int64Value(botID)), - table.ValueParam("$chat_id", types.Int64Value(chatID)), - table.ValueParam("$reminder_message_id", types.Int32Value(reminderMessageID)), - ) - err := db.client.Do(ctx, func(ctx context.Context, s table.Session) error { - _, _, err := s.Execute(ctx, writeTx, query, params) - return err - }) + switch reminderType { + case domain.ReminderTypeTomorrow: + reminder.Tomorrow.MessageID = messageID + reminder.Tomorrow.LastAt = lastAt + case domain.ReminderTypeSoon: + reminder.Soon.MessageID = messageID + reminder.Soon.LastAt = lastAt + case domain.ReminderTypeArrive: + reminder.Arrive.MessageID = messageID + reminder.Arrive.LastAt = lastAt + } - return err -} + updatedReminderJSON, err := json.Marshal(&reminder) + if err != nil { + log.Error("marshal updated reminder json", log.Err(err), log.BotID(botID), log.ChatID(chatID)) + return domain.ErrInternal + } -func (db *DB) SetJamaatMessageID(ctx context.Context, botID int64, chatID int64, jamaatMessageID int32) error { - query := ` - DECLARE $bot_id AS Int64; - DECLARE $chat_id AS Int64; - DECLARE $jamaat_message_id AS Int32; + updateQuery := ` + DECLARE $bot_id AS Int64; + DECLARE $chat_id AS Int64; + DECLARE $reminder AS Json; - UPDATE chats - SET jamaat_message_id = $jamaat_message_id - WHERE bot_id = $bot_id AND chat_id = $chat_id; - ` + UPDATE chats + SET reminder = $reminder + WHERE bot_id = $bot_id AND chat_id = $chat_id; + ` - params := table.NewQueryParameters( - table.ValueParam("$bot_id", types.Int64Value(botID)), - table.ValueParam("$chat_id", types.Int64Value(chatID)), - table.ValueParam("$jamaat_message_id", types.Int32Value(jamaatMessageID)), - ) + updateParams := table.NewQueryParameters( + table.ValueParam("$bot_id", types.Int64Value(botID)), + table.ValueParam("$chat_id", types.Int64Value(chatID)), + table.ValueParam("$reminder", types.JSONValue(string(updatedReminderJSON))), + ) - err := db.client.Do(ctx, func(ctx context.Context, s table.Session) error { - _, _, err := s.Execute(ctx, writeTx, query, params) - return err + txCommit := table.TxControl(table.WithTx(txID), table.CommitTx()) + _, _, err = s.Execute(ctx, txCommit, updateQuery, updateParams) + if err != nil { + log.Error("execute update reminder query", log.Err(err), log.BotID(botID), log.ChatID(chatID)) + return domain.ErrInternal + } + return nil }) return err @@ -297,7 +338,11 @@ func (db *DB) DeleteChat(ctx context.Context, botID int64, chatID int64) error { err := db.client.Do(ctx, func(ctx context.Context, s table.Session) error { _, _, err := s.Execute(ctx, writeTx, query, params) - return err + if err != nil { + log.Error("execute delete chat query", log.Err(err), log.BotID(botID), log.ChatID(chatID)) + return domain.ErrInternal + } + return nil }) return err