未コミットを一括コミット

This commit is contained in:
akira
2026-04-04 09:15:09 +09:00
parent ef7c9d3c21
commit 555940d8f4
26 changed files with 6367 additions and 6367 deletions

View File

@@ -1,53 +1,53 @@
--- ---
description: 新しいWindmillスクリプトを作成する description: 新しいWindmillスクリプトを作成する
--- ---
# 新しいWindmillスクリプトの作成 # 新しいWindmillスクリプトの作成
Windmillに新しいスクリプトを追加する手順。 Windmillに新しいスクリプトを追加する手順。
## 手順 ## 手順
1. **スクリプトファイルの作成**: スクリプトは以下のディレクトリ構造に従って作成する 1. **スクリプトファイルの作成**: スクリプトは以下のディレクトリ構造に従って作成する
- ユーザースクリプト: `u/<username>/<script_name>/` - ユーザースクリプト: `u/<username>/<script_name>/`
- フォルダスクリプト: `f/<folder_name>/<script_name>/` - フォルダスクリプト: `f/<folder_name>/<script_name>/`
2. **ファイル構成**: 各スクリプトフォルダには以下のファイルが必要 2. **ファイル構成**: 各スクリプトフォルダには以下のファイルが必要
- `script.yaml` — メタデータ(言語、概要、スキーマなど) - `script.yaml` — メタデータ(言語、概要、スキーマなど)
- スクリプト本体(例: `script.py`, `script.ts`, `script.sh` - スクリプト本体(例: `script.py`, `script.ts`, `script.sh`
3. **script.yaml のテンプレート** 3. **script.yaml のテンプレート**
```yaml ```yaml
summary: '<スクリプトの説明>' summary: '<スクリプトの説明>'
description: '<詳細な説明>' description: '<詳細な説明>'
lock: [] lock: []
schema: schema:
$schema: 'https://json-schema.org/draft/2020-12/schema' $schema: 'https://json-schema.org/draft/2020-12/schema'
type: object type: object
properties: properties:
param1: param1:
type: string type: string
description: '<パラメータの説明>' description: '<パラメータの説明>'
required: required:
- param1 - param1
kind: script kind: script
tag: '' tag: ''
``` ```
4. **Pythonスクリプト (`script.py`) のテンプレート** 4. **Pythonスクリプト (`script.py`) のテンプレート**
```python ```python
def main(param1: str): def main(param1: str):
""" """
スクリプトの説明 スクリプトの説明
""" """
return {"result": f"Hello, {param1}!"} return {"result": f"Hello, {param1}!"}
``` ```
5. **TypeScriptスクリプト (`script.ts`) のテンプレート** 5. **TypeScriptスクリプト (`script.ts`) のテンプレート**
```typescript ```typescript
export async function main(param1: string): Promise<any> { export async function main(param1: string): Promise<any> {
return { result: `Hello, ${param1}!` }; return { result: `Hello, ${param1}!` };
} }
``` ```
6. **サーバーに反映**: `/windmill-push` ワークフローを実行してpush 6. **サーバーに反映**: `/windmill-push` ワークフローを実行してpush

View File

@@ -1,39 +1,39 @@
--- ---
description: Windmillサーバーからワークフロー情報を取得する description: Windmillサーバーからワークフロー情報を取得する
--- ---
# Windmill Pull ワークフロー # Windmill Pull ワークフロー
Windmillサーバーから最新のスクリプトやフローの情報を取得する手順。 Windmillサーバーから最新のスクリプトやフローの情報を取得する手順。
wm-api.sh を使用してREST API経由で取得する。 wm-api.sh を使用してREST API経由で取得する。
// turbo-all // turbo-all
1. サーバーバージョンを確認 1. サーバーバージョンを確認
```bash ```bash
cd /home/akira/develop/windmill_workflow && ./wm-api.sh version cd /home/akira/develop/windmill_workflow && ./wm-api.sh version
``` ```
2. スクリプト一覧を取得 2. スクリプト一覧を取得
```bash ```bash
cd /home/akira/develop/windmill_workflow && ./wm-api.sh scripts cd /home/akira/develop/windmill_workflow && ./wm-api.sh scripts
``` ```
3. フロー一覧を取得 3. フロー一覧を取得
```bash ```bash
cd /home/akira/develop/windmill_workflow && ./wm-api.sh flows cd /home/akira/develop/windmill_workflow && ./wm-api.sh flows
``` ```
4. 特定のスクリプトの詳細を取得 4. 特定のスクリプトの詳細を取得
```bash ```bash
cd /home/akira/develop/windmill_workflow && ./wm-api.sh get-script <path> cd /home/akira/develop/windmill_workflow && ./wm-api.sh get-script <path>
``` ```
5. 特定のフローの詳細を取得 5. 特定のフローの詳細を取得
```bash ```bash
cd /home/akira/develop/windmill_workflow && ./wm-api.sh get-flow <path> cd /home/akira/develop/windmill_workflow && ./wm-api.sh get-flow <path>
``` ```
## 注意 ## 注意
- wmill CLIはCE版ではグローバルAPI認証の制限があるため使用できない - wmill CLIはCE版ではグローバルAPI認証の制限があるため使用できない
- 代わりに `wm-api.sh` を使用してREST APIで直接操作する - 代わりに `wm-api.sh` を使用してREST APIで直接操作する

View File

@@ -1,35 +1,35 @@
--- ---
description: ローカルのワークフローをWindmillサーバーにpushする description: ローカルのワークフローをWindmillサーバーにpushする
--- ---
# Windmill Push ワークフロー # Windmill Push ワークフロー
ローカルで作成・編集したスクリプトやフローをWindmillサーバーに反映する手順。 ローカルで作成・編集したスクリプトやフローをWindmillサーバーに反映する手順。
wm-api.sh を使用してREST API経由でpushする。 wm-api.sh を使用してREST API経由でpushする。
// turbo-all // turbo-all
1. 現在のサーバー接続を確認 1. 現在のサーバー接続を確認
```bash ```bash
cd /home/akira/develop/windmill_workflow && ./wm-api.sh version cd /home/akira/develop/windmill_workflow && ./wm-api.sh version
``` ```
2. 既存のスクリプト一覧を確認 2. 既存のスクリプト一覧を確認
```bash ```bash
cd /home/akira/develop/windmill_workflow && ./wm-api.sh scripts cd /home/akira/develop/windmill_workflow && ./wm-api.sh scripts
``` ```
3. 既存のフロー一覧を確認 3. 既存のフロー一覧を確認
```bash ```bash
cd /home/akira/develop/windmill_workflow && ./wm-api.sh flows cd /home/akira/develop/windmill_workflow && ./wm-api.sh flows
``` ```
4. スクリプトを作成する場合 (JSONファイルを用意して実行) 4. スクリプトを作成する場合 (JSONファイルを用意して実行)
```bash ```bash
cd /home/akira/develop/windmill_workflow && ./wm-api.sh create-script <script.json> cd /home/akira/develop/windmill_workflow && ./wm-api.sh create-script <script.json>
``` ```
## 注意 ## 注意
- wmill CLIはCE版ではグローバルAPI認証の制限があるため使用できない - wmill CLIはCE版ではグローバルAPI認証の制限があるため使用できない
- 代わりに `wm-api.sh` を使用してREST APIで直接操作する - 代わりに `wm-api.sh` を使用してREST APIで直接操作する
- Windmill MCP経由でも操作可能 - Windmill MCP経由でも操作可能

58
.gitignore vendored
View File

@@ -1,29 +1,29 @@
# Windmill secrets and sensitive data # Windmill secrets and sensitive data
*.secret.* *.secret.*
variables/ variables/
resources/ resources/
# Environment / secrets # Environment / secrets
.env .env
.env.local .env.local
# Python # Python
.venv/ .venv/
__pycache__/ __pycache__/
*.pyc *.pyc
# wmill CLI # wmill CLI
wmill-lock.yaml wmill-lock.yaml
# Node # Node
node_modules/ node_modules/
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# Editor # Editor
.vscode/ .vscode/
.idea/ .idea/
*.swp *.swp
*.swo *.swo

4
.serena/.gitignore vendored
View File

@@ -1,2 +1,2 @@
/cache /cache
/project.local.yml /project.local.yml

View File

@@ -1,152 +1,152 @@
# the name by which the project can be referenced within Serena # the name by which the project can be referenced within Serena
project_name: "windmill_workflow" project_name: "windmill_workflow"
# list of languages for which language servers are started; choose from: # list of languages for which language servers are started; choose from:
# al bash clojure cpp csharp # al bash clojure cpp csharp
# csharp_omnisharp dart elixir elm erlang # csharp_omnisharp dart elixir elm erlang
# fortran fsharp go groovy haskell # fortran fsharp go groovy haskell
# java julia kotlin lua markdown # java julia kotlin lua markdown
# matlab nix pascal perl php # matlab nix pascal perl php
# php_phpactor powershell python python_jedi r # php_phpactor powershell python python_jedi r
# rego ruby ruby_solargraph rust scala # rego ruby ruby_solargraph rust scala
# swift terraform toml typescript typescript_vts # swift terraform toml typescript typescript_vts
# vue yaml zig # vue yaml zig
# (This list may be outdated. For the current list, see values of Language enum here: # (This list may be outdated. For the current list, see values of Language enum here:
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py # https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) # For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
# Note: # Note:
# - For C, use cpp # - For C, use cpp
# - For JavaScript, use typescript # - For JavaScript, use typescript
# - For Free Pascal/Lazarus, use pascal # - For Free Pascal/Lazarus, use pascal
# Special requirements: # Special requirements:
# Some languages require additional setup/installations. # Some languages require additional setup/installations.
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers # See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
# When using multiple languages, the first language server that supports a given file will be used for that file. # When using multiple languages, the first language server that supports a given file will be used for that file.
# The first language is the default language and the respective language server will be used as a fallback. # The first language is the default language and the respective language server will be used as a fallback.
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. # Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
languages: languages:
- python - python
# the encoding used by text files in the project # the encoding used by text files in the project
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings # For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
encoding: "utf-8" encoding: "utf-8"
# line ending convention to use when writing source files. # line ending convention to use when writing source files.
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default) # Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings. # This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
line_ending: line_ending:
# The language backend to use for this project. # The language backend to use for this project.
# If not set, the global setting from serena_config.yml is used. # If not set, the global setting from serena_config.yml is used.
# Valid values: LSP, JetBrains # Valid values: LSP, JetBrains
# Note: the backend is fixed at startup. If a project with a different backend # Note: the backend is fixed at startup. If a project with a different backend
# is activated post-init, an error will be returned. # is activated post-init, an error will be returned.
language_backend: language_backend:
# whether to use project's .gitignore files to ignore files # whether to use project's .gitignore files to ignore files
ignore_all_files_in_gitignore: true ignore_all_files_in_gitignore: true
# list of additional paths to ignore in this project. # list of additional paths to ignore in this project.
# Same syntax as gitignore, so you can use * and **. # Same syntax as gitignore, so you can use * and **.
# Note: global ignored_paths from serena_config.yml are also applied additively. # Note: global ignored_paths from serena_config.yml are also applied additively.
ignored_paths: [] ignored_paths: []
# whether the project is in read-only mode # whether the project is in read-only mode
# If set to true, all editing tools will be disabled and attempts to use them will result in an error # If set to true, all editing tools will be disabled and attempts to use them will result in an error
# Added on 2025-04-18 # Added on 2025-04-18
read_only: false read_only: false
# list of tool names to exclude. # list of tool names to exclude.
# This extends the existing exclusions (e.g. from the global configuration) # This extends the existing exclusions (e.g. from the global configuration)
# #
# Below is the complete list of tools for convenience. # Below is the complete list of tools for convenience.
# To make sure you have the latest list of tools, and to view their descriptions, # To make sure you have the latest list of tools, and to view their descriptions,
# execute `uv run scripts/print_tool_overview.py`. # execute `uv run scripts/print_tool_overview.py`.
# #
# * `activate_project`: Activates a project by name. # * `activate_project`: Activates a project by name.
# * `check_onboarding_performed`: Checks whether project onboarding was already performed. # * `check_onboarding_performed`: Checks whether project onboarding was already performed.
# * `create_text_file`: Creates/overwrites a file in the project directory. # * `create_text_file`: Creates/overwrites a file in the project directory.
# * `delete_lines`: Deletes a range of lines within a file. # * `delete_lines`: Deletes a range of lines within a file.
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store. # * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
# * `execute_shell_command`: Executes a shell command. # * `execute_shell_command`: Executes a shell command.
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced. # * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type). # * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type). # * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. # * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file. # * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
# * `initial_instructions`: Gets the initial instructions for the current project. # * `initial_instructions`: Gets the initial instructions for the current project.
# Should only be used in settings where the system prompt cannot be set, # Should only be used in settings where the system prompt cannot be set,
# e.g. in clients you have no control over, like Claude Desktop. # e.g. in clients you have no control over, like Claude Desktop.
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol. # * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
# * `insert_at_line`: Inserts content at a given line in a file. # * `insert_at_line`: Inserts content at a given line in a file.
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol. # * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion). # * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
# * `list_memories`: Lists memories in Serena's project-specific memory store. # * `list_memories`: Lists memories in Serena's project-specific memory store.
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). # * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context). # * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
# * `read_file`: Reads a file within the project directory. # * `read_file`: Reads a file within the project directory.
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store. # * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
# * `remove_project`: Removes a project from the Serena configuration. # * `remove_project`: Removes a project from the Serena configuration.
# * `replace_lines`: Replaces a range of lines within a file with new content. # * `replace_lines`: Replaces a range of lines within a file with new content.
# * `replace_symbol_body`: Replaces the full definition of a symbol. # * `replace_symbol_body`: Replaces the full definition of a symbol.
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen. # * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
# * `search_for_pattern`: Performs a search for a pattern in the project. # * `search_for_pattern`: Performs a search for a pattern in the project.
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase. # * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
# * `switch_modes`: Activates modes by providing a list of their names # * `switch_modes`: Activates modes by providing a list of their names
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information. # * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task. # * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed. # * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store. # * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
excluded_tools: [] excluded_tools: []
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default). # list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
# This extends the existing inclusions (e.g. from the global configuration). # This extends the existing inclusions (e.g. from the global configuration).
included_optional_tools: [] included_optional_tools: []
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. # fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
# This cannot be combined with non-empty excluded_tools or included_optional_tools. # This cannot be combined with non-empty excluded_tools or included_optional_tools.
fixed_tools: [] fixed_tools: []
# list of mode names to that are always to be included in the set of active modes # list of mode names to that are always to be included in the set of active modes
# The full set of modes to be activated is base_modes + default_modes. # The full set of modes to be activated is base_modes + default_modes.
# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply. # If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this setting overrides the global configuration. # Otherwise, this setting overrides the global configuration.
# Set this to [] to disable base modes for this project. # Set this to [] to disable base modes for this project.
# Set this to a list of mode names to always include the respective modes for this project. # Set this to a list of mode names to always include the respective modes for this project.
base_modes: base_modes:
# list of mode names that are to be activated by default. # list of mode names that are to be activated by default.
# The full set of modes to be activated is base_modes + default_modes. # The full set of modes to be activated is base_modes + default_modes.
# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply. # If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this overrides the setting from the global configuration (serena_config.yml). # Otherwise, this overrides the setting from the global configuration (serena_config.yml).
# This setting can, in turn, be overridden by CLI parameters (--mode). # This setting can, in turn, be overridden by CLI parameters (--mode).
default_modes: default_modes:
# initial prompt for the project. It will always be given to the LLM upon activating the project # initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand). # (contrary to the memories, which are loaded on demand).
initial_prompt: "" initial_prompt: ""
# time budget (seconds) per tool call for the retrieval of additional symbol information # time budget (seconds) per tool call for the retrieval of additional symbol information
# such as docstrings or parameter information. # such as docstrings or parameter information.
# This overrides the corresponding setting in the global configuration; see the documentation there. # This overrides the corresponding setting in the global configuration; see the documentation there.
# If null or missing, use the setting from the global configuration. # If null or missing, use the setting from the global configuration.
symbol_info_budget: symbol_info_budget:
# list of regex patterns which, when matched, mark a memory entry as readonly. # list of regex patterns which, when matched, mark a memory entry as readonly.
# Extends the list from the global configuration, merging the two lists. # Extends the list from the global configuration, merging the two lists.
read_only_memory_patterns: [] read_only_memory_patterns: []
# list of regex patterns for memories to completely ignore. # list of regex patterns for memories to completely ignore.
# Matching memories will not appear in list_memories or activate_project output # Matching memories will not appear in list_memories or activate_project output
# and cannot be accessed via read_memory or write_memory. # and cannot be accessed via read_memory or write_memory.
# To access ignored memory files, use the read_file tool on the raw file path. # To access ignored memory files, use the read_file tool on the raw file path.
# Extends the list from the global configuration, merging the two lists. # Extends the list from the global configuration, merging the two lists.
# Example: ["_archive/.*", "_episodes/.*"] # Example: ["_archive/.*", "_episodes/.*"]
ignored_memory_patterns: [] ignored_memory_patterns: []
# advanced configuration option allowing to configure language server-specific options. # advanced configuration option allowing to configure language server-specific options.
# Maps the language key to the options. # Maps the language key to the options.
# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available. # Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
# No documentation on options means no options are available. # No documentation on options means no options are available.
ls_specific_settings: {} ls_specific_settings: {}

246
CLAUDE.md
View File

@@ -1,123 +1,123 @@
# Windmill Workflow プロジェクト # Windmill Workflow プロジェクト
白皇集落営農組合 統合システム向けの Windmill ワークフロー管理リポジトリ。 白皇集落営農組合 統合システム向けの Windmill ワークフロー管理リポジトリ。
## 環境 ## 環境
| 項目 | 値 | | 項目 | 値 |
|------|-----| |------|-----|
| Windmillサーバー | https://windmill.keinafarm.net | | Windmillサーバー | https://windmill.keinafarm.net |
| ワークスペース | `admins` | | ワークスペース | `admins` |
| APIトークン | `wm-api.sh` に設定済み | | APIトークン | `wm-api.sh` に設定済み |
| Gitリモート | https://gitea.keinafarm.net/akira/windmill_workflow.git | | Gitリモート | https://gitea.keinafarm.net/akira/windmill_workflow.git |
## 重要な制約 ## 重要な制約
- **`wmill` CLIは使用不可**CE版のグローバルAPI認証制限`wm-api.sh` または curl で直接REST APIを叩く - **`wmill` CLIは使用不可**CE版のグローバルAPI認証制限`wm-api.sh` または curl で直接REST APIを叩く
- **フローの PUT更新は 405**Windmill CE版の制限→ DELETE してから POST で再作成する - **フローの PUT更新は 405**Windmill CE版の制限→ DELETE してから POST で再作成する
- **`wmill.get_state()` はインラインフロースクリプトで永続化されない**→ 状態管理は必ず `wmill.get_variable()` / `set_variable()` を使う - **`wmill.get_state()` はインラインフロースクリプトで永続化されない**→ 状態管理は必ず `wmill.get_variable()` / `set_variable()` を使う
- **`python3` コマンドは Windows環境で失敗する**→ curl の出力は grep等で直接処理する - **`python3` コマンドは Windows環境で失敗する**→ curl の出力は grep等で直接処理する
## ディレクトリ構成 ## ディレクトリ構成
``` ```
windmill_workflow/ windmill_workflow/
├── flows/ # フロー定義JSON ├── flows/ # フロー定義JSON
│ ├── system_heartbeat.flow.json # Windmill自己診断フロー │ ├── system_heartbeat.flow.json # Windmill自己診断フロー
│ ├── shiraou_notification.flow.json # 白皇集落 変更通知フロー │ ├── shiraou_notification.flow.json # 白皇集落 変更通知フロー
│ └── mail_filter.flow.json # メールフィルタリングフロー │ └── mail_filter.flow.json # メールフィルタリングフロー
├── docs/ ├── docs/
│ └── shiraou/ # 白皇集落営農組合関連ドキュメント │ └── shiraou/ # 白皇集落営農組合関連ドキュメント
│ ├── 19_windmill_通知ワークフロー連携仕様.md # API仕様書 │ ├── 19_windmill_通知ワークフロー連携仕様.md # API仕様書
│ └── 20_マスタードキュメント_Windmill通知ワークフロー編.md # マスタードキュメント │ └── 20_マスタードキュメント_Windmill通知ワークフロー編.md # マスタードキュメント
├── .agent/workflows/ # エージェント作業手順 ├── .agent/workflows/ # エージェント作業手順
│ ├── windmill-push.md # サーバーへのpush手順 │ ├── windmill-push.md # サーバーへのpush手順
│ ├── windmill-pull.md # サーバーからのpull手順 │ ├── windmill-pull.md # サーバーからのpull手順
│ └── windmill-new-script.md # 新規スクリプト作成手順 │ └── windmill-new-script.md # 新規スクリプト作成手順
├── wm-api.sh # Windmill REST APIヘルパー ├── wm-api.sh # Windmill REST APIヘルパー
└── wmill.yaml # wmill設定defaultTs: bun └── wmill.yaml # wmill設定defaultTs: bun
``` ```
## 登録済みワークフロー ## 登録済みワークフロー
| パス | 概要 | スケジュール | | パス | 概要 | スケジュール |
|------|------|-------------| |------|------|-------------|
| `f/app_custom/system_heartbeat` | Windmill自己診断 | なし(手動) | | `f/app_custom/system_heartbeat` | Windmill自己診断 | なし(手動) |
| `f/shiraou/shiraou_notification` | 白皇集落営農 変更通知 | 5分毎JST | | `f/shiraou/shiraou_notification` | 白皇集落営農 変更通知 | 5分毎JST |
| `f/mail/mail_filter` | メールフィルタリングIMAP→LLM→LINE | 10分毎JST予定 | | `f/mail/mail_filter` | メールフィルタリングIMAP→LLM→LINE | 10分毎JST予定 |
| `u/antigravity/git_sync` | Git同期 | 30分毎 | | `u/antigravity/git_sync` | Git同期 | 30分毎 |
## wm-api.sh コマンド一覧 ## wm-api.sh コマンド一覧
```bash ```bash
./wm-api.sh version # サーバーバージョン確認 ./wm-api.sh version # サーバーバージョン確認
./wm-api.sh flows # フロー一覧 ./wm-api.sh flows # フロー一覧
./wm-api.sh schedules # スケジュール一覧 ./wm-api.sh schedules # スケジュール一覧
./wm-api.sh get-flow <path> # フロー詳細取得 ./wm-api.sh get-flow <path> # フロー詳細取得
./wm-api.sh create-flow <json-file> # フロー作成 ./wm-api.sh create-flow <json-file> # フロー作成
./wm-api.sh create-schedule <json-file> # スケジュール作成 ./wm-api.sh create-schedule <json-file> # スケジュール作成
./wm-api.sh run-flow <path> # フロー手動実行 ./wm-api.sh run-flow <path> # フロー手動実行
./wm-api.sh job-status <job-id> # ジョブ状態確認 ./wm-api.sh job-status <job-id> # ジョブ状態確認
``` ```
## フローのデプロイ手順 ## フローのデプロイ手順
```bash ```bash
# 1. flows/*.flow.json を編集 # 1. flows/*.flow.json を編集
# 2. 削除して再作成PUTは405のため # 2. 削除して再作成PUTは405のため
curl -sk -X DELETE \ curl -sk -X DELETE \
-H "Authorization: Bearer qLJ3VPZ61kTDiIwaUPUu1dXszGrsN1Dh" \ -H "Authorization: Bearer qLJ3VPZ61kTDiIwaUPUu1dXszGrsN1Dh" \
"https://windmill.keinafarm.net/api/w/admins/flows/delete/<path>" "https://windmill.keinafarm.net/api/w/admins/flows/delete/<path>"
curl -sk -X POST \ curl -sk -X POST \
-H "Authorization: Bearer qLJ3VPZ61kTDiIwaUPUu1dXszGrsN1Dh" \ -H "Authorization: Bearer qLJ3VPZ61kTDiIwaUPUu1dXszGrsN1Dh" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d @flows/<file>.flow.json \ -d @flows/<file>.flow.json \
"https://windmill.keinafarm.net/api/w/admins/flows/create" "https://windmill.keinafarm.net/api/w/admins/flows/create"
# 3. コミット&プッシュ # 3. コミット&プッシュ
git add flows/<file>.flow.json git add flows/<file>.flow.json
git commit -m "..." git commit -m "..."
git push origin main git push origin main
``` ```
## Windmill Variables ## Windmill Variables
| 変数パス | Secret | 説明 | | 変数パス | Secret | 説明 |
|---------|--------|------| |---------|--------|------|
| `u/admin/NOTIFICATION_API_KEY` | ✅ | shiraou APIキー | | `u/admin/NOTIFICATION_API_KEY` | ✅ | shiraou APIキー |
| `u/admin/LINE_CHANNEL_ACCESS_TOKEN` | ✅ | LINE Messaging APIトークン | | `u/admin/LINE_CHANNEL_ACCESS_TOKEN` | ✅ | LINE Messaging APIトークン |
| `u/admin/LINE_TO` | ✅ | LINE通知先IDユーザーまたはグループ | | `u/admin/LINE_TO` | ✅ | LINE通知先IDユーザーまたはグループ |
| `u/admin/SHIRAOU_LAST_CHECKED_AT` | ❌ | 前回確認時刻(ワークフローが自動更新) | | `u/admin/SHIRAOU_LAST_CHECKED_AT` | ❌ | 前回確認時刻(ワークフローが自動更新) |
| `u/admin/KEINASYSTEM_API_KEY` | ✅ | Keinasystem MAIL_API_KEY.envと同じ値 | | `u/admin/KEINASYSTEM_API_KEY` | ✅ | Keinasystem MAIL_API_KEY.envと同じ値 |
| `u/admin/KEINASYSTEM_API_URL` | ❌ | `https://keinafarm.net` | | `u/admin/KEINASYSTEM_API_URL` | ❌ | `https://keinafarm.net` |
| `u/admin/GEMINI_API_KEY` | ✅ | Google Gemini API キーLLM判定用 | | `u/admin/GEMINI_API_KEY` | ✅ | Google Gemini API キーLLM判定用 |
| `u/admin/GMAIL_IMAP_USER` | ✅ | GmailアカウントのIMAPユーザー名メールアドレス | | `u/admin/GMAIL_IMAP_USER` | ✅ | GmailアカウントのIMAPユーザー名メールアドレス |
| `u/admin/GMAIL_IMAP_PASSWORD` | ✅ | GmailのアプリパスワードIMAPパスワード | | `u/admin/GMAIL_IMAP_PASSWORD` | ✅ | GmailのアプリパスワードIMAPパスワード |
| `u/admin/MAIL_FILTER_GMAIL_LAST_UID` | ❌ | Gmail最終処理UIDワークフローが自動更新 | | `u/admin/MAIL_FILTER_GMAIL_LAST_UID` | ❌ | Gmail最終処理UIDワークフローが自動更新 |
| `u/admin/HOTMAIL_IMAP_USER` | ✅ | Hotmail IMAPユーザー名有効化時に登録 | | `u/admin/HOTMAIL_IMAP_USER` | ✅ | Hotmail IMAPユーザー名有効化時に登録 |
| `u/admin/HOTMAIL_IMAP_PASSWORD` | ✅ | Hotmail IMAPパスワード有効化時に登録 | | `u/admin/HOTMAIL_IMAP_PASSWORD` | ✅ | Hotmail IMAPパスワード有効化時に登録 |
| `u/admin/MAIL_FILTER_HOTMAIL_LAST_UID` | ❌ | Hotmail最終処理UID有効化時に登録 | | `u/admin/MAIL_FILTER_HOTMAIL_LAST_UID` | ❌ | Hotmail最終処理UID有効化時に登録 |
| `u/admin/XSERVER_IMAP_USER` | ✅ | Xserver IMAPユーザー名有効化時に登録 | | `u/admin/XSERVER_IMAP_USER` | ✅ | Xserver IMAPユーザー名有効化時に登録 |
| `u/admin/XSERVER_IMAP_PASSWORD` | ✅ | Xserver IMAPパスワード有効化時に登録 | | `u/admin/XSERVER_IMAP_PASSWORD` | ✅ | Xserver IMAPパスワード有効化時に登録 |
| `u/admin/MAIL_FILTER_XSERVER_LAST_UID` | ❌ | Xserver最終処理UID有効化時に登録 | | `u/admin/MAIL_FILTER_XSERVER_LAST_UID` | ❌ | Xserver最終処理UID有効化時に登録 |
## マスタードキュメント ## マスタードキュメント
- [白皇集落 Windmill通知ワークフロー](docs/shiraou/20_マスタードキュメント_Windmill通知ワークフロー編.md) - [白皇集落 Windmill通知ワークフロー](docs/shiraou/20_マスタードキュメント_Windmill通知ワークフロー編.md)
## メールフィルタリング — アカウント有効化手順 ## メールフィルタリング — アカウント有効化手順
Gmail → Hotmail → Xserver の順で段階的に有効化する。 Gmail → Hotmail → Xserver の順で段階的に有効化する。
### Gmail 初期設定 ### Gmail 初期設定
1. GoogleアカウントでIMAPを有効化Googleアカウント設定 → セキュリティ → アプリパスワード) 1. GoogleアカウントでIMAPを有効化Googleアカウント設定 → セキュリティ → アプリパスワード)
2. Windmill Variables に `GMAIL_IMAP_USER`, `GMAIL_IMAP_PASSWORD` を登録 2. Windmill Variables に `GMAIL_IMAP_USER`, `GMAIL_IMAP_PASSWORD` を登録
3. フローを手動実行(初回: 既存メールスキップ、最大UIDを記録 3. フローを手動実行(初回: 既存メールスキップ、最大UIDを記録
4. スケジュール登録10分毎 4. スケジュール登録10分毎
### Hotmail/Xserver 追加時 ### Hotmail/Xserver 追加時
1. Windmill Variables に対応する変数を登録 1. Windmill Variables に対応する変数を登録
2. `flows/mail_filter.flow.json` の該当アカウントの `"enabled": false``true` に変更 2. `flows/mail_filter.flow.json` の該当アカウントの `"enabled": false``true` に変更
3. フローを DELETE → POST で再デプロイ 3. フローを DELETE → POST で再デプロイ

View File

@@ -1,6 +1,6 @@
# alexa-api/.env # alexa-api/.env
# このファイルをコピーして .env を作成し、ALEXA_COOKIE に値を設定する # このファイルをコピーして .env を作成し、ALEXA_COOKIE に値を設定する
# .env は Git にコミットしない(.gitignore 参照) # .env は Git にコミットしない(.gitignore 参照)
# Amazon Cookieauth.js を実行して取得) # Amazon Cookieauth.js を実行して取得)
ALEXA_COOKIE=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ALEXA_COOKIE=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

View File

@@ -1,2 +1,2 @@
node_modules/ node_modules/
.env .env

View File

@@ -1,14 +1,14 @@
FROM node:20-alpine FROM node:20-alpine
WORKDIR /app WORKDIR /app
# 依存パッケージのみ先にコピー(キャッシュ活用) # 依存パッケージのみ先にコピー(キャッシュ活用)
COPY package*.json ./ COPY package*.json ./
RUN npm install --omit=dev RUN npm install --omit=dev
# ソースをコピー # ソースをコピー
COPY server.js . COPY server.js .
EXPOSE 3500 EXPOSE 3500
CMD ["node", "server.js"] CMD ["node", "server.js"]

View File

@@ -1,70 +1,70 @@
/** /**
* alexa-api/auth.js * alexa-api/auth.js
* ローカル PC で実行して Amazon Cookie を取得するスクリプト。 * ローカル PC で実行して Amazon Cookie を取得するスクリプト。
* *
* 使い方: * 使い方:
* cd alexa-api * cd alexa-api
* npm install alexa-cookie2 * npm install alexa-cookie2
* node auth.js * node auth.js
* *
* → ブラウザで http://localhost:3456 を開く * → ブラウザで http://localhost:3456 を開く
* → Amazon にログイン * → Amazon にログイン
* → コンソールに Cookie が表示される * → コンソールに Cookie が表示される
* → その値を Windmill Variable "u/admin/ALEXA_COOKIE" に登録 * → その値を Windmill Variable "u/admin/ALEXA_COOKIE" に登録
*/ */
const AlexaCookie = require('alexa-cookie2'); const AlexaCookie = require('alexa-cookie2');
const PROXY_PORT = 3456; const PROXY_PORT = 3456;
console.log('=============================================='); console.log('==============================================');
console.log(' Alexa Cookie 取得ツール'); console.log(' Alexa Cookie 取得ツール');
console.log('=============================================='); console.log('==============================================');
console.log(`\n認証プロキシを起動中... (port ${PROXY_PORT})`); console.log(`\n認証プロキシを起動中... (port ${PROXY_PORT})`);
console.log('\n【手順】'); console.log('\n【手順】');
console.log(` 1. ブラウザで http://localhost:${PROXY_PORT} を開く`); console.log(` 1. ブラウザで http://localhost:${PROXY_PORT} を開く`);
console.log(' 2. Amazon アカウントにログインamazon.co.jp'); console.log(' 2. Amazon アカウントにログインamazon.co.jp');
console.log(' 3. ログイン完了後、このコンソールに Cookie が表示される\n'); console.log(' 3. ログイン完了後、このコンソールに Cookie が表示される\n');
AlexaCookie.generateAlexaCookie( AlexaCookie.generateAlexaCookie(
'', '',
{ {
amazonPage: 'amazon.co.jp', amazonPage: 'amazon.co.jp',
acceptLanguage: 'ja-JP', acceptLanguage: 'ja-JP',
setupProxy: true, setupProxy: true,
proxyPort: PROXY_PORT, proxyPort: PROXY_PORT,
proxyOwnIp: '127.0.0.1', proxyOwnIp: '127.0.0.1',
proxyListenBind: '0.0.0.0', proxyListenBind: '0.0.0.0',
logger: (msg) => { logger: (msg) => {
if (!msg.includes('verbose') && !msg.includes('DEBUG')) { if (!msg.includes('verbose') && !msg.includes('DEBUG')) {
console.log('[auth]', msg); console.log('[auth]', msg);
} }
}, },
}, },
(err, cookie) => { (err, cookie) => {
// alexa-cookie2 はブラウザを開くよう促すメッセージも err として渡してくる // alexa-cookie2 はブラウザを開くよう促すメッセージも err として渡してくる
if (err) { if (err) {
const msg = err.message || String(err); const msg = err.message || String(err);
if (msg.includes('Please open')) { if (msg.includes('Please open')) {
// これは実際のエラーではなく「ブラウザで開いて」という指示 // これは実際のエラーではなく「ブラウザで開いて」という指示
console.log('\n>>> ブラウザで http://localhost:3456/ を開いて Amazon にログインしてください <<<\n'); console.log('\n>>> ブラウザで http://localhost:3456/ を開いて Amazon にログインしてください <<<\n');
// プロキシを生かしたまま待機process.exit しない) // プロキシを生かしたまま待機process.exit しない)
return; return;
} }
console.error('\n[ERROR] 認証失敗:', msg); console.error('\n[ERROR] 認証失敗:', msg);
process.exit(1); process.exit(1);
} }
console.log('\n=============================================='); console.log('\n==============================================');
console.log(' Cookie 取得成功!'); console.log(' Cookie 取得成功!');
console.log('=============================================='); console.log('==============================================');
console.log('\n以下の値を Windmill Variable に登録してください:'); console.log('\n以下の値を Windmill Variable に登録してください:');
console.log(' パス: u/admin/ALEXA_COOKIE'); console.log(' パス: u/admin/ALEXA_COOKIE');
console.log(' Secret: ONチェックを入れる'); console.log(' Secret: ONチェックを入れる');
console.log('\n--- Cookie ---'); console.log('\n--- Cookie ---');
console.log(cookie); console.log(cookie);
console.log('--- ここまで ---\n'); console.log('--- ここまで ---\n');
process.exit(0); process.exit(0);
} }
); );

View File

@@ -1,76 +1,76 @@
/** /**
* auth2.js - alexa-remote2 自身の認証フローを使う * auth2.js - alexa-remote2 自身の認証フローを使う
* alexa-cookie2 より確実(ライブラリ内蔵の OAuth プロキシを使用) * alexa-cookie2 より確実(ライブラリ内蔵の OAuth プロキシを使用)
* *
* 使い方: * 使い方:
* node auth2.js * node auth2.js
* → ブラウザで http://localhost:3001/ を開いて Amazon にログイン * → ブラウザで http://localhost:3001/ を開いて Amazon にログイン
* → 成功するとコンソールに Cookie が出力される → .env に保存 * → 成功するとコンソールに Cookie が出力される → .env に保存
*/ */
const AlexaRemote = require('alexa-remote2'); const AlexaRemote = require('alexa-remote2');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const PORT = 3001; const PORT = 3001;
console.log('=============================================='); console.log('==============================================');
console.log(' Alexa 認証ツール (alexa-remote2 内蔵プロキシ)'); console.log(' Alexa 認証ツール (alexa-remote2 内蔵プロキシ)');
console.log('=============================================='); console.log('==============================================');
console.log(`\nプロキシ起動中... (port ${PORT})`); console.log(`\nプロキシ起動中... (port ${PORT})`);
console.log(`\n【手順】ブラウザで http://localhost:${PORT}/ を開いて Amazon にログイン\n`); console.log(`\n【手順】ブラウザで http://localhost:${PORT}/ を開いて Amazon にログイン\n`);
const alexa = new AlexaRemote(); const alexa = new AlexaRemote();
alexa.init( alexa.init(
{ {
cookie: null, cookie: null,
alexaServiceHost: 'alexa.amazon.co.jp', alexaServiceHost: 'alexa.amazon.co.jp',
amazonPage: 'amazon.co.jp', amazonPage: 'amazon.co.jp',
acceptLanguage: 'ja-JP', acceptLanguage: 'ja-JP',
useWsMqtt: false, useWsMqtt: false,
setupProxy: true, setupProxy: true,
proxyOwnIp: '127.0.0.1', proxyOwnIp: '127.0.0.1',
proxyPort: PORT, proxyPort: PORT,
proxyListenBind: '0.0.0.0', proxyListenBind: '0.0.0.0',
logger: console.log, logger: console.log,
onSucess: (refreshedCookie) => { onSucess: (refreshedCookie) => {
// 認証成功時にリフレッシュされた Cookie を受け取る // 認証成功時にリフレッシュされた Cookie を受け取る
console.log('\n[onSucess] Cookie refreshed'); console.log('\n[onSucess] Cookie refreshed');
}, },
}, },
(err, result) => { (err, result) => {
if (err) { if (err) {
const msg = err.message || String(err); const msg = err.message || String(err);
if (msg.includes('open') && (msg.includes('http://') || msg.includes('localhost'))) { if (msg.includes('open') && (msg.includes('http://') || msg.includes('localhost'))) {
console.log(`\n>>> ブラウザで http://localhost:${PORT}/ を開いてください <<<\n`); console.log(`\n>>> ブラウザで http://localhost:${PORT}/ を開いてください <<<\n`);
// プロキシを生かしたまま待機 // プロキシを生かしたまま待機
return; return;
} }
console.error('\n[ERROR]', msg); console.error('\n[ERROR]', msg);
return; return;
} }
// 認証成功 // 認証成功
console.log('\n=============================================='); console.log('\n==============================================');
console.log(' 認証成功!'); console.log(' 認証成功!');
console.log('=============================================='); console.log('==============================================');
// alexa-remote2 内部の Cookie を取得 // alexa-remote2 内部の Cookie を取得
const cookie = alexa.cookie || alexa._options?.cookie; const cookie = alexa.cookie || alexa._options?.cookie;
if (cookie) { if (cookie) {
const cookieStr = typeof cookie === 'string' ? cookie : JSON.stringify(cookie); const cookieStr = typeof cookie === 'string' ? cookie : JSON.stringify(cookie);
console.log('\n以下を alexa-api/.env の ALEXA_COOKIE に設定してください:\n'); console.log('\n以下を alexa-api/.env の ALEXA_COOKIE に設定してください:\n');
console.log('ALEXA_COOKIE=' + cookieStr); console.log('ALEXA_COOKIE=' + cookieStr);
// .env に自動保存 // .env に自動保存
const envPath = path.join(__dirname, '.env'); const envPath = path.join(__dirname, '.env');
fs.writeFileSync(envPath, 'ALEXA_COOKIE=' + cookieStr + '\n'); fs.writeFileSync(envPath, 'ALEXA_COOKIE=' + cookieStr + '\n');
console.log(`\n.env に自動保存しました: ${envPath}`); console.log(`\n.env に自動保存しました: ${envPath}`);
} else { } else {
console.log('Cookie を取得できませんでした。alexa オブジェクト:', Object.keys(alexa)); console.log('Cookie を取得できませんでした。alexa オブジェクト:', Object.keys(alexa));
} }
process.exit(0); process.exit(0);
} }
); );

View File

@@ -1,76 +1,76 @@
/** /**
* auth3.js - メール/パスワードで直接認証2FA なしのアカウント向け) * auth3.js - メール/パスワードで直接認証2FA なしのアカウント向け)
* *
* 使い方: * 使い方:
* AMAZON_EMAIL="xxx@xxx.com" AMAZON_PASSWORD="yourpass" node auth3.js * AMAZON_EMAIL="xxx@xxx.com" AMAZON_PASSWORD="yourpass" node auth3.js
* *
* 成功すると .env を更新して終了します。 * 成功すると .env を更新して終了します。
*/ */
const AlexaRemote = require('alexa-remote2'); const AlexaRemote = require('alexa-remote2');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const email = process.env.AMAZON_EMAIL; const email = process.env.AMAZON_EMAIL;
const password = process.env.AMAZON_PASSWORD; const password = process.env.AMAZON_PASSWORD;
if (!email || !password) { if (!email || !password) {
console.error('[ERROR] 環境変数 AMAZON_EMAIL と AMAZON_PASSWORD を設定してください'); console.error('[ERROR] 環境変数 AMAZON_EMAIL と AMAZON_PASSWORD を設定してください');
console.error(' 例: AMAZON_EMAIL="xxx@xxx.com" AMAZON_PASSWORD="pass" node auth3.js'); console.error(' 例: AMAZON_EMAIL="xxx@xxx.com" AMAZON_PASSWORD="pass" node auth3.js');
process.exit(1); process.exit(1);
} }
console.log(`[INFO] ${email} でログイン試行中...`); console.log(`[INFO] ${email} でログイン試行中...`);
const alexa = new AlexaRemote(); const alexa = new AlexaRemote();
alexa.init( alexa.init(
{ {
email, email,
password, password,
alexaServiceHost: 'alexa.amazon.co.jp', alexaServiceHost: 'alexa.amazon.co.jp',
amazonPage: 'amazon.co.jp', amazonPage: 'amazon.co.jp',
acceptLanguage: 'ja-JP', acceptLanguage: 'ja-JP',
useWsMqtt: false, useWsMqtt: false,
setupProxy: false, setupProxy: false,
logger: (msg) => { logger: (msg) => {
if (!msg.includes('verbose') && !msg.includes('Bearer')) { if (!msg.includes('verbose') && !msg.includes('Bearer')) {
console.log('[alexa]', msg); console.log('[alexa]', msg);
} }
}, },
onSucess: (refreshedCookie) => { onSucess: (refreshedCookie) => {
saveCookie(refreshedCookie, 'onSucess refresh'); saveCookie(refreshedCookie, 'onSucess refresh');
}, },
}, },
(err) => { (err) => {
if (err) { if (err) {
console.error('[ERROR] 認証失敗(詳細):', err); console.error('[ERROR] 認証失敗(詳細):', err);
console.error('\n考えられる原因:'); console.error('\n考えられる原因:');
console.error(' - パスワードが違う'); console.error(' - パスワードが違う');
console.error(' - Amazon が CAPTCHA を要求している(後で再試行)'); console.error(' - Amazon が CAPTCHA を要求している(後で再試行)');
console.error(' - 2FA が実際は有効になっている'); console.error(' - 2FA が実際は有効になっている');
process.exit(1); process.exit(1);
} }
// 認証成功 // 認証成功
const cookie = alexa.cookie; const cookie = alexa.cookie;
saveCookie(cookie, 'init success'); saveCookie(cookie, 'init success');
process.exit(0); process.exit(0);
} }
); );
function saveCookie(cookie, source) { function saveCookie(cookie, source) {
if (!cookie) { if (!cookie) {
console.error(`[${source}] Cookie が空です`); console.error(`[${source}] Cookie が空です`);
return; return;
} }
const cookieStr = typeof cookie === 'string' ? cookie : JSON.stringify(cookie); const cookieStr = typeof cookie === 'string' ? cookie : JSON.stringify(cookie);
const envPath = path.join(__dirname, '.env'); const envPath = path.join(__dirname, '.env');
fs.writeFileSync(envPath, 'ALEXA_COOKIE=' + cookieStr + '\n'); fs.writeFileSync(envPath, 'ALEXA_COOKIE=' + cookieStr + '\n');
console.log('\n=============================================='); console.log('\n==============================================');
console.log(' 認証成功!'); console.log(' 認証成功!');
console.log('=============================================='); console.log('==============================================');
console.log('.env を更新しました:', envPath); console.log('.env を更新しました:', envPath);
console.log('Cookie length:', cookieStr.length); console.log('Cookie length:', cookieStr.length);
} }

View File

@@ -1,190 +1,190 @@
/** /**
* auth4.js - Amazon Japan OpenID フローを正しく再現するカスタム認証スクリプト * auth4.js - Amazon Japan OpenID フローを正しく再現するカスタム認証スクリプト
* alexa-cookie2 の古いエンドポイント問題を回避して直接フォームを処理する * alexa-cookie2 の古いエンドポイント問題を回避して直接フォームを処理する
*/ */
const https = require('https'); const https = require('https');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const EMAIL = process.env.AMAZON_EMAIL; const EMAIL = process.env.AMAZON_EMAIL;
const PASSWORD = process.env.AMAZON_PASSWORD; const PASSWORD = process.env.AMAZON_PASSWORD;
if (!EMAIL || !PASSWORD) { if (!EMAIL || !PASSWORD) {
console.error('[ERROR] 環境変数 AMAZON_EMAIL と AMAZON_PASSWORD を設定してください'); console.error('[ERROR] 環境変数 AMAZON_EMAIL と AMAZON_PASSWORD を設定してください');
process.exit(1); process.exit(1);
} }
const ALEXA_LOGIN_URL = const ALEXA_LOGIN_URL =
'https://www.amazon.co.jp/ap/signin?' + 'https://www.amazon.co.jp/ap/signin?' +
new URLSearchParams({ new URLSearchParams({
'openid.assoc_handle': 'amzn_dp_project_dee_jp', 'openid.assoc_handle': 'amzn_dp_project_dee_jp',
'openid.mode': 'checkid_setup', 'openid.mode': 'checkid_setup',
'openid.ns': 'http://specs.openid.net/auth/2.0', 'openid.ns': 'http://specs.openid.net/auth/2.0',
'openid.claimed_id': 'http://specs.openid.net/auth/2.0/identifier_select', 'openid.claimed_id': 'http://specs.openid.net/auth/2.0/identifier_select',
'openid.identity': 'http://specs.openid.net/auth/2.0/identifier_select', 'openid.identity': 'http://specs.openid.net/auth/2.0/identifier_select',
'openid.return_to': 'https://alexa.amazon.co.jp/api/apps/v1/token', 'openid.return_to': 'https://alexa.amazon.co.jp/api/apps/v1/token',
'pageId': 'amzn_dp_project_dee_jp', 'pageId': 'amzn_dp_project_dee_jp',
}).toString(); }).toString();
const USER_AGENT = const USER_AGENT =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36'; 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36';
let cookieJar = {}; let cookieJar = {};
function setCookies(setCookieHeaders) { function setCookies(setCookieHeaders) {
if (!setCookieHeaders) return; if (!setCookieHeaders) return;
const headers = Array.isArray(setCookieHeaders) ? setCookieHeaders : [setCookieHeaders]; const headers = Array.isArray(setCookieHeaders) ? setCookieHeaders : [setCookieHeaders];
for (const h of headers) { for (const h of headers) {
const [kv] = h.split(';'); const [kv] = h.split(';');
const [k, v] = kv.trim().split('='); const [k, v] = kv.trim().split('=');
if (k && v !== undefined) cookieJar[k.trim()] = v.trim(); if (k && v !== undefined) cookieJar[k.trim()] = v.trim();
} }
} }
function getCookieHeader() { function getCookieHeader() {
return Object.entries(cookieJar) return Object.entries(cookieJar)
.map(([k, v]) => `${k}=${v}`) .map(([k, v]) => `${k}=${v}`)
.join('; '); .join('; ');
} }
function request(url, options = {}) { function request(url, options = {}) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const parsed = new URL(url); const parsed = new URL(url);
const reqOpts = { const reqOpts = {
hostname: parsed.hostname, hostname: parsed.hostname,
path: parsed.pathname + parsed.search, path: parsed.pathname + parsed.search,
method: options.method || 'GET', method: options.method || 'GET',
headers: { headers: {
'User-Agent': USER_AGENT, 'User-Agent': USER_AGENT,
'Accept-Language': 'ja-JP,ja;q=0.9', 'Accept-Language': 'ja-JP,ja;q=0.9',
'Accept': 'text/html,application/xhtml+xml,*/*;q=0.8', 'Accept': 'text/html,application/xhtml+xml,*/*;q=0.8',
'Cookie': getCookieHeader(), 'Cookie': getCookieHeader(),
...(options.headers || {}), ...(options.headers || {}),
}, },
}; };
const req = https.request(reqOpts, (res) => { const req = https.request(reqOpts, (res) => {
setCookies(res.headers['set-cookie']); setCookies(res.headers['set-cookie']);
let body = ''; let body = '';
res.on('data', (d) => (body += d)); res.on('data', (d) => (body += d));
res.on('end', () => { res.on('end', () => {
resolve({ status: res.statusCode, headers: res.headers, body }); resolve({ status: res.statusCode, headers: res.headers, body });
}); });
}); });
req.on('error', reject); req.on('error', reject);
if (options.body) req.write(options.body); if (options.body) req.write(options.body);
req.end(); req.end();
}); });
} }
// HTML の hidden フィールドを抽出 // HTML の hidden フィールドを抽出
function extractHiddenFields(html) { function extractHiddenFields(html) {
const fields = {}; const fields = {};
const re = /<input[^>]+type=["']?hidden["']?[^>]*>/gi; const re = /<input[^>]+type=["']?hidden["']?[^>]*>/gi;
let match; let match;
while ((match = re.exec(html)) !== null) { while ((match = re.exec(html)) !== null) {
const tag = match[0]; const tag = match[0];
const name = (tag.match(/name=["']([^"']+)["']/) || [])[1]; const name = (tag.match(/name=["']([^"']+)["']/) || [])[1];
const value = (tag.match(/value=["']([^"']*)["']/) || ['', ''])[1]; const value = (tag.match(/value=["']([^"']*)["']/) || ['', ''])[1];
if (name) fields[name] = value; if (name) fields[name] = value;
} }
return fields; return fields;
} }
// フォームの action URL を抽出 // フォームの action URL を抽出
function extractFormAction(html) { function extractFormAction(html) {
const m = html.match(/id="ap_login_form"[^>]+action="([^"]+)"/); const m = html.match(/id="ap_login_form"[^>]+action="([^"]+)"/);
if (m) return m[1].replace(/&amp;/g, '&'); if (m) return m[1].replace(/&amp;/g, '&');
const m2 = html.match(/name="signIn"[^>]+action="([^"]+)"/); const m2 = html.match(/name="signIn"[^>]+action="([^"]+)"/);
if (m2) return m2[1].replace(/&amp;/g, '&'); if (m2) return m2[1].replace(/&amp;/g, '&');
return null; return null;
} }
async function main() { async function main() {
console.log('[1] ログインページ取得中...'); console.log('[1] ログインページ取得中...');
const page1 = await request(ALEXA_LOGIN_URL); const page1 = await request(ALEXA_LOGIN_URL);
console.log(` Status: ${page1.status}, Cookies: ${Object.keys(cookieJar).join(', ')}`); console.log(` Status: ${page1.status}, Cookies: ${Object.keys(cookieJar).join(', ')}`);
if (page1.status !== 200) { if (page1.status !== 200) {
console.error(`[ERROR] ログインページ取得失敗: ${page1.status}`); console.error(`[ERROR] ログインページ取得失敗: ${page1.status}`);
process.exit(1); process.exit(1);
} }
// フォーム情報を抽出 // フォーム情報を抽出
const action = extractFormAction(page1.body); const action = extractFormAction(page1.body);
const hidden = extractHiddenFields(page1.body); const hidden = extractHiddenFields(page1.body);
if (!action) { if (!action) {
console.error('[ERROR] ログインフォームが見つかりません。HTMLを確認します:'); console.error('[ERROR] ログインフォームが見つかりません。HTMLを確認します:');
console.error(page1.body.substring(0, 500)); console.error(page1.body.substring(0, 500));
process.exit(1); process.exit(1);
} }
console.log(`[2] フォーム送信先: ${action}`); console.log(`[2] フォーム送信先: ${action}`);
console.log(` Hidden fields: ${Object.keys(hidden).join(', ')}`); console.log(` Hidden fields: ${Object.keys(hidden).join(', ')}`);
// フォームデータ構築 // フォームデータ構築
const formData = new URLSearchParams({ const formData = new URLSearchParams({
...hidden, ...hidden,
email: EMAIL, email: EMAIL,
password: PASSWORD, password: PASSWORD,
rememberMe: 'true', rememberMe: 'true',
}).toString(); }).toString();
console.log('[3] 認証送信中...'); console.log('[3] 認証送信中...');
const page2 = await request(action, { const page2 = await request(action, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
'Referer': ALEXA_LOGIN_URL, 'Referer': ALEXA_LOGIN_URL,
}, },
body: formData, body: formData,
}); });
console.log(` Status: ${page2.status}`); console.log(` Status: ${page2.status}`);
console.log(` Location: ${page2.headers.location || '(none)'}`); console.log(` Location: ${page2.headers.location || '(none)'}`);
console.log(` Cookies after login: ${Object.keys(cookieJar).join(', ')}`); console.log(` Cookies after login: ${Object.keys(cookieJar).join(', ')}`);
// リダイレクトをたどる // リダイレクトをたどる
let current = page2; let current = page2;
let redirectCount = 0; let redirectCount = 0;
while (current.status >= 300 && current.status < 400 && current.headers.location && redirectCount < 10) { while (current.status >= 300 && current.status < 400 && current.headers.location && redirectCount < 10) {
const loc = current.headers.location; const loc = current.headers.location;
const nextUrl = loc.startsWith('http') ? loc : `https://www.amazon.co.jp${loc}`; const nextUrl = loc.startsWith('http') ? loc : `https://www.amazon.co.jp${loc}`;
console.log(`[${4 + redirectCount}] Redirect → ${nextUrl.substring(0, 80)}...`); console.log(`[${4 + redirectCount}] Redirect → ${nextUrl.substring(0, 80)}...`);
current = await request(nextUrl); current = await request(nextUrl);
console.log(` Status: ${current.status}, New Cookies: ${Object.keys(cookieJar).join(', ')}`); console.log(` Status: ${current.status}, New Cookies: ${Object.keys(cookieJar).join(', ')}`);
redirectCount++; redirectCount++;
} }
// 成功判定: at-acbjp または session-token が含まれているか // 成功判定: at-acbjp または session-token が含まれているか
const cookie = getCookieHeader(); const cookie = getCookieHeader();
const hasAlexaToken = cookie.includes('at-acbjp') || cookie.includes('session-token'); const hasAlexaToken = cookie.includes('at-acbjp') || cookie.includes('session-token');
if (!hasAlexaToken) { if (!hasAlexaToken) {
console.error('[ERROR] 認証に失敗しました。取得できたCookieに認証トークンが含まれていません。'); console.error('[ERROR] 認証に失敗しました。取得できたCookieに認証トークンが含まれていません。');
console.error('取得済みCookieキー:', Object.keys(cookieJar).join(', ')); console.error('取得済みCookieキー:', Object.keys(cookieJar).join(', '));
if (current.body.includes('captcha') || current.body.includes('CAPTCHA')) { if (current.body.includes('captcha') || current.body.includes('CAPTCHA')) {
console.error('※ CAPTCHA が要求されています。しばらく待ってから再試行してください。'); console.error('※ CAPTCHA が要求されています。しばらく待ってから再試行してください。');
} }
if (current.body.includes('password') && current.body.includes('error')) { if (current.body.includes('password') && current.body.includes('error')) {
console.error('※ パスワードが間違っている可能性があります。'); console.error('※ パスワードが間違っている可能性があります。');
} }
process.exit(1); process.exit(1);
} }
// .env に保存 // .env に保存
const envPath = path.join(__dirname, '.env'); const envPath = path.join(__dirname, '.env');
fs.writeFileSync(envPath, `ALEXA_COOKIE=${cookie}\n`); fs.writeFileSync(envPath, `ALEXA_COOKIE=${cookie}\n`);
console.log('\n=============================================='); console.log('\n==============================================');
console.log(' 認証成功!'); console.log(' 認証成功!');
console.log('=============================================='); console.log('==============================================');
console.log(`.env を保存しました: ${envPath}`); console.log(`.env を保存しました: ${envPath}`);
console.log(`Cookie 長さ: ${cookie.length} 文字`); console.log(`Cookie 長さ: ${cookie.length} 文字`);
} }
main().catch((err) => { main().catch((err) => {
console.error('[FATAL]', err); console.error('[FATAL]', err);
process.exit(1); process.exit(1);
}); });

View File

@@ -1,19 +1,19 @@
services: services:
alexa-api: alexa-api:
build: . build: .
container_name: alexa_api container_name: alexa_api
restart: unless-stopped restart: unless-stopped
env_file: env_file:
- .env - .env
environment: environment:
- PORT=3500 - PORT=3500
networks: networks:
- windmill_windmill-internal - windmill_windmill-internal
# 外部には公開しないWindmill ワーカーから内部ネットワーク経由でのみアクセス) # 外部には公開しないWindmill ワーカーから内部ネットワーク経由でのみアクセス)
# デバッグ時は以下のコメントを外す: # デバッグ時は以下のコメントを外す:
# ports: # ports:
# - "127.0.0.1:3500:3500" # - "127.0.0.1:3500:3500"
networks: networks:
windmill_windmill-internal: windmill_windmill-internal:
external: true external: true

File diff suppressed because it is too large Load Diff

View File

@@ -1,130 +1,130 @@
/** /**
* test_tts.js - TTS API テスト * test_tts.js - TTS API テスト
* node test_tts.js * node test_tts.js
*/ */
const https = require('https'); const https = require('https');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const envContent = fs.readFileSync(path.join(__dirname, '.env'), 'utf8'); const envContent = fs.readFileSync(path.join(__dirname, '.env'), 'utf8');
const COOKIE_STR = envContent.match(/ALEXA_COOKIE=(.+)/)[1].trim(); const COOKIE_STR = envContent.match(/ALEXA_COOKIE=(.+)/)[1].trim();
function makeRequest(url, options = {}, extraCookies = '') { function makeRequest(url, options = {}, extraCookies = '') {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const parsed = new URL(url); const parsed = new URL(url);
const allCookies = COOKIE_STR + (extraCookies ? '; ' + extraCookies : ''); const allCookies = COOKIE_STR + (extraCookies ? '; ' + extraCookies : '');
const reqOpts = { const reqOpts = {
hostname: parsed.hostname, hostname: parsed.hostname,
path: parsed.pathname + parsed.search, path: parsed.pathname + parsed.search,
method: options.method || 'GET', method: options.method || 'GET',
headers: { headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Accept': 'application/json, text/plain, */*', 'Accept': 'application/json, text/plain, */*',
'Accept-Language': 'ja-JP,ja;q=0.9', 'Accept-Language': 'ja-JP,ja;q=0.9',
'Cookie': allCookies, 'Cookie': allCookies,
...(options.headers || {}), ...(options.headers || {}),
}, },
}; };
const req = https.request(reqOpts, (res) => { const req = https.request(reqOpts, (res) => {
let body = ''; let body = '';
res.on('data', d => body += d); res.on('data', d => body += d);
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body })); res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body }));
}); });
req.on('error', reject); req.on('error', reject);
if (options.body) req.write(options.body); if (options.body) req.write(options.body);
req.end(); req.end();
}); });
} }
async function main() { async function main() {
// 1. CSRFトークン取得 // 1. CSRFトークン取得
console.log('[1] CSRF token取得...'); console.log('[1] CSRF token取得...');
const langRes = await makeRequest('https://alexa.amazon.co.jp/api/language'); const langRes = await makeRequest('https://alexa.amazon.co.jp/api/language');
const setCookies = langRes.headers['set-cookie'] || []; const setCookies = langRes.headers['set-cookie'] || [];
const csrfCookieStr = setCookies.find(c => c.startsWith('csrf=')); const csrfCookieStr = setCookies.find(c => c.startsWith('csrf='));
const csrfToken = csrfCookieStr ? csrfCookieStr.split('=')[1].split(';')[0] : null; const csrfToken = csrfCookieStr ? csrfCookieStr.split('=')[1].split(';')[0] : null;
console.log(' CSRF token:', csrfToken); console.log(' CSRF token:', csrfToken);
console.log(' Status:', langRes.status); console.log(' Status:', langRes.status);
if (!csrfToken) { console.error('CSRF token not found!'); process.exit(1); } if (!csrfToken) { console.error('CSRF token not found!'); process.exit(1); }
// 2. デバイス一覧取得 // 2. デバイス一覧取得
console.log('[2] デバイス一覧取得...'); console.log('[2] デバイス一覧取得...');
const devRes = await makeRequest('https://alexa.amazon.co.jp/api/devices-v2/device?cached=false'); const devRes = await makeRequest('https://alexa.amazon.co.jp/api/devices-v2/device?cached=false');
console.log(' Status:', devRes.status); console.log(' Status:', devRes.status);
const devices = JSON.parse(devRes.body).devices || []; const devices = JSON.parse(devRes.body).devices || [];
console.log(' Device count:', devices.length); console.log(' Device count:', devices.length);
// デバイス一覧表示 // デバイス一覧表示
devices.filter(d => d.deviceFamily !== 'TABLET').forEach(d => { devices.filter(d => d.deviceFamily !== 'TABLET').forEach(d => {
console.log(` - ${d.accountName} (type=${d.deviceType}, serial=${d.serialNumber})`); console.log(` - ${d.accountName} (type=${d.deviceType}, serial=${d.serialNumber})`);
}); });
// プレハブを探す // プレハブを探す
const target = devices.find(d => d.serialNumber === 'G0922H08525302K5'); // オフィスの右エコー(以前成功したデバイス) const target = devices.find(d => d.serialNumber === 'G0922H08525302K5'); // オフィスの右エコー(以前成功したデバイス)
console.log('\nTarget device:', target ? `${target.accountName}` : 'NOT FOUND'); console.log('\nTarget device:', target ? `${target.accountName}` : 'NOT FOUND');
if (!target) { process.exit(1); } if (!target) { process.exit(1); }
// 2.5. カスタマーIDを取得 // 2.5. カスタマーIDを取得
const bootstrapRes = await makeRequest('https://alexa.amazon.co.jp/api/bootstrap'); const bootstrapRes = await makeRequest('https://alexa.amazon.co.jp/api/bootstrap');
const bootstrap = JSON.parse(bootstrapRes.body); const bootstrap = JSON.parse(bootstrapRes.body);
const customerId = bootstrap.authentication?.customerId; const customerId = bootstrap.authentication?.customerId;
console.log(' Customer ID:', customerId); console.log(' Customer ID:', customerId);
// 3. TTSリクエスト新Cookie + Alexa.Speak + locale:'ja-JP' + 日本語テキスト) // 3. TTSリクエスト新Cookie + Alexa.Speak + locale:'ja-JP' + 日本語テキスト)
const sequenceObj = { const sequenceObj = {
'@type': 'com.amazon.alexa.behaviors.model.Sequence', '@type': 'com.amazon.alexa.behaviors.model.Sequence',
startNode: { startNode: {
'@type': 'com.amazon.alexa.behaviors.model.OpaquePayloadOperationNode', '@type': 'com.amazon.alexa.behaviors.model.OpaquePayloadOperationNode',
type: 'Alexa.Speak', type: 'Alexa.Speak',
operationPayload: { operationPayload: {
deviceType: target.deviceType, deviceType: target.deviceType,
deviceSerialNumber: target.serialNumber, deviceSerialNumber: target.serialNumber,
customerId: customerId, customerId: customerId,
locale: 'ja-JP', locale: 'ja-JP',
textToSpeak: '\u3053\u308c\u306f\u65e5\u672c\u8a9e\u306e\u30c6\u30b9\u30c8\u3067\u3059', // 「これは日本語のテストです」 textToSpeak: '\u3053\u308c\u306f\u65e5\u672c\u8a9e\u306e\u30c6\u30b9\u30c8\u3067\u3059', // 「これは日本語のテストです」
}, },
}, },
}; };
// non-ASCII を \uXXXX に強制エスケープ // non-ASCII を \uXXXX に強制エスケープ
// Amazon のパーサーが sequenceJson 内の raw UTF-8 を処理できない場合の回避策 // Amazon のパーサーが sequenceJson 内の raw UTF-8 を処理できない場合の回避策
const rawSequenceJson = JSON.stringify(sequenceObj).replace( const rawSequenceJson = JSON.stringify(sequenceObj).replace(
/[\u0080-\uffff]/g, /[\u0080-\uffff]/g,
c => '\\u' + ('0000' + c.charCodeAt(0).toString(16)).slice(-4) c => '\\u' + ('0000' + c.charCodeAt(0).toString(16)).slice(-4)
); );
const bodyObj = { const bodyObj = {
behaviorId: 'PREVIEW', behaviorId: 'PREVIEW',
sequenceJson: rawSequenceJson, sequenceJson: rawSequenceJson,
status: 'ENABLED', status: 'ENABLED',
}; };
const body = JSON.stringify(bodyObj); const body = JSON.stringify(bodyObj);
console.log('\n[3] TTS送信...'); console.log('\n[3] TTS送信...');
// 送信内容確認textToSpeakの部分が\uXXXXエスケープになっているか // 送信内容確認textToSpeakの部分が\uXXXXエスケープになっているか
const ttsIdx = body.indexOf('textToSpeak'); const ttsIdx = body.indexOf('textToSpeak');
console.log(' textToSpeak部分:', body.substring(ttsIdx, ttsIdx + 80)); console.log(' textToSpeak部分:', body.substring(ttsIdx, ttsIdx + 80));
const ttsRes = await makeRequest('https://alexa.amazon.co.jp/api/behaviors/preview', { const ttsRes = await makeRequest('https://alexa.amazon.co.jp/api/behaviors/preview', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'csrf': csrfToken, 'csrf': csrfToken,
'Referer': 'https://alexa.amazon.co.jp/spa/index.html', 'Referer': 'https://alexa.amazon.co.jp/spa/index.html',
'Origin': 'https://alexa.amazon.co.jp', 'Origin': 'https://alexa.amazon.co.jp',
}, },
body, body,
}, `csrf=${csrfToken}`); }, `csrf=${csrfToken}`);
console.log('TTS status:', ttsRes.status); console.log('TTS status:', ttsRes.status);
console.log('TTS response:', ttsRes.body.substring(0, 500)); console.log('TTS response:', ttsRes.body.substring(0, 500));
if (ttsRes.status === 200 || ttsRes.status === 202) { if (ttsRes.status === 200 || ttsRes.status === 202) {
console.log('\n成功エコーがしゃべるはずです。'); console.log('\n成功エコーがしゃべるはずです。');
} }
} }
main().catch(console.error); main().catch(console.error);

View File

@@ -1,459 +1,459 @@
# マスタードキュメント - Windmill通知ワークフロー編 # マスタードキュメント - Windmill通知ワークフロー編
> **最終更新**: 2026-02-21 > **最終更新**: 2026-02-21
> **対象システム**: windmill.keinafarm.netワークスペース: admins > **対象システム**: windmill.keinafarm.netワークスペース: admins
> **目的**: このドキュメントだけでWindmill通知ワークフローの全容を把握できること > **目的**: このドキュメントだけでWindmill通知ワークフローの全容を把握できること
--- ---
## 目次 ## 目次
1. [機能概要](#1-機能概要) 1. [機能概要](#1-機能概要)
2. [フロー設計](#2-フロー設計) 2. [フロー設計](#2-フロー設計)
3. [変更履歴取得API仕様](#3-変更履歴取得api仕様) 3. [変更履歴取得API仕様](#3-変更履歴取得api仕様)
4. [LINE通知仕様](#4-line通知仕様) 4. [LINE通知仕様](#4-line通知仕様)
5. [Windmill設定仕様](#5-windmill設定仕様) 5. [Windmill設定仕様](#5-windmill設定仕様)
6. [状態管理仕様](#6-状態管理仕様) 6. [状態管理仕様](#6-状態管理仕様)
7. [設計判断と制約](#7-設計判断と制約) 7. [設計判断と制約](#7-設計判断と制約)
8. [運用手順](#8-運用手順) 8. [運用手順](#8-運用手順)
9. [ソースファイル索引](#9-ソースファイル索引) 9. [ソースファイル索引](#9-ソースファイル索引)
10. [更新履歴](#更新履歴) 10. [更新履歴](#更新履歴)
--- ---
## 1. 機能概要 ## 1. 機能概要
### 目的 ### 目的
`shiraou.keinafarm.net`(白皇集落営農組合 統合システムで発生した予約・実績の変更を、LINE Messaging API 経由で管理者に通知する。 `shiraou.keinafarm.net`(白皇集落営農組合 統合システムで発生した予約・実績の変更を、LINE Messaging API 経由で管理者に通知する。
### ユーザーフロー ### ユーザーフロー
``` ```
統合システム上で予約・実績の変更が発生 統合システム上で予約・実績の変更が発生
└→ Windmill が5分毎にポーリング └→ Windmill が5分毎にポーリング
└→ 変更があればLINEにプッシュ通知 └→ 変更があればLINEにプッシュ通知
└→ 管理者がLINEで変更内容を確認 └→ 管理者がLINEで変更内容を確認
``` ```
### 通知される操作一覧 ### 通知される操作一覧
| 区分 | 操作 | 説明 | | 区分 | 操作 | 説明 |
|------|------|------| |------|------|------|
| 予約 | `create` | 予約が作成された | | 予約 | `create` | 予約が作成された |
| 予約 | `update` | 予約の日時・機械が変更された | | 予約 | `update` | 予約の日時・機械が変更された |
| 予約 | `cancel` | 予約がキャンセルされた | | 予約 | `cancel` | 予約がキャンセルされた |
| 実績 | `create` | 実績が登録された | | 実績 | `create` | 実績が登録された |
| 実績 | `update` | 実績が修正された | | 実績 | `update` | 実績が修正された |
| 実績 | `delete` | 実績が削除された | | 実績 | `delete` | 実績が削除された |
--- ---
## 2. フロー設計 ## 2. フロー設計
### Windmillフロー情報 ### Windmillフロー情報
| 項目 | 値 | | 項目 | 値 |
|------|-----| |------|-----|
| パス | `f/shiraou/shiraou_notification` | | パス | `f/shiraou/shiraou_notification` |
| 概要 | 白皇集落営農 変更通知 | | 概要 | 白皇集落営農 変更通知 |
| ステップ数 | 1単一Pythonスクリプト | | ステップ数 | 1単一Pythonスクリプト |
| スケジュール | `0 */5 * * * *`5分毎、JST | | スケジュール | `0 */5 * * * *`5分毎、JST |
| スケジュールパス | `f/shiraou/shiraou_notification_every_5min` | | スケジュールパス | `f/shiraou/shiraou_notification_every_5min` |
### 実行フロー(擬似コード) ### 実行フロー(擬似コード)
```python ```python
# Step 1: シークレット・前回実行時刻を取得 # Step 1: シークレット・前回実行時刻を取得
api_key = get_variable("u/admin/NOTIFICATION_API_KEY") api_key = get_variable("u/admin/NOTIFICATION_API_KEY")
line_token = get_variable("u/admin/LINE_CHANNEL_ACCESS_TOKEN") line_token = get_variable("u/admin/LINE_CHANNEL_ACCESS_TOKEN")
line_to = get_variable("u/admin/LINE_TO") line_to = get_variable("u/admin/LINE_TO")
last_checked = get_variable("u/admin/SHIRAOU_LAST_CHECKED_AT") # 空なら初回 last_checked = get_variable("u/admin/SHIRAOU_LAST_CHECKED_AT") # 空なら初回
since = last_checked or (now() - 10) since = last_checked or (now() - 10)
# Step 2: 変更履歴を取得 # Step 2: 変更履歴を取得
response = GET "https://shiraou.keinafarm.net/reservations/api/changes/?since={since}" response = GET "https://shiraou.keinafarm.net/reservations/api/changes/?since={since}"
headers: { "X-API-Key": api_key } headers: { "X-API-Key": api_key }
# Step 3: 変更があればLINE通知 # Step 3: 変更があればLINE通知
if response.reservations or response.usages: if response.reservations or response.usages:
message = format_message(response) message = format_message(response)
POST "https://api.line.me/v2/bot/message/push" POST "https://api.line.me/v2/bot/message/push"
body: { "to": line_to, "messages": [{"type": "text", "text": message}] } body: { "to": line_to, "messages": [{"type": "text", "text": message}] }
# Step 4: 前回実行時刻を更新(正常完了時のみ) # Step 4: 前回実行時刻を更新(正常完了時のみ)
set_variable("u/admin/SHIRAOU_LAST_CHECKED_AT", response.checked_at) set_variable("u/admin/SHIRAOU_LAST_CHECKED_AT", response.checked_at)
``` ```
### エラー時の挙動 ### エラー時の挙動
- API呼び出し失敗、LINE送信失敗のいずれでも例外が発生 - API呼び出し失敗、LINE送信失敗のいずれでも例外が発生
- 例外が発生した場合、`SHIRAOU_LAST_CHECKED_AT` は更新されない - 例外が発生した場合、`SHIRAOU_LAST_CHECKED_AT` は更新されない
- 次回実行時に同じ `since` で再試行される(変更の取り漏れ防止) - 次回実行時に同じ `since` で再試行される(変更の取り漏れ防止)
--- ---
## 3. 変更履歴取得API仕様 ## 3. 変更履歴取得API仕様
### エンドポイント ### エンドポイント
``` ```
GET https://shiraou.keinafarm.net/reservations/api/changes/ GET https://shiraou.keinafarm.net/reservations/api/changes/
``` ```
### 認証 ### 認証
``` ```
X-API-Key: <NOTIFICATION_API_KEY> X-API-Key: <NOTIFICATION_API_KEY>
``` ```
APIキーが不正な場合は `401 Unauthorized` が返る。 APIキーが不正な場合は `401 Unauthorized` が返る。
### クエリパラメータ ### クエリパラメータ
| パラメータ | 型 | 必須 | 説明 | | パラメータ | 型 | 必須 | 説明 |
|-----------|-----|------|------| |-----------|-----|------|------|
| `since` | ISO8601文字列 | 必須 | この日時以降の変更を取得する | | `since` | ISO8601文字列 | 必須 | この日時以降の変更を取得する |
**`since` の形式例**: **`since` の形式例**:
- `2026-02-21T10:00:00+09:00`(タイムゾーン付き、推奨) - `2026-02-21T10:00:00+09:00`(タイムゾーン付き、推奨)
- `2026-02-21T10:00:00`ナイーブ、JSTとして扱われる - `2026-02-21T10:00:00`ナイーブ、JSTとして扱われる
### レスポンス200 OK ### レスポンス200 OK
```json ```json
{ {
"checked_at": "2026-02-21T12:00:00+09:00", "checked_at": "2026-02-21T12:00:00+09:00",
"since": "2026-02-21T10:00:00+09:00", "since": "2026-02-21T10:00:00+09:00",
"reservations": [ "reservations": [
{ {
"operation": "create", "operation": "create",
"reservation_id": 123, "reservation_id": 123,
"user_name": "田中太郎", "user_name": "田中太郎",
"machine_name": "トラクター", "machine_name": "トラクター",
"start_at": "2026-02-25T09:00:00+09:00", "start_at": "2026-02-25T09:00:00+09:00",
"end_at": "2026-02-25T12:00:00+09:00", "end_at": "2026-02-25T12:00:00+09:00",
"operated_at": "2026-02-21T11:30:00+09:00", "operated_at": "2026-02-21T11:30:00+09:00",
"operator_name": "田中太郎", "operator_name": "田中太郎",
"reason": "" "reason": ""
} }
], ],
"usages": [ "usages": [
{ {
"operation": "update", "operation": "update",
"usage_id": 456, "usage_id": 456,
"user_name": "山田次郎", "user_name": "山田次郎",
"machine_name": "コンバイン", "machine_name": "コンバイン",
"amount": 4.0, "amount": 4.0,
"unit": "時間", "unit": "時間",
"start_at": "2026-02-20T08:00:00+09:00", "start_at": "2026-02-20T08:00:00+09:00",
"end_at": "2026-02-20T12:00:00+09:00", "end_at": "2026-02-20T12:00:00+09:00",
"operated_at": "2026-02-21T11:55:00+09:00", "operated_at": "2026-02-21T11:55:00+09:00",
"operator_name": "管理者A", "operator_name": "管理者A",
"reason": "記録ミスのため修正" "reason": "記録ミスのため修正"
} }
] ]
} }
``` ```
### 変更なし時のレスポンス ### 変更なし時のレスポンス
```json ```json
{ {
"checked_at": "2026-02-21T12:05:00+09:00", "checked_at": "2026-02-21T12:05:00+09:00",
"since": "2026-02-21T12:00:00+09:00", "since": "2026-02-21T12:00:00+09:00",
"reservations": [], "reservations": [],
"usages": [] "usages": []
} }
``` ```
### エラーレスポンス ### エラーレスポンス
| ステータス | 原因 | | ステータス | 原因 |
|-----------|------| |-----------|------|
| `401 Unauthorized` | APIキーが不正または未設定 | | `401 Unauthorized` | APIキーが不正または未設定 |
| `400 Bad Request` | `since` パラメータが欠落または不正な日時形式 | | `400 Bad Request` | `since` パラメータが欠落または不正な日時形式 |
--- ---
## 4. LINE通知仕様 ## 4. LINE通知仕様
### 使用API ### 使用API
LINE Messaging API - Push Message LINE Messaging API - Push Message
``` ```
POST https://api.line.me/v2/bot/message/push POST https://api.line.me/v2/bot/message/push
Authorization: Bearer <LINE_CHANNEL_ACCESS_TOKEN> Authorization: Bearer <LINE_CHANNEL_ACCESS_TOKEN>
Content-Type: application/json Content-Type: application/json
``` ```
### リクエストボディ ### リクエストボディ
```json ```json
{ {
"to": "<LINE_TO>", "to": "<LINE_TO>",
"messages": [ "messages": [
{ {
"type": "text", "type": "text",
"text": "<フォーマット済みメッセージ>" "text": "<フォーマット済みメッセージ>"
} }
] ]
} }
``` ```
### メッセージフォーマット ### メッセージフォーマット
``` ```
📋 営農システム 変更通知 📋 営農システム 変更通知
🟢 予約作成 🟢 予約作成
機械: トラクター 機械: トラクター
利用者: 田中太郎 利用者: 田中太郎
日時: 2026-02-25 09:00 〜 2026-02-25 12:00 日時: 2026-02-25 09:00 〜 2026-02-25 12:00
🔴 予約キャンセル 🔴 予約キャンセル
機械: 田植機 機械: 田植機
利用者: 佐藤花子 利用者: 佐藤花子
日時: 2026-02-22 08:00 〜 2026-02-22 17:00 日時: 2026-02-22 08:00 〜 2026-02-22 17:00
🔵 実績修正 🔵 実績修正
機械: コンバイン 機械: コンバイン
利用者: 山田次郎 利用者: 山田次郎
利用量: 4.0時間 利用量: 4.0時間
日: 2026-02-20 日: 2026-02-20
理由: 記録ミスのため修正 理由: 記録ミスのため修正
``` ```
### アイコン規則 ### アイコン規則
| アイコン | 意味 | | アイコン | 意味 |
|---------|------| |---------|------|
| 🟢 | 作成create / 予約作成 / 実績登録) | | 🟢 | 作成create / 予約作成 / 実績登録) |
| 🔵 | 変更update / 予約変更 / 実績修正) | | 🔵 | 変更update / 予約変更 / 実績修正) |
| 🔴 | 削除・キャンセルcancel / delete | | 🔴 | 削除・キャンセルcancel / delete |
### 通知先の種別 ### 通知先の種別
`LINE_TO` にはユーザーIDまたはグループIDを設定する。 `LINE_TO` にはユーザーIDまたはグループIDを設定する。
| 種別 | ID形式 | | 種別 | ID形式 |
|------|-------| |------|-------|
| ユーザー | `U` で始まる文字列 | | ユーザー | `U` で始まる文字列 |
| グループ | `C` で始まる文字列 | | グループ | `C` で始まる文字列 |
--- ---
## 5. Windmill設定仕様 ## 5. Windmill設定仕様
### Windmill Variablesシークレット ### Windmill Variablesシークレット
以下の変数を Windmill UIVariables ページ)で作成・管理する。 以下の変数を Windmill UIVariables ページ)で作成・管理する。
| 変数パス | Secret | 説明 | 取得元 | | 変数パス | Secret | 説明 | 取得元 |
|---------|--------|------|-------| |---------|--------|------|-------|
| `u/admin/NOTIFICATION_API_KEY` | ✅ | shiraou.keinafarm.net のAPIキー | Djangoサーバー側 `NOTIFICATION_API_KEY` 環境変数と同一値 | | `u/admin/NOTIFICATION_API_KEY` | ✅ | shiraou.keinafarm.net のAPIキー | Djangoサーバー側 `NOTIFICATION_API_KEY` 環境変数と同一値 |
| `u/admin/LINE_CHANNEL_ACCESS_TOKEN` | ✅ | LINE Messaging API チャネルアクセストークン | LINE Developers Console | | `u/admin/LINE_CHANNEL_ACCESS_TOKEN` | ✅ | LINE Messaging API チャネルアクセストークン | LINE Developers Console |
| `u/admin/LINE_TO` | ✅ | 通知先のLINEユーザーIDまたはグループID | LINE webhook / profile API | | `u/admin/LINE_TO` | ✅ | 通知先のLINEユーザーIDまたはグループID | LINE webhook / profile API |
| `u/admin/SHIRAOU_LAST_CHECKED_AT` | ❌ | 前回確認時刻(ワークフローが自動更新) | ワークフローが自動管理(初期値: 空文字) | | `u/admin/SHIRAOU_LAST_CHECKED_AT` | ❌ | 前回確認時刻(ワークフローが自動更新) | ワークフローが自動管理(初期値: 空文字) |
### Django側の設定shiraou.keinafarm.net ### Django側の設定shiraou.keinafarm.net
`docker-compose.yml` に以下の環境変数を追加: `docker-compose.yml` に以下の環境変数を追加:
```yaml ```yaml
environment: environment:
- NOTIFICATION_API_KEY=<NOTIFICATION_API_KEYと同一の値> - NOTIFICATION_API_KEY=<NOTIFICATION_API_KEYと同一の値>
``` ```
APIキー生成コマンド: APIキー生成コマンド:
```bash ```bash
openssl rand -hex 32 openssl rand -hex 32
``` ```
--- ---
## 6. 状態管理仕様 ## 6. 状態管理仕様
### 状態変数: `SHIRAOU_LAST_CHECKED_AT` ### 状態変数: `SHIRAOU_LAST_CHECKED_AT`
| 項目 | 内容 | | 項目 | 内容 |
|------|------| |------|------|
| 格納場所 | Windmill Variable `u/admin/SHIRAOU_LAST_CHECKED_AT` | | 格納場所 | Windmill Variable `u/admin/SHIRAOU_LAST_CHECKED_AT` |
| 型 | ISO8601文字列例: `2026-02-21T15:30:00+09:00` | | 型 | ISO8601文字列例: `2026-02-21T15:30:00+09:00` |
| 初期値 | 空文字(初回実行時は `現在時刻 - 10分` を使用) | | 初期値 | 空文字(初回実行時は `現在時刻 - 10分` を使用) |
| 更新タイミング | フロー正常完了時のみ、APIレスポンスの `checked_at` を保存 | | 更新タイミング | フロー正常完了時のみ、APIレスポンスの `checked_at` を保存 |
| 参照タイミング | フロー実行開始時、`since` パラメータとして使用 | | 参照タイミング | フロー実行開始時、`since` パラメータとして使用 |
### 重複通知防止の仕組み ### 重複通知防止の仕組み
``` ```
実行1: since=T0, checked_at=T1 → LAST_CHECKED_AT = T1 実行1: since=T0, checked_at=T1 → LAST_CHECKED_AT = T1
実行2: since=T1, checked_at=T2 → T1以降の変更のみ取得 実行2: since=T1, checked_at=T2 → T1以降の変更のみ取得
``` ```
- `since``checked_at`APIが確認した時刻を使うことで、変更の取りこぼしが発生しない - `since``checked_at`APIが確認した時刻を使うことで、変更の取りこぼしが発生しない
- `since`(リクエストに渡した時刻)ではなく `checked_at`(サーバーが確認した時刻)を保存するのがポイント - `since`(リクエストに渡した時刻)ではなく `checked_at`(サーバーが確認した時刻)を保存するのがポイント
### 旧実装との違い(トラブルシュート記録) ### 旧実装との違い(トラブルシュート記録)
| | 旧実装 | 現実装 | | | 旧実装 | 現実装 |
|---|--------|--------| |---|--------|--------|
| 状態保存方法 | `wmill.get_state()` / `set_state()` | `wmill.get_variable()` / `set_variable()` | | 状態保存方法 | `wmill.get_state()` / `set_state()` | `wmill.get_variable()` / `set_variable()` |
| 問題 | フローのインラインスクリプトでは実行をまたいで保存されない | - | | 問題 | フローのインラインスクリプトでは実行をまたいで保存されない | - |
| 症状 | 毎回 `since = 現在 - 10分` になり、毎回通知が飛ぶ | 正常動作 | | 症状 | 毎回 `since = 現在 - 10分` になり、毎回通知が飛ぶ | 正常動作 |
--- ---
## 7. 設計判断と制約 ## 7. 設計判断と制約
### 絶対に変えてはいけない制約 ### 絶対に変えてはいけない制約
1. **`SHIRAOU_LAST_CHECKED_AT` には `checked_at` を保存すること**`since` を保存しない) 1. **`SHIRAOU_LAST_CHECKED_AT` には `checked_at` を保存すること**`since` を保存しない)
- `checked_at`: APIサーバーが「この時刻まで確認した」という保証付きの時刻 - `checked_at`: APIサーバーが「この時刻まで確認した」という保証付きの時刻
- 同じ変更が2度通知されることを防ぐ - 同じ変更が2度通知されることを防ぐ
2. **状態更新は正常完了後のみ行うこと** 2. **状態更新は正常完了後のみ行うこと**
- API呼び出し失敗・LINE送信失敗時は `SHIRAOU_LAST_CHECKED_AT` を更新しない - API呼び出し失敗・LINE送信失敗時は `SHIRAOU_LAST_CHECKED_AT` を更新しない
- 次回実行で同じ範囲を再取得し、通知の取り漏れを防ぐ - 次回実行で同じ範囲を再取得し、通知の取り漏れを防ぐ
3. **`wmill.get_state()` は使用しないこと** 3. **`wmill.get_state()` は使用しないこと**
- Windmillのインラインフロースクリプトでは実行をまたいで保存されない - Windmillのインラインフロースクリプトでは実行をまたいで保存されない
- 状態管理は必ず Windmill Variable を使うこと - 状態管理は必ず Windmill Variable を使うこと
### 設計判断 ### 設計判断
| 判断 | 理由 | | 判断 | 理由 |
|------|------| |------|------|
| 単一ステップフロー | 状態管理を1か所に集約するため。`get_state()`/`set_state()` のスコープ問題を回避 | | 単一ステップフロー | 状態管理を1か所に集約するため。`get_state()`/`set_state()` のスコープ問題を回避 |
| SSL検証スキップ | shiraou.keinafarm.net が自己署名証明書の可能性があるため | | SSL検証スキップ | shiraou.keinafarm.net が自己署名証明書の可能性があるため |
| タイムアウト 30秒 | 農業用途で多少の応答遅延を許容しつつ、無限待機を防ぐ | | タイムアウト 30秒 | 農業用途で多少の応答遅延を許容しつつ、無限待機を防ぐ |
| 5分ポーリング間隔 | 農業機械の予約用途では数分の遅延は許容範囲。リアルタイム不要 | | 5分ポーリング間隔 | 農業機械の予約用途では数分の遅延は許容範囲。リアルタイム不要 |
--- ---
## 8. 運用手順 ## 8. 運用手順
### フローを手動実行 ### フローを手動実行
```bash ```bash
curl -sk -X POST \ curl -sk -X POST \
-H "Authorization: Bearer qLJ3VPZ61kTDiIwaUPUu1dXszGrsN1Dh" \ -H "Authorization: Bearer qLJ3VPZ61kTDiIwaUPUu1dXszGrsN1Dh" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{}' \ -d '{}' \
"https://windmill.keinafarm.net/api/w/admins/jobs/run/f/f/shiraou/shiraou_notification" "https://windmill.keinafarm.net/api/w/admins/jobs/run/f/f/shiraou/shiraou_notification"
``` ```
### 動作確認curlで直接API呼び出し ### 動作確認curlで直接API呼び出し
```bash ```bash
# 変更なし確認 # 変更なし確認
curl -H "X-API-Key: <キー>" \ curl -H "X-API-Key: <キー>" \
"https://shiraou.keinafarm.net/reservations/api/changes/?since=2026-02-21T11:59:00%2B09:00" "https://shiraou.keinafarm.net/reservations/api/changes/?since=2026-02-21T11:59:00%2B09:00"
# 広い範囲で変更取得(初期確認用) # 広い範囲で変更取得(初期確認用)
curl -H "X-API-Key: <キー>" \ curl -H "X-API-Key: <キー>" \
"https://shiraou.keinafarm.net/reservations/api/changes/?since=2026-01-01T00:00:00" "https://shiraou.keinafarm.net/reservations/api/changes/?since=2026-01-01T00:00:00"
``` ```
### フローの更新デプロイ手順 ### フローの更新デプロイ手順
```bash ```bash
cd /path/to/windmill_workflow cd /path/to/windmill_workflow
# 1. flows/shiraou_notification.flow.json を編集 # 1. flows/shiraou_notification.flow.json を編集
# 2. 既存フローを削除して再作成PUTは405のため # 2. 既存フローを削除して再作成PUTは405のため
curl -sk -X DELETE \ curl -sk -X DELETE \
-H "Authorization: Bearer qLJ3VPZ61kTDiIwaUPUu1dXszGrsN1Dh" \ -H "Authorization: Bearer qLJ3VPZ61kTDiIwaUPUu1dXszGrsN1Dh" \
"https://windmill.keinafarm.net/api/w/admins/flows/delete/f/shiraou/shiraou_notification" "https://windmill.keinafarm.net/api/w/admins/flows/delete/f/shiraou/shiraou_notification"
curl -sk -X POST \ curl -sk -X POST \
-H "Authorization: Bearer qLJ3VPZ61kTDiIwaUPUu1dXszGrsN1Dh" \ -H "Authorization: Bearer qLJ3VPZ61kTDiIwaUPUu1dXszGrsN1Dh" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d @flows/shiraou_notification.flow.json \ -d @flows/shiraou_notification.flow.json \
"https://windmill.keinafarm.net/api/w/admins/flows/create" "https://windmill.keinafarm.net/api/w/admins/flows/create"
# 3. スケジュールは再作成不要(フローの削除・再作成でも維持される) # 3. スケジュールは再作成不要(フローの削除・再作成でも維持される)
``` ```
### APIキーローテーション手順 ### APIキーローテーション手順
1. Djangoサーバー側で新しいキーを生成: `openssl rand -hex 32` 1. Djangoサーバー側で新しいキーを生成: `openssl rand -hex 32`
2. `docker-compose.yml``NOTIFICATION_API_KEY` を更新してデプロイ 2. `docker-compose.yml``NOTIFICATION_API_KEY` を更新してデプロイ
3. Windmill UI で `u/admin/NOTIFICATION_API_KEY` の値を同じ新しいキーに更新 3. Windmill UI で `u/admin/NOTIFICATION_API_KEY` の値を同じ新しいキーに更新
4. フローを手動実行して動作確認 4. フローを手動実行して動作確認
### 過去のジョブ結果確認 ### 過去のジョブ結果確認
```bash ```bash
curl -sk -H "Authorization: Bearer qLJ3VPZ61kTDiIwaUPUu1dXszGrsN1Dh" \ curl -sk -H "Authorization: Bearer qLJ3VPZ61kTDiIwaUPUu1dXszGrsN1Dh" \
"https://windmill.keinafarm.net/api/w/admins/jobs/list?per_page=10&script_path_exact=f/shiraou/shiraou_notification&is_flow=true" "https://windmill.keinafarm.net/api/w/admins/jobs/list?per_page=10&script_path_exact=f/shiraou/shiraou_notification&is_flow=true"
``` ```
--- ---
## 9. ソースファイル索引 ## 9. ソースファイル索引
### フロー定義 ### フロー定義
| ファイル | 説明 | | ファイル | 説明 |
|---------|------| |---------|------|
| [flows/shiraou_notification.flow.json](../../flows/shiraou_notification.flow.json) | フロー本体。単一Pythonステップでポーリング・通知・状態更新を実行 | | [flows/shiraou_notification.flow.json](../../flows/shiraou_notification.flow.json) | フロー本体。単一Pythonステップでポーリング・通知・状態更新を実行 |
**フロー構造**: **フロー構造**:
```json ```json
{ {
"path": "f/shiraou/shiraou_notification", "path": "f/shiraou/shiraou_notification",
"value": { "value": {
"modules": [ "modules": [
{ {
"id": "a", "id": "a",
"value": { "value": {
"type": "rawscript", "type": "rawscript",
"language": "python3", "language": "python3",
"content": "..." "content": "..."
} }
} }
] ]
} }
} }
``` ```
### ヘルパースクリプト ### ヘルパースクリプト
| ファイル | 説明 | | ファイル | 説明 |
|---------|------| |---------|------|
| [wm-api.sh](../../wm-api.sh) | Windmill REST API操作ヘルパー。フロー作成・スケジュール管理に使用 | | [wm-api.sh](../../wm-api.sh) | Windmill REST API操作ヘルパー。フロー作成・スケジュール管理に使用 |
**主要コマンド**: **主要コマンド**:
``` ```
create-flow <file> JSONファイルからフローを作成 create-flow <file> JSONファイルからフローを作成
create-schedule <file> JSONファイルからスケジュールを作成 create-schedule <file> JSONファイルからスケジュールを作成
flows フロー一覧取得 flows フロー一覧取得
schedules スケジュール一覧取得 schedules スケジュール一覧取得
``` ```
### ドキュメント ### ドキュメント
| ファイル | 説明 | | ファイル | 説明 |
|---------|------| |---------|------|
| [docs/shiraou/19_windmill_通知ワークフロー連携仕様.md](19_windmill_通知ワークフロー連携仕様.md) | 仕様書。API仕様・メッセージフォーマットの原典 | | [docs/shiraou/19_windmill_通知ワークフロー連携仕様.md](19_windmill_通知ワークフロー連携仕様.md) | 仕様書。API仕様・メッセージフォーマットの原典 |
| [docs/shiraou/20_マスタードキュメント_Windmill通知ワークフロー編.md](20_マスタードキュメント_Windmill通知ワークフロー編.md) | 本ドキュメント | | [docs/shiraou/20_マスタードキュメント_Windmill通知ワークフロー編.md](20_マスタードキュメント_Windmill通知ワークフロー編.md) | 本ドキュメント |
### エージェントワークフロー ### エージェントワークフロー
| ファイル | 説明 | | ファイル | 説明 |
|---------|------| |---------|------|
| [.agent/workflows/windmill-push.md](../../.agent/workflows/windmill-push.md) | Windmillへのpush手順。wmill CLIの制限とAPI代替の経緯を記録 | | [.agent/workflows/windmill-push.md](../../.agent/workflows/windmill-push.md) | Windmillへのpush手順。wmill CLIの制限とAPI代替の経緯を記録 |
| [.agent/workflows/windmill-new-script.md](../../.agent/workflows/windmill-new-script.md) | 新規スクリプト作成手順 | | [.agent/workflows/windmill-new-script.md](../../.agent/workflows/windmill-new-script.md) | 新規スクリプト作成手順 |
--- ---
## 更新履歴 ## 更新履歴
| 日付 | 変更内容 | | 日付 | 変更内容 |
|------|---------| |------|---------|
| 2026-02-21 | 初版作成。フロー登録・スケジュール設定・状態管理バグ修正を含む | | 2026-02-21 | 初版作成。フロー登録・スケジュール設定・状態管理バグ修正を含む |

File diff suppressed because it is too large Load Diff

View File

@@ -1,148 +1,148 @@
Alexa TTS API マスタードキュメント Alexa TTS API マスタードキュメント
最終更新: 2026-03-03 状態: サーバーからの日本語TTS未解決調査中 最終更新: 2026-03-03 状態: サーバーからの日本語TTS未解決調査中
------------ ------------
2026/03/03 10:24 akira記録 2026/03/03 10:24 akira記録
akiraが下記の変更をしましたので、内容を読んでください。 akiraが下記の変更をしましたので、内容を読んでください。
1) 構成とサーバーへのファイル受け渡し方法を変更しました 1) 構成とサーバーへのファイル受け渡し方法を変更しました
/home/claude/windmill_workflow /home/claude/windmill_workflow
に、https://gitea.keinafarm.net/akira/windmill_workflow.gitをcloneしました に、https://gitea.keinafarm.net/akira/windmill_workflow.gitをcloneしました
これにより、 これにより、
C:\Users\akira\Develop\windmill_workflow C:\Users\akira\Develop\windmill_workflow
とのやり取りはgiteaを使って出来るようになります。 とのやり取りはgiteaを使って出来るようになります。
2) docker compose up -dで、「 Docker 内部の IP アドレスが変わり、Traefik のルーティングが古い IP を参照したまま 502/504 エラーになる」のは、原因があるはずです。(他のコンテナで、問題になっていないので) 2) docker compose up -dで、「 Docker 内部の IP アドレスが変わり、Traefik のルーティングが古い IP を参照したまま 502/504 エラーになる」のは、原因があるはずです。(他のコンテナで、問題になっていないので)
調査して、Traefik 再起動が不必要になるようにしたいです 調査して、Traefik 再起動が不必要になるようにしたいです
------------ ------------
概要 概要
Windmill から家中の Echo デバイスに任意のテキストを読み上げさせる API サーバー。Node.js + Express で実装し、Docker コンテナとして Windmill サーバー上で稼働する。 Windmill から家中の Echo デバイスに任意のテキストを読み上げさせる API サーバー。Node.js + Express で実装し、Docker コンテナとして Windmill サーバー上で稼働する。
⚠ 現在の問題: ローカル PC からは日本語TTS動作確認済み。しかしサーバーkeinafarm.netのコンテナからリクエストすると日本語文字が Amazon 側で除去されて発話されない。原因は Amazon の IP ベースフィルタリング海外IPからは日本語 textToSpeak を無視する模様)。調査継続中。 ⚠ 現在の問題: ローカル PC からは日本語TTS動作確認済み。しかしサーバーkeinafarm.netのコンテナからリクエストすると日本語文字が Amazon 側で除去されて発話されない。原因は Amazon の IP ベースフィルタリング海外IPからは日本語 textToSpeak を無視する模様)。調査継続中。
ファイル構成 ファイル構成
ファイル 場所 役割 備考 ファイル 場所 役割 備考
server.js alexa-api/(リポジトリ) Express API サーバー本体 本番コード。変更したらビルド・再デプロイが必要 server.js alexa-api/(リポジトリ) Express API サーバー本体 本番コード。変更したらビルド・再デプロイが必要
Dockerfile alexa-api/(リポジトリ) Docker イメージ定義 node:20-alpine ベース。server.js と package*.json をコピー Dockerfile alexa-api/(リポジトリ) Docker イメージ定義 node:20-alpine ベース。server.js と package*.json をコピー
docker-compose.yml alexa-api/(リポジトリ) コンテナ起動設定 windmill_windmill-internal ネットワーク接続。外部ポート非公開 docker-compose.yml alexa-api/(リポジトリ) コンテナ起動設定 windmill_windmill-internal ネットワーク接続。外部ポート非公開
package.json / package-lock.json alexa-api/(リポジトリ) npm 依存関係 本番: express のみ。devDeps に alexa-remote2不使用 package.json / package-lock.json alexa-api/(リポジトリ) npm 依存関係 本番: express のみ。devDeps に alexa-remote2不使用
.env.example alexa-api/(リポジトリ) 環境変数テンプレート ALEXA_COOKIE=xxx の形式 .env.example alexa-api/(リポジトリ) 環境変数テンプレート ALEXA_COOKIE=xxx の形式
.env alexa-api/(リポジトリ、.gitignore 対象) 実際の Cookie 保管 Git にコミットしない。ローカル作業後に scp でサーバーへ転送 .env alexa-api/(リポジトリ、.gitignore 対象) 実際の Cookie 保管 Git にコミットしない。ローカル作業後に scp でサーバーへ転送
auth4.js alexa-api/(リポジトリ) Amazon 認証・Cookie 取得スクリプト ローカルのみで実行Windows PC auth4.js alexa-api/(リポジトリ) Amazon 認証・Cookie 取得スクリプト ローカルのみで実行Windows PC
auth.js / auth2.js / auth3.js alexa-api/(リポジトリ) auth4.js の旧バージョン 参考用。実際は auth4.js を使う auth.js / auth2.js / auth3.js alexa-api/(リポジトリ) auth4.js の旧バージョン 参考用。実際は auth4.js を使う
test_tts.js alexa-api/(リポジトリ) ローカルテスト用スクリプト 直接 alexa.amazon.co.jp を叩いて動作確認 test_tts.js alexa-api/(リポジトリ) ローカルテスト用スクリプト 直接 alexa.amazon.co.jp を叩いて動作確認
サーバー上のファイル場所: /home/claude/alexa-api/git リポジトリとは別にコピーして管理) サーバー上のファイル場所: /home/claude/alexa-api/git リポジトリとは別にコピーして管理)
サーバーへのデプロイ手順 サーバーへのデプロイ手順
server.js や Dockerfile、package.json を変更した場合は以下の手順でサーバーに反映する。 server.js や Dockerfile、package.json を変更した場合は以下の手順でサーバーに反映する。
Step 1: ローカルでファイルを編集 Step 1: ローカルでファイルを編集
リポジトリc:\Users\akira\Develop\windmill_workflow\alexa-api\)でファイルを編集する。 リポジトリc:\Users\akira\Develop\windmill_workflow\alexa-api\)でファイルを編集する。
Step 2: scp でサーバーに転送 Step 2: scp でサーバーに転送
変更したファイルをサーバーに scp で転送する: 変更したファイルをサーバーに scp で転送する:
# server.js を変更した場合 scp alexa-api/server.js keinafarm-claude:/home/claude/alexa-api/server.js # Dockerfile や package.json を変更した場合 scp alexa-api/Dockerfile keinafarm-claude:/home/claude/alexa-api/Dockerfile scp alexa-api/package.json keinafarm-claude:/home/claude/alexa-api/package.json scp alexa-api/package-lock.json keinafarm-claude:/home/claude/alexa-api/package-lock.json # .env を更新した場合Cookie 更新時など) scp alexa-api/.env keinafarm-claude:/home/claude/alexa-api/.env # server.js を変更した場合 scp alexa-api/server.js keinafarm-claude:/home/claude/alexa-api/server.js # Dockerfile や package.json を変更した場合 scp alexa-api/Dockerfile keinafarm-claude:/home/claude/alexa-api/Dockerfile scp alexa-api/package.json keinafarm-claude:/home/claude/alexa-api/package.json scp alexa-api/package-lock.json keinafarm-claude:/home/claude/alexa-api/package-lock.json # .env を更新した場合Cookie 更新時など) scp alexa-api/.env keinafarm-claude:/home/claude/alexa-api/.env
Step 3: サーバーでビルドして再起動 Step 3: サーバーでビルドして再起動
⚠ 重要: docker compose restart はイメージをリビルドしない。server.js 等を変更した場合は必ず build + up -d を実行すること。 ⚠ 重要: docker compose restart はイメージをリビルドしない。server.js 等を変更した場合は必ず build + up -d を実行すること。
# SSH でサーバーに接続してビルド+起動 ssh keinafarm-claude cd /home/claude/alexa-api # イメージをビルドserver.js 等の変更を反映) sudo docker compose build # コンテナを再作成して起動 sudo docker compose up -d # Traefik を再起動(コンテナ再作成後は必須) sudo docker restart traefik # SSH でサーバーに接続してビルド+起動 ssh keinafarm-claude cd /home/claude/alexa-api # イメージをビルドserver.js 等の変更を反映) sudo docker compose build # コンテナを再作成して起動 sudo docker compose up -d # Traefik を再起動(コンテナ再作成後は必須) sudo docker restart traefik
Step 4: 動作確認 Step 4: 動作確認
# ヘルスチェックWindmill ワーカーコンテナ内から) curl http://alexa_api:3500/health # ログ確認 sudo docker logs alexa_api -f # デバイス一覧確認 curl http://alexa_api:3500/devices # ヘルスチェックWindmill ワーカーコンテナ内から) curl http://alexa_api:3500/health # ログ確認 sudo docker logs alexa_api -f # デバイス一覧確認 curl http://alexa_api:3500/devices
Cookie だけ更新する場合server.js 変更なし) Cookie だけ更新する場合server.js 変更なし)
# .env をサーバーに転送 scp alexa-api/.env keinafarm-claude:/home/claude/alexa-api/.env # コンテナを再起動restart で OK → イメージのリビルド不要) ssh keinafarm-claude 'sudo docker compose -f /home/claude/alexa-api/docker-compose.yml restart' # ※ Traefik の再起動は不要(コンテナ再作成しないため) # .env をサーバーに転送 scp alexa-api/.env keinafarm-claude:/home/claude/alexa-api/.env # コンテナを再起動restart で OK → イメージのリビルド不要) ssh keinafarm-claude 'sudo docker compose -f /home/claude/alexa-api/docker-compose.yml restart' # ※ Traefik の再起動は不要(コンテナ再作成しないため)
Traefik 再起動が必要な理由 Traefik 再起動が必要な理由
docker compose up -d はコンテナを「再作成」するdocker compose restart は既存コンテナを再起動するだけ)。コンテナが再作成されると Docker 内部の IP アドレスが変わり、Traefik のルーティングが古い IP を参照したまま 502/504 エラーになる。 docker compose up -d はコンテナを「再作成」するdocker compose restart は既存コンテナを再起動するだけ)。コンテナが再作成されると Docker 内部の IP アドレスが変わり、Traefik のルーティングが古い IP を参照したまま 502/504 エラーになる。
対処: sudo docker restart traefik で Traefik に新しい IP を再検出させる。 対処: sudo docker restart traefik で Traefik に新しい IP を再検出させる。
この問題は Traefik の設定で watch: true にすれば自動解消できるが、現状はコンテナ再作成のたびに手動で Traefik を再起動する運用としている。 この問題は Traefik の設定で watch: true にすれば自動解消できるが、現状はコンテナ再作成のたびに手動で Traefik を再起動する運用としている。
docker-compose.yml の内容 docker-compose.yml の内容
services: alexa-api: build: . container_name: alexa_api restart: unless-stopped env_file: - .env environment: - PORT=3500 networks: - windmill_windmill-internal # 外部には公開しないWindmill ワーカーから内部ネットワーク経由でのみアクセス) # デバッグ時は以下のコメントを外す: # ports: # - "127.0.0.1:3500:3500" networks: windmill_windmill-internal: external: true services: alexa-api: build: . container_name: alexa_api restart: unless-stopped env_file: - .env environment: - PORT=3500 networks: - windmill_windmill-internal # 外部には公開しないWindmill ワーカーから内部ネットワーク経由でのみアクセス) # デバッグ時は以下のコメントを外す: # ports: # - "127.0.0.1:3500:3500" networks: windmill_windmill-internal: external: true
認証方法auth4.js 認証方法auth4.js
Amazon Japan OpenID フローを自前で実装。ローカル PCWindowsでのみ実行する Amazon Japan OpenID フローを自前で実装。ローカル PCWindowsでのみ実行する
# ローカルPC の alexa-api ディレクトリで実行 cd alexa-api AMAZON_EMAIL="メールアドレス" AMAZON_PASSWORD="パスワード" node auth4.js # ローカルPC の alexa-api ディレクトリで実行 cd alexa-api AMAZON_EMAIL="メールアドレス" AMAZON_PASSWORD="パスワード" node auth4.js
成功すると alexa-api/.env が生成または更新される。 成功すると alexa-api/.env が生成または更新される。
ログインフローの概要: ログインフローの概要:
GET https://www.amazon.co.jp/ap/signin?openid.assoc_handle=amzn_dp_project_dee_jp GET https://www.amazon.co.jp/ap/signin?openid.assoc_handle=amzn_dp_project_dee_jp
hidden フィールドanti-csrftoken-a2z, appActionToken, workflowState 等)を抽出 hidden フィールドanti-csrftoken-a2z, appActionToken, workflowState 等)を抽出
POST でメール/パスワードを送信 POST でメール/パスワードを送信
alexa.amazon.co.jp/api/apps/v1/token へのリダイレクトをたどる alexa.amazon.co.jp/api/apps/v1/token へのリダイレクトをたどる
取得した Cookieat-acbjp, sess-at-acbjp, sst-acbjp, session-token 等)を .env に保存 取得した Cookieat-acbjp, sess-at-acbjp, sst-acbjp, session-token 等)を .env に保存
TTS の仕組みserver.js TTS の仕組みserver.js
alexa-remote2 は使わない直接 API 実装。Endpoints: alexa-remote2 は使わない直接 API 実装。Endpoints:
POST /speak — { device: "デバイス名 or serial", text: "しゃべる内容" } POST /speak — { device: "デバイス名 or serial", text: "しゃべる内容" }
GET /devices — デバイス一覧 GET /devices — デバイス一覧
GET /health — ヘルスチェック GET /health — ヘルスチェック
内部の API 呼び出し順序: 内部の API 呼び出し順序:
GET /api/language → Set-Cookie: csrf=XXXXX を取得(毎リクエストごと) GET /api/language → Set-Cookie: csrf=XXXXX を取得(毎リクエストごと)
GET /api/bootstrap → customerId を取得(キャッシュ: 永続A1AE8HXD8IJ61L GET /api/bootstrap → customerId を取得(キャッシュ: 永続A1AE8HXD8IJ61L
GET /api/devices-v2/device → デバイス一覧5分キャッシュ GET /api/devices-v2/device → デバイス一覧5分キャッシュ
POST /api/behaviors/preview にシーケンス JSON を送信 POST /api/behaviors/preview にシーケンス JSON を送信
POST /api/behaviors/preview のボディ構造: POST /api/behaviors/preview のボディ構造:
{ behaviorId: "PREVIEW", sequenceJson: JSON.stringify({ "@type": "com.amazon.alexa.behaviors.model.Sequence", startNode: { "@type": "com.amazon.alexa.behaviors.model.OpaquePayloadOperationNode", type: "Alexa.Speak", operationPayload: { deviceType: "...", deviceSerialNumber: "...", customerId: "A1AE8HXD8IJ61L", locale: "ja-JP", // ← 重要(下記参照) textToSpeak: "発話内容" } } }), status: "ENABLED" } { behaviorId: "PREVIEW", sequenceJson: JSON.stringify({ "@type": "com.amazon.alexa.behaviors.model.Sequence", startNode: { "@type": "com.amazon.alexa.behaviors.model.OpaquePayloadOperationNode", type: "Alexa.Speak", operationPayload: { deviceType: "...", deviceSerialNumber: "...", customerId: "A1AE8HXD8IJ61L", locale: "ja-JP", // ← 重要(下記参照) textToSpeak: "発話内容" } } }), status: "ENABLED" }
ヘッダーに csrf: XXXXX と Cookie に csrf=XXXXX の両方が必要。Content-Length は Buffer.byteLength で計算(マルチバイト文字対応)。 ヘッダーに csrf: XXXXX と Cookie に csrf=XXXXX の両方が必要。Content-Length は Buffer.byteLength で計算(マルチバイト文字対応)。
⚠ locale パラメータについて(重要・未解決) ⚠ locale パラメータについて(重要・未解決)
locale 値 ローカル PC から サーバーkeinafarm.netから locale 値 ローカル PC から サーバーkeinafarm.netから
""(空文字) ✅ 日本語・英語・漢字全て発話 ❌ 英語TTSになり日本語部分が発話されない ""(空文字) ✅ 日本語・英語・漢字全て発話 ❌ 英語TTSになり日本語部分が発話されない
"ja-JP" ❌ 一瞬音が出るだけ(失敗) ❌ 日本語文字が Amazon 側で除去され英字のみ発話 "ja-JP" ❌ 一瞬音が出るだけ(失敗) ❌ 日本語文字が Amazon 側で除去され英字のみ発話
現在 server.js では locale: "ja-JP" に設定している。 現在 server.js では locale: "ja-JP" に設定している。
仮説: Amazon が海外IPkeinafarm.net = 非日本IPからのリクエストを IP ベースでフィルタリングし、textToSpeak の日本語文字を除去している。Alexa.TextCommand は同じ問題がない(異なる API パス)。 仮説: Amazon が海外IPkeinafarm.net = 非日本IPからのリクエストを IP ベースでフィルタリングし、textToSpeak の日本語文字を除去している。Alexa.TextCommand は同じ問題がない(異なる API パス)。
確認済み事実: alexa_api の server.js ログには日本語テキストが正しく届いている。除去は Amazon サーバー側で発生。 確認済み事実: alexa_api の server.js ログには日本語テキストが正しく届いている。除去は Amazon サーバー側で発生。
次の調査候補: 次の調査候補:
SSML の <lang xml:lang="ja-JP"> タグで強制的に日本語 TTS を指定できるか SSML の <lang xml:lang="ja-JP"> タグで強制的に日本語 TTS を指定できるか
Alexa.TextCommand で「次を読み上げて:{text}」形式が使えるか Alexa.TextCommand で「次を読み上げて:{text}」形式が使えるか
ローカルブリッジ方式(ユーザーのローカル PC で小さなプロキシサーバーを動かし、クラウドサーバーからローカル経由で alexa.amazon.co.jp を叩く) ローカルブリッジ方式(ユーザーのローカル PC で小さなプロキシサーバーを動かし、クラウドサーバーからローカル経由で alexa.amazon.co.jp を叩く)
デバイス一覧Echo デバイスのみ) デバイス一覧Echo デバイスのみ)
名前 deviceType serialNumber 名前 deviceType serialNumber
プレハブ A4ZXE0RM7LQ7A G0922H085165007R プレハブ A4ZXE0RM7LQ7A G0922H085165007R
リビングエコー1 ASQZWP4GPYUT7 G8M2DB08522600RL リビングエコー1 ASQZWP4GPYUT7 G8M2DB08522600RL
リビングエコー2 ASQZWP4GPYUT7 G8M2DB08522503WF リビングエコー2 ASQZWP4GPYUT7 G8M2DB08522503WF
オフィスの右エコー A4ZXE0RM7LQ7A G0922H08525302K5 オフィスの右エコー A4ZXE0RM7LQ7A G0922H08525302K5
オフィスの左エコー A4ZXE0RM7LQ7A G0922H08525302J9 オフィスの左エコー A4ZXE0RM7LQ7A G0922H08525302J9
寝室のエコー ASQZWP4GPYUT7 G8M2HN08534302XH 寝室のエコー ASQZWP4GPYUT7 G8M2HN08534302XH
Windmill スクリプトu/admin/alexa_speak Windmill スクリプトu/admin/alexa_speak
export async function main(device: string, text: string) { const res = await fetch("http://alexa_api:3500/speak", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ device, text }), }); if (!res.ok) throw new Error("alexa-api error " + res.status); return res.json(); } export async function main(device: string, text: string) { const res = await fetch("http://alexa_api:3500/speak", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ device, text }), }); if (!res.ok) throw new Error("alexa-api error " + res.status); return res.json(); }
device はデバイス名日本語またはシリアル番号で指定可能。Windmill ワーカーから http://alexa_api:3500 でアクセスwindmill_windmill-internal ネットワーク経由)。 device はデバイス名日本語またはシリアル番号で指定可能。Windmill ワーカーから http://alexa_api:3500 でアクセスwindmill_windmill-internal ネットワーク経由)。
Cookie の更新手順 Cookie の更新手順
Cookie は数日〜数週間で期限切れ。切れたら: Cookie は数日〜数週間で期限切れ。切れたら:
# 1. ローカル PC で Cookie を取得 cd alexa-api AMAZON_EMAIL="メールアドレス" AMAZON_PASSWORD="パスワード" node auth4.js # → alexa-api/.env が更新される # 2. サーバーに .env を転送 scp alexa-api/.env keinafarm-claude:/home/claude/alexa-api/.env # 3. コンテナを再起動restart で OK、リビルド不要 ssh keinafarm-claude 'sudo docker compose -f /home/claude/alexa-api/docker-compose.yml restart' # ※ Traefik 再起動は不要(コンテナ再作成なし) # 1. ローカル PC で Cookie を取得 cd alexa-api AMAZON_EMAIL="メールアドレス" AMAZON_PASSWORD="パスワード" node auth4.js # → alexa-api/.env が更新される # 2. サーバーに .env を転送 scp alexa-api/.env keinafarm-claude:/home/claude/alexa-api/.env # 3. コンテナを再起動restart で OK、リビルド不要 ssh keinafarm-claude 'sudo docker compose -f /home/claude/alexa-api/docker-compose.yml restart' # ※ Traefik 再起動は不要(コンテナ再作成なし)
既知の問題・落とし穴 既知の問題・落とし穴
docker compose restart ≠ リビルド: server.js を変更しても restart ではコンテナ内のコードは古いまま。build + up -d が必要。 docker compose restart ≠ リビルド: server.js を変更しても restart ではコンテナ内のコードは古いまま。build + up -d が必要。
コンテナ再作成後は Traefik 再起動必須: up -d でコンテナ再作成すると Docker 内部 IP が変わり Traefik が 502/504 を返す。sudo docker restart traefik で解消。 コンテナ再作成後は Traefik 再起動必須: up -d でコンテナ再作成すると Docker 内部 IP が変わり Traefik が 502/504 を返す。sudo docker restart traefik で解消。
alexa-remote2 は使えない: 取得した Cookie 文字列を受け付けない(内部で再認証しようとして失敗)。直接 API 実装が必要。 alexa-remote2 は使えない: 取得した Cookie 文字列を受け付けない(内部で再認証しようとして失敗)。直接 API 実装が必要。
CSRF トークンは Cookie と ヘッダーの両方に必要: csrf ヘッダーだけ、または Cookie だけでは認証失敗。 CSRF トークンは Cookie と ヘッダーの両方に必要: csrf ヘッダーだけ、または Cookie だけでは認証失敗。
operationPayload に customerId 必須: ないと 400 エラー。 operationPayload に customerId 必須: ないと 400 エラー。
レート制限: 短時間に連続リクエストすると HTTP 429 または 200 で音が出ない。通常の通知用途では問題なし。 レート制限: 短時間に連続リクエストすると HTTP 429 または 200 で音が出ない。通常の通知用途では問題なし。
git push がブロックされる: Gitea の pre-receive フックremote: Gitea: User permission denied for writingで push が失敗する。根本原因は未調査。ファイル転送は scp で行っている。 git push がブロックされる: Gitea の pre-receive フックremote: Gitea: User permission denied for writingで push が失敗する。根本原因は未調査。ファイル転送は scp で行っている。
firstRunCompleted: false はデバイス設定の未完了フラグ: TTS には直接影響しないroot cause ではなかった)。 firstRunCompleted: false はデバイス設定の未完了フラグ: TTS には直接影響しないroot cause ではなかった)。
サーバー上の運用コマンド一覧 サーバー上の運用コマンド一覧
# コンテナ状態確認 sudo docker ps | grep alexa # リアルタイムログ確認 sudo docker logs alexa_api -f # コンテナ停止 sudo docker compose -f /home/claude/alexa-api/docker-compose.yml stop # ビルド+起動(コード変更後) cd /home/claude/alexa-api sudo docker compose build sudo docker compose up -d sudo docker restart traefik # Cookie 更新時(再起動のみ) sudo docker compose -f /home/claude/alexa-api/docker-compose.yml restart # コンテナ状態確認 sudo docker ps | grep alexa # リアルタイムログ確認 sudo docker logs alexa_api -f # コンテナ停止 sudo docker compose -f /home/claude/alexa-api/docker-compose.yml stop # ビルド+起動(コード変更後) cd /home/claude/alexa-api sudo docker compose build sudo docker compose up -d sudo docker restart traefik # Cookie 更新時(再起動のみ) sudo docker compose -f /home/claude/alexa-api/docker-compose.yml restart

File diff suppressed because it is too large Load Diff

View File

@@ -1,105 +1,105 @@
# 引き継ぎ - `u/admin/alexa_speak` API反映後にUIドロップダウンが変わらない件 # 引き継ぎ - `u/admin/alexa_speak` API反映後にUIドロップダウンが変わらない件
> **作成日**: 2026-03-04 > **作成日**: 2026-03-04
> **対象**: `windmill.keinafarm.net` / workspace `admins` > **対象**: `windmill.keinafarm.net` / workspace `admins`
> **対象スクリプト**: `u/admin/alexa_speak` > **対象スクリプト**: `u/admin/alexa_speak`
> **目的**: 別端末から同じ事象に遭遇しても、原因切り分けと復旧をすぐ実施できるようにする > **目的**: 別端末から同じ事象に遭遇しても、原因切り分けと復旧をすぐ実施できるようにする
--- ---
## 1. 事象の概要 ## 1. 事象の概要
`u/admin/alexa_speak` を Windmill API`create-script`)で更新した直後、 `u/admin/alexa_speak` を Windmill API`create-script`)で更新した直後、
- Scriptタブ上のコードは更新済み - Scriptタブ上のコードは更新済み
- `schema` 上も `device``dynselect-device` - `schema` 上も `device``dynselect-device`
- しかし Inputフォームは `Device` がテキスト入力のまま(ドロップダウンにならない) - しかし Inputフォームは `Device` がテキスト入力のまま(ドロップダウンにならない)
という状態になった。 という状態になった。
--- ---
## 2. 当日の時系列(要点) ## 2. 当日の時系列(要点)
1. 既存スクリプトを取得し、ローカル `scripts/alexa_speak.ts`Dynamic Select実装ありをAPIで反映 1. 既存スクリプトを取得し、ローカル `scripts/alexa_speak.ts`Dynamic Select実装ありをAPIで反映
2. サーバー再取得で `content` 一致を確認(更新自体は成功) 2. サーバー再取得で `content` 一致を確認(更新自体は成功)
3. UIを開くと `Device` が入力欄のままで、ドロップダウン化されていない 3. UIを開くと `Device` が入力欄のままで、ドロップダウン化されていない
4. `schema.device``format: dynselect-device`, `originalType: DynSelect_device` に更新して再反映 4. `schema.device``format: dynselect-device`, `originalType: DynSelect_device` に更新して再反映
5. それでも UI は直ちには変わらず 5. それでも UI は直ちには変わらず
6. Windmill UIで `Edit` に入り、`Deploy` を1回実施 6. Windmill UIで `Edit` に入り、`Deploy` を1回実施
7. 直後にドロップダウン表示へ反映 7. 直後にドロップダウン表示へ反映
--- ---
## 3. 確認できた事実 ## 3. 確認できた事実
- API反映は成功しているhash更新 - API反映は成功しているhash更新
- 中間: `a6010687183a199d` - 中間: `a6010687183a199d`
- 最終: `318d78f45a084e32` - 最終: `318d78f45a084e32`
- 最終状態では以下がAPIで確認済み - 最終状態では以下がAPIで確認済み
- `schema.properties.device.format = "dynselect-device"` - `schema.properties.device.format = "dynselect-device"`
- `schema.properties.device.originalType = "DynSelect_device"` - `schema.properties.device.originalType = "DynSelect_device"`
- UI反映は API反映だけでは即時にならず、`Edit -> Deploy` 後に反映された - UI反映は API反映だけでは即時にならず、`Edit -> Deploy` 後に反映された
--- ---
## 4. 想定される原因 ## 4. 想定される原因
Windmill CE 側で、API経由更新時にフォームUIメタ情報入力ウィジェット解決の再計算または再適用が即時反映されないケースがある。 Windmill CE 側で、API経由更新時にフォームUIメタ情報入力ウィジェット解決の再計算または再適用が即時反映されないケースがある。
実務上は「API更新後にUIで1回Deploy」が回避策として有効。 実務上は「API更新後にUIで1回Deploy」が回避策として有効。
--- ---
## 5. 再現時の標準対応手順Runbook ## 5. 再現時の標準対応手順Runbook
### 5.1 APIでスクリプト更新 ### 5.1 APIでスクリプト更新
```bash ```bash
cd /home/akira/develop/windmill_workflow cd /home/akira/develop/windmill_workflow
./wm-api.sh get-script u/admin/alexa_speak > /tmp/remote_alexa_speak.json ./wm-api.sh get-script u/admin/alexa_speak > /tmp/remote_alexa_speak.json
# parent_hash を含む payload を作成して create-script # parent_hash を含む payload を作成して create-script
./wm-api.sh create-script /tmp/alexa_speak_push.json ./wm-api.sh create-script /tmp/alexa_speak_push.json
``` ```
### 5.2 APIで反映確認 ### 5.2 APIで反映確認
```bash ```bash
./wm-api.sh get-script u/admin/alexa_speak ./wm-api.sh get-script u/admin/alexa_speak
``` ```
確認ポイント: 確認ポイント:
- `hash` が更新されている - `hash` が更新されている
- `content` が想定コードになっている - `content` が想定コードになっている
- `schema.properties.device.format``dynselect-device` - `schema.properties.device.format``dynselect-device`
- `schema.properties.device.originalType``DynSelect_device` - `schema.properties.device.originalType``DynSelect_device`
### 5.3 UI反映されない場合 ### 5.3 UI反映されない場合
1. `u/admin/alexa_speak` を最新リビジョンで開く 1. `u/admin/alexa_speak` を最新リビジョンで開く
2. ハードリロード(`Ctrl + Shift + R` 2. ハードリロード(`Ctrl + Shift + R`
3. 変化がなければ `Edit -> Deploy` を1回実施 3. 変化がなければ `Edit -> Deploy` を1回実施
4. Inputフォームの `Device` がドロップダウン化されたことを確認 4. Inputフォームの `Device` がドロップダウン化されたことを確認
--- ---
## 6. 補足(今回の最終状態) ## 6. 補足(今回の最終状態)
- スクリプト: `u/admin/alexa_speak` - スクリプト: `u/admin/alexa_speak`
- 期待UI: - 期待UI:
- `Device`: ドロップダウンdynselect - `Device`: ドロップダウンdynselect
- `Text`: テキスト入力 - `Text`: テキスト入力
- 前提: - 前提:
- `alexa_api` コンテナが稼働 - `alexa_api` コンテナが稼働
- `http://alexa_api:3500/devices` が取得可能 - `http://alexa_api:3500/devices` が取得可能
--- ---
## 7. 引き継ぎメモ ## 7. 引き継ぎメモ
- 「API反映成功」と「UIフォーム反映成功」は同時とは限らない - 「API反映成功」と「UIフォーム反映成功」は同時とは限らない
- 引き継ぎ時は、必ず以下をセットで確認する - 引き継ぎ時は、必ず以下をセットで確認する
1. APIレスポンスの `hash``schema` 1. APIレスポンスの `hash``schema`
2. UI表示必要なら `Edit -> Deploy` 2. UI表示必要なら `Edit -> Deploy`

View File

@@ -1,361 +1,361 @@
# マスタードキュメント - Windmillフロー管理 API一本化編 # マスタードキュメント - Windmillフロー管理 API一本化編
> **最終更新**: 2026-03-03 > **最終更新**: 2026-03-03
> **対象**: `windmill.keinafarm.net` / workspace `admins` > **対象**: `windmill.keinafarm.net` / workspace `admins`
> **目的**: ローカルGitとサーバーGitの衝突を避けつつ、Windmill APIを唯一の運用経路に統一する > **目的**: ローカルGitとサーバーGitの衝突を避けつつ、Windmill APIを唯一の運用経路に統一する
--- ---
## 目次 ## 目次
1. [この文書の役割](#1-この文書の役割) 1. [この文書の役割](#1-この文書の役割)
2. [運用方針(結論)](#2-運用方針結論) 2. [運用方針(結論)](#2-運用方針結論)
3. [現状の課題と解決方針](#3-現状の課題と解決方針) 3. [現状の課題と解決方針](#3-現状の課題と解決方針)
4. [管理対象と正本の定義](#4-管理対象と正本の定義) 4. [管理対象と正本の定義](#4-管理対象と正本の定義)
5. [同期・反映の仕様](#5-同期反映の仕様) 5. [同期・反映の仕様](#5-同期反映の仕様)
6. [競合時の動作仕様](#6-競合時の動作仕様) 6. [競合時の動作仕様](#6-競合時の動作仕様)
7. [実装計画](#7-実装計画) 7. [実装計画](#7-実装計画)
8. [標準運用手順Runbook](#8-標準運用手順runbook) 8. [標準運用手順Runbook](#8-標準運用手順runbook)
9. [セキュリティ・監査方針](#9-セキュリティ監査方針) 9. [セキュリティ・監査方針](#9-セキュリティ監査方針)
10. [障害前提の復旧設計(必須)](#10-障害前提の復旧設計必須) 10. [障害前提の復旧設計(必須)](#10-障害前提の復旧設計必須)
11. [Windmill依存を薄くする方針必須](#11-windmill依存を薄くする方針必須) 11. [Windmill依存を薄くする方針必須](#11-windmill依存を薄くする方針必須)
12. [受け入れ条件](#12-受け入れ条件) 12. [受け入れ条件](#12-受け入れ条件)
13. [既知の注意点](#13-既知の注意点) 13. [既知の注意点](#13-既知の注意点)
14. [更新履歴](#14-更新履歴) 14. [更新履歴](#14-更新履歴)
--- ---
## 1. この文書の役割 ## 1. この文書の役割
この文書は、次回セッション開始時にこれだけ読めば作業を継続できることを目的とした、**運用仕様 + 実装計画の単一ソース**である。 この文書は、次回セッション開始時にこれだけ読めば作業を継続できることを目的とした、**運用仕様 + 実装計画の単一ソース**である。
- 暗黙知を残さない - 暗黙知を残さない
- 方針・手順・失敗時の扱いを固定化する - 方針・手順・失敗時の扱いを固定化する
- API経由運用に必要な実装タスクを明文化する - API経由運用に必要な実装タスクを明文化する
--- ---
## 2. 運用方針(結論) ## 2. 運用方針(結論)
### 採用する方式: API一本化Server First ### 採用する方式: API一本化Server First
1. ローカルリポジトリは、サーバー側Gitをリモートにしない 1. ローカルリポジトリは、サーバー側Gitをリモートにしない
2. Windmillの実体変更は **Windmill REST API 経由のみ** 2. Windmillの実体変更は **Windmill REST API 経由のみ**
3. 作業開始時にAPIでサーバー状態を取り込み、サーバーが新しければローカルへ同期 3. 作業開始時にAPIでサーバー状態を取り込み、サーバーが新しければローカルへ同期
4. **運用単位は「workflow packageflow + schedules」を基本**とする 4. **運用単位は「workflow packageflow + schedules」を基本**とする
5. ローカル変更はAPIでサーバーへ反映 5. ローカル変更はAPIでサーバーへ反映
6. サーバー側の Git Sync Workflow は定期記録用途として継続 6. サーバー側の Git Sync Workflow は定期記録用途として継続
7. ローカルGitコミットは手動またはAIで実施し、監査/履歴用途として扱う 7. ローカルGitコミットは手動またはAIで実施し、監査/履歴用途として扱う
--- ---
## 3. 現状の課題と解決方針 ## 3. 現状の課題と解決方針
### 課題 ### 課題
- Windmill側の自動Git記録WFと、ローカルGit運用が同一系統を触ると競合が発生する - Windmill側の自動Git記録WFと、ローカルGit運用が同一系統を触ると競合が発生する
- `wmill` CLIは制約があり使いにくく、運用が不安定 - `wmill` CLIは制約があり使いにくく、運用が不安定
- サーバーとローカルのどちらが最新か判定が曖昧 - サーバーとローカルのどちらが最新か判定が曖昧
### 解決 ### 解決
- 更新経路をWindmill APIに統一し、競合面を縮小する - 更新経路をWindmill APIに統一し、競合面を縮小する
- 「正本=Windmillサーバー」の原則を固定する - 「正本=Windmillサーバー」の原則を固定する
- `hash` / `parent_hash` を使った衝突検知を標準化する - `hash` / `parent_hash` を使った衝突検知を標準化する
--- ---
## 4. 管理対象と正本の定義 ## 4. 管理対象と正本の定義
### 正本 ### 正本
- **Windmillサーバー上のオブジェクト** - **Windmillサーバー上のオブジェクト**
- scripts (`/w/{workspace}/scripts/*`) - scripts (`/w/{workspace}/scripts/*`)
- flows (`/w/{workspace}/flows/*`) - flows (`/w/{workspace}/flows/*`)
- schedules (`/w/{workspace}/schedules/*`) - schedules (`/w/{workspace}/schedules/*`)
### ローカルの位置づけ ### ローカルの位置づけ
- ローカルファイルは「編集用ワークツリー + 監査ログ」 - ローカルファイルは「編集用ワークツリー + 監査ログ」
- ローカルGitはサーバー同期の必須経路ではない - ローカルGitはサーバー同期の必須経路ではない
### 管理単位(重要) ### 管理単位(重要)
- 単体オブジェクト運用scriptだけ、flowだけは補助用途 - 単体オブジェクト運用scriptだけ、flowだけは補助用途
- 標準運用は **workflow package 単位** とする - 標準運用は **workflow package 単位** とする
- `flow` 本体 - `flow` 本体
- その `flow path` に紐づく `schedules``is_flow=true` かつ `script_path == flow.path` - その `flow path` に紐づく `schedules``is_flow=true` かつ `script_path == flow.path`
### 既存の主要オブジェクト(例) ### 既存の主要オブジェクト(例)
- script: `u/admin/alexa_speak` - script: `u/admin/alexa_speak`
- flows: `f/app_custom/system_heartbeat`, `f/shiraou/shiraou_notification` など - flows: `f/app_custom/system_heartbeat`, `f/shiraou/shiraou_notification` など
--- ---
## 5. 同期・反映の仕様 ## 5. 同期・反映の仕様
## 5.1 Pullサーバー -> ローカル) ## 5.1 Pullサーバー -> ローカル)
### 目的 ### 目的
- 作業前にサーバー最新状態をローカルへ取り込む - 作業前にサーバー最新状態をローカルへ取り込む
### 判定ルール ### 判定ルール
- サーバー `updated_at` / `hash` とローカル管理メタ情報を比較 - サーバー `updated_at` / `hash` とローカル管理メタ情報を比較
- サーバーが新しければローカルを更新 - サーバーが新しければローカルを更新
### 対象APIオブジェクト単体 ### 対象APIオブジェクト単体
- `GET /api/w/{workspace}/scripts/get/p/{path}` - `GET /api/w/{workspace}/scripts/get/p/{path}`
- `GET /api/w/{workspace}/flows/get/{path}` - `GET /api/w/{workspace}/flows/get/{path}`
- `GET /api/w/{workspace}/schedules/get/{path}` - `GET /api/w/{workspace}/schedules/get/{path}`
### workflow package Pull標準 ### workflow package Pull標準
1. `GET /flows/get/{flow_path}` で flow 本体取得 1. `GET /flows/get/{flow_path}` で flow 本体取得
2. `GET /schedules/list` で schedule 一覧取得 2. `GET /schedules/list` で schedule 一覧取得
3. `script_path == flow_path` の schedule 群を抽出 3. `script_path == flow_path` の schedule 群を抽出
4. ローカルへ一括保存flow + schedules 4. ローカルへ一括保存flow + schedules
## 5.2 Pushローカル -> サーバー) ## 5.2 Pushローカル -> サーバー)
### 目的 ### 目的
- ローカル変更をサーバーへ安全反映 - ローカル変更をサーバーへ安全反映
### スクリプト反映API標準 ### スクリプト反映API標準
- `POST /api/w/{workspace}/scripts/create` - `POST /api/w/{workspace}/scripts/create`
必須パラメータ(最小): 必須パラメータ(最小):
- `path` - `path`
- `parent_hash`直前取得したサーバーhash - `parent_hash`直前取得したサーバーhash
- `summary` - `summary`
- `description` - `description`
- `content` - `content`
- `schema` - `schema`
- `language`TypeScriptは `bun` - `language`TypeScriptは `bun`
- `kind`(通常 `script` - `kind`(通常 `script`
- `lock`(既存値継承) - `lock`(既存値継承)
### フロー反映API ### フロー反映API
- CE制約により `PUT` 更新が不可/不安定な場合があるため、原則: - CE制約により `PUT` 更新が不可/不安定な場合があるため、原則:
1. `DELETE /flows/delete/{path}` 1. `DELETE /flows/delete/{path}`
2. `POST /flows/create` 2. `POST /flows/create`
### schedule反映APIworkflow package の一部) ### schedule反映APIworkflow package の一部)
- workflow package Push時は、対象 flow に紐づく schedule も同時同期する - workflow package Push時は、対象 flow に紐づく schedule も同時同期する
- 原則: - 原則:
1. サーバー現行 schedule`script_path == flow_path`)一覧取得 1. サーバー現行 schedule`script_path == flow_path`)一覧取得
2. ローカル定義との差分計算(追加・更新・削除) 2. ローカル定義との差分計算(追加・更新・削除)
3. `DELETE /schedules/delete/{path}``POST /schedules/create` で収束 3. `DELETE /schedules/delete/{path}``POST /schedules/create` で収束
--- ---
## 6. 競合時の動作仕様 ## 6. 競合時の動作仕様
### スクリプトPush競合 ### スクリプトPush競合
- `parent_hash` 不一致時は更新拒否(期待動作) - `parent_hash` 不一致時は更新拒否(期待動作)
- 対応: - 対応:
1. 最新をPull 1. 最新をPull
2. 差分を再適用 2. 差分を再適用
3. 再Push 3. 再Push
### フローPush競合 ### フローPush競合
- 削除再作成前に最新を必ずPullしてローカル保存 - 削除再作成前に最新を必ずPullしてローカル保存
- 競合が疑われる場合は自動実行せず手動確認にフォールバック - 競合が疑われる場合は自動実行せず手動確認にフォールバック
- **Preflight必須**: Push直前に `remote_index` の既知hashとサーバー現在hashを比較し、不一致ならPush中断fail closed - **Preflight必須**: Push直前に `remote_index` の既知hashとサーバー現在hashを比較し、不一致ならPush中断fail closed
- Push後は `post-verify`再取得して期待JSON一致確認を必須化 - Push後は `post-verify`再取得して期待JSON一致確認を必須化
### schedulePush競合 ### schedulePush競合
- `schedule.path` 重複や同名上書きに注意 - `schedule.path` 重複や同名上書きに注意
- workflow package Push時は、対象 flow の schedule 群をまとめて同期し、中途半端な状態を残さない - workflow package Push時は、対象 flow の schedule 群をまとめて同期し、中途半端な状態を残さない
- 失敗時は flow と schedules の両方を再取得して整合を確認 - 失敗時は flow と schedules の両方を再取得して整合を確認
- schedule 同期も Preflight/`post-verify` の対象に含める - schedule 同期も Preflight/`post-verify` の対象に含める
--- ---
## 7. 実装計画 ## 7. 実装計画
## Phase 0: 文書固定(完了) ## Phase 0: 文書固定(完了)
- 本ドキュメント作成 - 本ドキュメント作成
## Phase 1: API運用コマンド整備完了 ## Phase 1: API運用コマンド整備完了
`wm-api.sh` へ追加: `wm-api.sh` へ追加:
1. `pull-script <path> <outfile>` 1. `pull-script <path> <outfile>`
2. `push-script <path> <infile>` 2. `push-script <path> <infile>`
3. `pull-flow <path> <outfile>` 3. `pull-flow <path> <outfile>`
4. `push-flow <json-file>` 4. `push-flow <json-file>`
5. `pull-all`scripts/flowsの一覧取得 + 一括保存) 5. `pull-all`scripts/flowsの一覧取得 + 一括保存)
6. `status-remote`ローカルとサーバーのhash比較 6. `status-remote`ローカルとサーバーのhash比較
## Phase 2: workflow package 対応(次タスク) ## Phase 2: workflow package 対応(次タスク)
`wm-api.sh` へ追加: `wm-api.sh` へ追加:
1. `pull-workflow <flow_path>`flow + schedules 一括取得) 1. `pull-workflow <flow_path>`flow + schedules 一括取得)
2. `push-workflow <workflow-dir>`flow反映 + schedules差分同期 2. `push-workflow <workflow-dir>`flow反映 + schedules差分同期
3. `status-workflow [flow_path]`workflow単位の差分表示 3. `status-workflow [flow_path]`workflow単位の差分表示
4. `pull-all-workflows`flow全件を workflow package で取得) 4. `pull-all-workflows`flow全件を workflow package で取得)
## Phase 3: メタ情報管理 ## Phase 3: メタ情報管理
- `state/remote_index.json` を拡張し、`scripts/flows/schedules` を保持 - `state/remote_index.json` を拡張し、`scripts/flows/schedules` を保持
- `state/workflow_index.json` を導入し、`workflow -> flow_hash + schedule_hashes` を保持 - `state/workflow_index.json` を導入し、`workflow -> flow_hash + schedule_hashes` を保持
## Phase 4: 標準化 ## Phase 4: 標準化
- `docs/flow-manage` に操作例を固定 - `docs/flow-manage` に操作例を固定
- 「作業開始時は必ずpull」を運用ルール化 - 「作業開始時は必ずpull」を運用ルール化
## Phase 5: 半自動化(任意) ## Phase 5: 半自動化(任意)
- AI/スクリプトで - AI/スクリプトで
- 変更検知 - 変更検知
- Pull提案 - Pull提案
- Push時の競合自動リカバリ - Push時の競合自動リカバリ
--- ---
## 8. 標準運用手順Runbook ## 8. 標準運用手順Runbook
### 8.1 日常の更新(推奨) ### 8.1 日常の更新(推奨)
1. `pull-all` でサーバー最新を取得 1. `pull-all` でサーバー最新を取得
2. ローカル編集 2. ローカル編集
3. workflow修正時は `push-workflow` で反映flow + schedules 3. workflow修正時は `push-workflow` で反映flow + schedules
4. script単体修正時のみ `push-script` を使う 4. script単体修正時のみ `push-script` を使う
5. 必要に応じてローカルGitにコミット 5. 必要に応じてローカルGitにコミット
### 8.2 サーバーで変更されたものを取り込む ### 8.2 サーバーで変更されたものを取り込む
1. `status-remote` 実行 1. `status-remote` 実行
2. workflow単位の変更は `status-workflow` / `pull-workflow` で取得 2. workflow単位の変更は `status-workflow` / `pull-workflow` で取得
3. 単体変更は `pull-*` で取得 3. 単体変更は `pull-*` で取得
4. ローカル履歴としてコミット(任意) 4. ローカル履歴としてコミット(任意)
### 8.3 `alexa_speak` 更新例(現在の具体例) ### 8.3 `alexa_speak` 更新例(現在の具体例)
1. `pull-script u/admin/alexa_speak scripts/alexa_speak.ts` 1. `pull-script u/admin/alexa_speak scripts/alexa_speak.ts`
2. `scripts/alexa_speak.ts` を編集 2. `scripts/alexa_speak.ts` を編集
3. `push-script u/admin/alexa_speak scripts/alexa_speak.ts` 3. `push-script u/admin/alexa_speak scripts/alexa_speak.ts`
4. Windmill UIで動作確認`device` がドロップダウン表示) 4. Windmill UIで動作確認`device` がドロップダウン表示)
5. API反映後にUIが変わらない場合は `Edit -> Deploy` を1回実行して再確認 5. API反映後にUIが変わらない場合は `Edit -> Deploy` を1回実行して再確認
関連引き継ぎ文書: 関連引き継ぎ文書:
- `docs/flow-manage/11_引き継ぎ_alexa_speak_API反映後にUIドロップダウンが変わらない件.md` - `docs/flow-manage/11_引き継ぎ_alexa_speak_API反映後にUIドロップダウンが変わらない件.md`
--- ---
## 9. セキュリティ・監査方針 ## 9. セキュリティ・監査方針
- APIトークンは環境変数またはローカル限定設定で管理 - APIトークンは環境変数またはローカル限定設定で管理
- リポジトリへ平文トークンをコミットしない - リポジトリへ平文トークンをコミットしない
- 反映時刻・対象path・実行者をログ化 - 反映時刻・対象path・実行者をログ化
- サーバーGit Syncは監査証跡として維持 - サーバーGit Syncは監査証跡として維持
--- ---
## 10. 障害前提の復旧設計(必須) ## 10. 障害前提の復旧設計(必須)
サーバー内部障害、API非冪等、通信断は防止不能とみなし、**復旧可能性を担保する**。 サーバー内部障害、API非冪等、通信断は防止不能とみなし、**復旧可能性を担保する**。
### 10.1 復旧の基本原則 ### 10.1 復旧の基本原則
1. Push前に対象オブジェクトをバックアップ保存必須 1. Push前に対象オブジェクトをバックアップ保存必須
2. Push後に `post-verify`(再取得して期待値比較)を必須化 2. Push後に `post-verify`(再取得して期待値比較)を必須化
3. Push直前に `preflight hash check` を必須化し、不一致時はPush停止fail closed 3. Push直前に `preflight hash check` を必須化し、不一致時はPush停止fail closed
4. 失敗時は「再実行」ではなく「現状確認 -> 復旧」の順で実施 4. 失敗時は「再実行」ではなく「現状確認 -> 復旧」の順で実施
### 10.2 標準復旧手順 ### 10.2 標準復旧手順
1. `get` で現状確認存在、content/value、version/hash 1. `get` で現状確認存在、content/value、version/hash
2. 期待状態との差分を判定 2. 期待状態との差分を判定
3. 必要ならバックアップから復元 3. 必要ならバックアップから復元
4. 復元後に `post-verify` 4. 復元後に `post-verify`
5. 復旧ログを記録日時、path、操作者、原因、処置 5. 復旧ログを記録日時、path、操作者、原因、処置
### 10.3 フロー欠落時の緊急復旧 ### 10.3 フロー欠落時の緊急復旧
`delete -> create` の途中失敗でフローが消失した場合: `delete -> create` の途中失敗でフローが消失した場合:
1. 直前バックアップJSONで即時 `flows/create` 1. 直前バックアップJSONで即時 `flows/create`
2. 依存スケジュールの有効性を確認 2. 依存スケジュールの有効性を確認
3. 関連ジョブの手動実行で動作確認 3. 関連ジョブの手動実行で動作確認
### 10.4 delete -> create 運用ガード(必須) ### 10.4 delete -> create 運用ガード(必須)
1. `push-flow` / `push-workflow` 前に対象flowのバックアップJSONを必ず保存 1. `push-flow` / `push-workflow` 前に対象flowのバックアップJSONを必ず保存
2. Preflightで hash 不一致なら自動Pushしない手動レビューへフォールバック 2. Preflightで hash 不一致なら自動Pushしない手動レビューへフォールバック
3. Push後に flow と schedules を再取得し、ローカル期待値と一致確認 3. Push後に flow と schedules を再取得し、ローカル期待値と一致確認
4. 不一致時は即時にバックアップから復元し、復旧ログを残す 4. 不一致時は即時にバックアップから復元し、復旧ログを残す
--- ---
## 11. Windmill依存を薄くする方針必須 ## 11. Windmill依存を薄くする方針必須
Windmill API依存は避けられないため、依存点を最小化する。 Windmill API依存は避けられないため、依存点を最小化する。
### 11.1 使用APIの固定 ### 11.1 使用APIの固定
- `scripts`: `list/get/create` - `scripts`: `list/get/create`
- `flows`: `list/get/create/delete` - `flows`: `list/get/create/delete`
- `schedules`: `list/get/create/delete`workflow package 同期で常用) - `schedules`: `list/get/create/delete`workflow package 同期で常用)
上記以外のAPIは原則使わない。 上記以外のAPIは原則使わない。
### 11.2 判定ロジックの優先順位 ### 11.2 判定ロジックの優先順位
1. 実体JSON比較content/valueの比較を主判定 1. 実体JSON比較content/valueの比較を主判定
2. `hash` / `version_id` / `edited_at` は補助判定 2. `hash` / `version_id` / `edited_at` は補助判定
3. 補助フィールド欠落時も動作継続できる実装にする 3. 補助フィールド欠落時も動作継続できる実装にする
### 11.3 仕様変更検知 ### 11.3 仕様変更検知
- 定期的に `smoke test` を実行してレスポンス形を検証 - 定期的に `smoke test` を実行してレスポンス形を検証
- 期待フィールド欠落時はPush停止fail closed - 期待フィールド欠落時はPush停止fail closed
- 仕様差分を本ドキュメントの更新履歴に反映 - 仕様差分を本ドキュメントの更新履歴に反映
--- ---
## 12. 受け入れ条件 ## 12. 受け入れ条件
以下を満たせば「このプロジェクトはサーバーのワークフローを管理するためのもの」と言える状態: 以下を満たせば「このプロジェクトはサーバーのワークフローを管理するためのもの」と言える状態:
1. ローカルから workflow packageflow + schedulesの Pull/Push がAPIで完結 1. ローカルから workflow packageflow + schedulesの Pull/Push がAPIで完結
2. サーバー更新が workflow 単位でローカル検知できる 2. サーバー更新が workflow 単位でローカル検知できる
3. 競合時の復旧手順が flow/schedule 両方で定義済み 3. 競合時の復旧手順が flow/schedule 両方で定義済み
4. 運用手順がこの文書だけで再現可能 4. 運用手順がこの文書だけで再現可能
--- ---
## 13. 既知の注意点 ## 13. 既知の注意点
1. `wm-api.sh``bash` 前提。WindowsのCRLF混入で shebang 実行失敗し得るため、`.gitattributes``*.sh text eol=lf` を固定し、実行は `bash wm-api.sh ...` を標準とする 1. `wm-api.sh``bash` 前提。WindowsのCRLF混入で shebang 実行失敗し得るため、`.gitattributes``*.sh text eol=lf` を固定し、実行は `bash wm-api.sh ...` を標準とする
2. フロー更新は環境により `PUT` できないため削除再作成を標準とする 2. フロー更新は環境により `PUT` できないため削除再作成を標準とする
3. 削除再作成は `version_id/hash/edited_at` を更新するため、Preflight hash check がないと競合上書きを見落とす可能性がある 3. 削除再作成は `version_id/hash/edited_at` を更新するため、Preflight hash check がないと競合上書きを見落とす可能性がある
4. 1つのflowに複数scheduleが紐づくことがあるため、`script_path` ベースで束ねて管理する 4. 1つのflowに複数scheduleが紐づくことがあるため、`script_path` ベースで束ねて管理する
5. API応答仕様はWindmillバージョン差で微差が出るため、初回導入時は `get` のレスポンス形を確認する 5. API応答仕様はWindmillバージョン差で微差が出るため、初回導入時は `get` のレスポンス形を確認する
--- ---
## 14. 更新履歴 ## 14. 更新履歴
| 日付 | 変更内容 | | 日付 | 変更内容 |
|------|----------| |------|----------|
| 2026-03-03 | 初版作成API一本化方針、同期仕様、実装計画、Runbookを定義 | | 2026-03-03 | 初版作成API一本化方針、同期仕様、実装計画、Runbookを定義 |
| 2026-03-03 | 障害前提の復旧設計、Windmill依存を薄くする方針を必須要件として追記 | | 2026-03-03 | 障害前提の復旧設計、Windmill依存を薄くする方針を必須要件として追記 |
| 2026-03-03 | 運用単位を workflow packageflow + schedulesへ変更し、実装計画とRunbookを更新 | | 2026-03-03 | 運用単位を workflow packageflow + schedulesへ変更し、実装計画とRunbookを更新 |
| 2026-03-03 | `delete -> create` と hash 管理の運用ガードpreflight / fail closed / post-verifyおよびCRLF対策を追記 | | 2026-03-03 | `delete -> create` と hash 管理の運用ガードpreflight / fail closed / post-verifyおよびCRLF対策を追記 |

View File

@@ -1,309 +1,309 @@
# Windmill 通知ワークフロー連携仕様 # Windmill 通知ワークフロー連携仕様
> **作成日**: 2026-02-21 > **作成日**: 2026-02-21
> **対象システム**: 白皇集落営農組合 統合システム (`shiraou.keinafarm.net`) > **対象システム**: 白皇集落営農組合 統合システム (`shiraou.keinafarm.net`)
> **目的**: 予約・実績の変更をLINEで管理者に通知するWindmillワークフローの実装仕様 > **目的**: 予約・実績の変更をLINEで管理者に通知するWindmillワークフローの実装仕様
--- ---
## 1. 概要 ## 1. 概要
統合システム側が「変更履歴取得API」を提供する。 統合システム側が「変更履歴取得API」を提供する。
Windmillは定期的にこのAPIをポーリングし、変更があればLINEへ通知する。 Windmillは定期的にこのAPIをポーリングし、変更があればLINEへ通知する。
``` ```
Windmill定期実行 Windmill定期実行
└→ GET https://shiraou.keinafarm.net/reservations/api/changes/?since=<前回実行時刻> └→ GET https://shiraou.keinafarm.net/reservations/api/changes/?since=<前回実行時刻>
└→ 変更一覧(予約・実績)を取得 └→ 変更一覧(予約・実績)を取得
└→ 変更があればLINE Messaging APIへ通知 └→ 変更があればLINE Messaging APIへ通知
└→ 前回実行時刻を更新 └→ 前回実行時刻を更新
``` ```
--- ---
## 2. 変更履歴取得API ## 2. 変更履歴取得API
### エンドポイント ### エンドポイント
``` ```
GET https://shiraou.keinafarm.net/reservations/api/changes/ GET https://shiraou.keinafarm.net/reservations/api/changes/
``` ```
### 認証 ### 認証
`X-API-Key` ヘッダーにAPIキーを指定する統合システム管理者から取得 `X-API-Key` ヘッダーにAPIキーを指定する統合システム管理者から取得
``` ```
X-API-Key: <NOTIFICATION_API_KEY> X-API-Key: <NOTIFICATION_API_KEY>
``` ```
APIキーが不正な場合は `401 Unauthorized` が返る。 APIキーが不正な場合は `401 Unauthorized` が返る。
### クエリパラメータ ### クエリパラメータ
| パラメータ | 型 | 必須 | 説明 | | パラメータ | 型 | 必須 | 説明 |
|-----------|-----|------|------| |-----------|-----|------|------|
| `since` | ISO8601文字列 | 必須 | この日時以降の変更を取得する | | `since` | ISO8601文字列 | 必須 | この日時以降の変更を取得する |
**`since` の形式例**: **`since` の形式例**:
- `2026-02-21T10:00:00` ナイーブ、JSTとして扱われる - `2026-02-21T10:00:00` ナイーブ、JSTとして扱われる
- `2026-02-21T10:00:00+09:00` (タイムゾーン付き、推奨) - `2026-02-21T10:00:00+09:00` (タイムゾーン付き、推奨)
### レスポンス200 OK ### レスポンス200 OK
```json ```json
{ {
"checked_at": "2026-02-21T12:00:00+09:00", "checked_at": "2026-02-21T12:00:00+09:00",
"since": "2026-02-21T10:00:00+09:00", "since": "2026-02-21T10:00:00+09:00",
"reservations": [ "reservations": [
{ {
"operation": "create", "operation": "create",
"reservation_id": 123, "reservation_id": 123,
"user_name": "田中太郎", "user_name": "田中太郎",
"machine_name": "トラクター", "machine_name": "トラクター",
"start_at": "2026-02-25T09:00:00+09:00", "start_at": "2026-02-25T09:00:00+09:00",
"end_at": "2026-02-25T12:00:00+09:00", "end_at": "2026-02-25T12:00:00+09:00",
"operated_at": "2026-02-21T11:30:00+09:00", "operated_at": "2026-02-21T11:30:00+09:00",
"operator_name": "田中太郎", "operator_name": "田中太郎",
"reason": "" "reason": ""
}, },
{ {
"operation": "cancel", "operation": "cancel",
"reservation_id": 120, "reservation_id": 120,
"user_name": "佐藤花子", "user_name": "佐藤花子",
"machine_name": "田植機", "machine_name": "田植機",
"start_at": "2026-02-22T08:00:00+09:00", "start_at": "2026-02-22T08:00:00+09:00",
"end_at": "2026-02-22T17:00:00+09:00", "end_at": "2026-02-22T17:00:00+09:00",
"operated_at": "2026-02-21T11:45:00+09:00", "operated_at": "2026-02-21T11:45:00+09:00",
"operator_name": "佐藤花子", "operator_name": "佐藤花子",
"reason": "" "reason": ""
} }
], ],
"usages": [ "usages": [
{ {
"operation": "update", "operation": "update",
"usage_id": 456, "usage_id": 456,
"user_name": "山田次郎", "user_name": "山田次郎",
"machine_name": "コンバイン", "machine_name": "コンバイン",
"amount": 4.0, "amount": 4.0,
"unit": "時間", "unit": "時間",
"start_at": "2026-02-20T08:00:00+09:00", "start_at": "2026-02-20T08:00:00+09:00",
"end_at": "2026-02-20T12:00:00+09:00", "end_at": "2026-02-20T12:00:00+09:00",
"operated_at": "2026-02-21T11:55:00+09:00", "operated_at": "2026-02-21T11:55:00+09:00",
"operator_name": "管理者A", "operator_name": "管理者A",
"reason": "記録ミスのため修正" "reason": "記録ミスのため修正"
} }
] ]
} }
``` ```
### operation の値一覧 ### operation の値一覧
**予約reservations**: **予約reservations**:
| 値 | 意味 | | 値 | 意味 |
|----|------| |----|------|
| `create` | 予約が作成された | | `create` | 予約が作成された |
| `update` | 予約の日時・機械が変更された | | `update` | 予約の日時・機械が変更された |
| `cancel` | 予約がキャンセルされた | | `cancel` | 予約がキャンセルされた |
**実績usages**: **実績usages**:
| 値 | 意味 | | 値 | 意味 |
|----|------| |----|------|
| `create` | 実績が登録された | | `create` | 実績が登録された |
| `update` | 実績が修正された | | `update` | 実績が修正された |
| `delete` | 実績が削除された | | `delete` | 実績が削除された |
### 変更なしの場合 ### 変更なしの場合
`reservations``usages` が両方空配列になる。通知は不要。 `reservations``usages` が両方空配列になる。通知は不要。
```json ```json
{ {
"checked_at": "2026-02-21T12:05:00+09:00", "checked_at": "2026-02-21T12:05:00+09:00",
"since": "2026-02-21T12:00:00+09:00", "since": "2026-02-21T12:00:00+09:00",
"reservations": [], "reservations": [],
"usages": [] "usages": []
} }
``` ```
### エラーレスポンス ### エラーレスポンス
| ステータス | 原因 | | ステータス | 原因 |
|-----------|------| |-----------|------|
| `401 Unauthorized` | APIキーが不正または未設定 | | `401 Unauthorized` | APIキーが不正または未設定 |
| `400 Bad Request` | `since` パラメータが欠落または不正な日時形式 | | `400 Bad Request` | `since` パラメータが欠落または不正な日時形式 |
--- ---
## 3. Windmillワークフロー設計 ## 3. Windmillワークフロー設計
### 3.1 スケジュール ### 3.1 スケジュール
- **実行間隔**: 5分毎`*/5 * * * *` - **実行間隔**: 5分毎`*/5 * * * *`
- 農業機械の予約という用途上、数分の遅延は許容範囲 - 農業機械の予約という用途上、数分の遅延は許容範囲
### 3.2 状態管理(前回実行時刻) ### 3.2 状態管理(前回実行時刻)
Windmillの **Resource** または **State Variable** に前回実行時刻を保存する。 Windmillの **Resource** または **State Variable** に前回実行時刻を保存する。
``` ```
変数名: last_checked_at 変数名: last_checked_at
初期値: (初回実行時は現在時刻 - 10分 を使用) 初期値: (初回実行時は現在時刻 - 10分 を使用)
``` ```
### 3.3 ワークフロー全体フロー(擬似コード) ### 3.3 ワークフロー全体フロー(擬似コード)
```python ```python
# 1. 前回実行時刻を取得 # 1. 前回実行時刻を取得
last_checked = get_state("last_checked_at") or (now() - 10 minutes) last_checked = get_state("last_checked_at") or (now() - 10 minutes)
# 2. 変更履歴を取得 # 2. 変更履歴を取得
response = GET "https://shiraou.keinafarm.net/reservations/api/changes/" response = GET "https://shiraou.keinafarm.net/reservations/api/changes/"
params: { since: last_checked.isoformat() } params: { since: last_checked.isoformat() }
headers: { "X-API-Key": NOTIFICATION_API_KEY } headers: { "X-API-Key": NOTIFICATION_API_KEY }
# 3. 変更があればLINEに通知 # 3. 変更があればLINEに通知
if response.reservations or response.usages: if response.reservations or response.usages:
message = format_line_message(response) message = format_line_message(response)
send_line_message(LINE_CHANNEL_ACCESS_TOKEN, LINE_USER_ID, message) send_line_message(LINE_CHANNEL_ACCESS_TOKEN, LINE_USER_ID, message)
# 4. 前回実行時刻を更新 # 4. 前回実行時刻を更新
set_state("last_checked_at", response.checked_at) set_state("last_checked_at", response.checked_at)
``` ```
### 3.4 LINEメッセージのフォーマット例 ### 3.4 LINEメッセージのフォーマット例
```python ```python
def format_line_message(data): def format_line_message(data):
lines = ["📋 営農システム 変更通知\n"] lines = ["📋 営農システム 変更通知\n"]
for r in data["reservations"]: for r in data["reservations"]:
start = r["start_at"][:16].replace("T", " ") start = r["start_at"][:16].replace("T", " ")
end = r["end_at"][:16].replace("T", " ") end = r["end_at"][:16].replace("T", " ")
if r["operation"] == "create": if r["operation"] == "create":
icon = "🟢" icon = "🟢"
label = "予約作成" label = "予約作成"
elif r["operation"] == "update": elif r["operation"] == "update":
icon = "🔵" icon = "🔵"
label = "予約変更" label = "予約変更"
elif r["operation"] == "cancel": elif r["operation"] == "cancel":
icon = "🔴" icon = "🔴"
label = "予約キャンセル" label = "予約キャンセル"
lines.append(f"{icon} {label}") lines.append(f"{icon} {label}")
lines.append(f" 機械: {r['machine_name']}") lines.append(f" 機械: {r['machine_name']}")
lines.append(f" 利用者: {r['user_name']}") lines.append(f" 利用者: {r['user_name']}")
lines.append(f" 日時: {start}{end}") lines.append(f" 日時: {start}{end}")
if r["reason"]: if r["reason"]:
lines.append(f" 理由: {r['reason']}") lines.append(f" 理由: {r['reason']}")
lines.append("") lines.append("")
for u in data["usages"]: for u in data["usages"]:
start = u["start_at"][:16].replace("T", " ") start = u["start_at"][:16].replace("T", " ")
if u["operation"] == "create": if u["operation"] == "create":
icon = "🟢" icon = "🟢"
label = "実績登録" label = "実績登録"
elif u["operation"] == "update": elif u["operation"] == "update":
icon = "🔵" icon = "🔵"
label = "実績修正" label = "実績修正"
elif u["operation"] == "delete": elif u["operation"] == "delete":
icon = "🔴" icon = "🔴"
label = "実績削除" label = "実績削除"
lines.append(f"{icon} {label}") lines.append(f"{icon} {label}")
lines.append(f" 機械: {u['machine_name']}") lines.append(f" 機械: {u['machine_name']}")
lines.append(f" 利用者: {u['user_name']}") lines.append(f" 利用者: {u['user_name']}")
lines.append(f" 利用量: {u['amount']}{u['unit']}") lines.append(f" 利用量: {u['amount']}{u['unit']}")
lines.append(f" 日: {start[:10]}") lines.append(f" 日: {start[:10]}")
if u["reason"]: if u["reason"]:
lines.append(f" 理由: {u['reason']}") lines.append(f" 理由: {u['reason']}")
lines.append("") lines.append("")
return "\n".join(lines).strip() return "\n".join(lines).strip()
``` ```
**出力例**: **出力例**:
``` ```
📋 営農システム 変更通知 📋 営農システム 変更通知
🟢 予約作成 🟢 予約作成
機械: トラクター 機械: トラクター
利用者: 田中太郎 利用者: 田中太郎
日時: 2026-02-25 09:00 〜 2026-02-25 12:00 日時: 2026-02-25 09:00 〜 2026-02-25 12:00
🔴 予約キャンセル 🔴 予約キャンセル
機械: 田植機 機械: 田植機
利用者: 佐藤花子 利用者: 佐藤花子
日時: 2026-02-22 08:00 〜 2026-02-22 17:00 日時: 2026-02-22 08:00 〜 2026-02-22 17:00
🔵 実績修正 🔵 実績修正
機械: コンバイン 機械: コンバイン
利用者: 山田次郎 利用者: 山田次郎
利用量: 4.0時間 利用量: 4.0時間
日: 2026-02-20 日: 2026-02-20
理由: 記録ミスのため修正 理由: 記録ミスのため修正
``` ```
--- ---
## 4. Windmill側の環境変数シークレット ## 4. Windmill側の環境変数シークレット
| 変数名 | 説明 | 設定場所 | | 変数名 | 説明 | 設定場所 |
|--------|------|---------| |--------|------|---------|
| `NOTIFICATION_API_KEY` | 統合システムのAPIキー | Windmill Secret | | `NOTIFICATION_API_KEY` | 統合システムのAPIキー | Windmill Secret |
| `LINE_CHANNEL_ACCESS_TOKEN` | LINE Messaging API チャネルアクセストークン | Windmill Secret | | `LINE_CHANNEL_ACCESS_TOKEN` | LINE Messaging API チャネルアクセストークン | Windmill Secret |
| `LINE_USER_ID` / `LINE_GROUP_ID` | 通知先のユーザーIDまたはグループID | Windmill Secret | | `LINE_USER_ID` / `LINE_GROUP_ID` | 通知先のユーザーIDまたはグループID | Windmill Secret |
--- ---
## 5. 統合システム側の設定django側の作業 ## 5. 統合システム側の設定django側の作業
本番サーバーの `docker-compose.yml` に以下の環境変数を追加し、デプロイする。 本番サーバーの `docker-compose.yml` に以下の環境変数を追加し、デプロイする。
```yaml ```yaml
environment: environment:
- NOTIFICATION_API_KEY=<任意の強いランダム文字列> - NOTIFICATION_API_KEY=<任意の強いランダム文字列>
``` ```
**APIキーの生成例**: **APIキーの生成例**:
```bash ```bash
openssl rand -hex 32 openssl rand -hex 32
``` ```
--- ---
## 6. 動作確認方法 ## 6. 動作確認方法
### curlで直接テスト ### curlで直接テスト
```bash ```bash
# 変更なし直近1分 # 変更なし直近1分
curl -H "X-API-Key: <キー>" \ curl -H "X-API-Key: <キー>" \
"https://shiraou.keinafarm.net/reservations/api/changes/?since=2026-02-21T11:59:00%2B09:00" "https://shiraou.keinafarm.net/reservations/api/changes/?since=2026-02-21T11:59:00%2B09:00"
# 広い範囲で変更を取得(初期確認用) # 広い範囲で変更を取得(初期確認用)
curl -H "X-API-Key: <キー>" \ curl -H "X-API-Key: <キー>" \
"https://shiraou.keinafarm.net/reservations/api/changes/?since=2026-01-01T00:00:00" "https://shiraou.keinafarm.net/reservations/api/changes/?since=2026-01-01T00:00:00"
# APIキーなし → 401が返ることを確認 # APIキーなし → 401が返ることを確認
curl "https://shiraou.keinafarm.net/reservations/api/changes/?since=2026-01-01T00:00:00" curl "https://shiraou.keinafarm.net/reservations/api/changes/?since=2026-01-01T00:00:00"
``` ```
### ダッシュボードで変更確認 ### ダッシュボードで変更確認
管理者アカウントで `https://shiraou.keinafarm.net/reservations/dashboard/` にアクセスすると、 管理者アカウントで `https://shiraou.keinafarm.net/reservations/dashboard/` にアクセスすると、
直近30件の予約操作履歴・実績操作ログを確認できる。 直近30件の予約操作履歴・実績操作ログを確認できる。
Windmillワークフローのデバッグ時に「APIが何を返すべきか」の確認に使える。 Windmillワークフローのデバッグ時に「APIが何を返すべきか」の確認に使える。
--- ---
## 7. 注意事項 ## 7. 注意事項
- `since` に渡す時刻は **前回の `checked_at`** を使う(`since` ではなく `checked_at` を保存すること) - `since` に渡す時刻は **前回の `checked_at`** を使う(`since` ではなく `checked_at` を保存すること)
- 同一の変更が2回通知されないよう、状態管理を確実に行う - 同一の変更が2回通知されないよう、状態管理を確実に行う
- ワークフローがエラーで終了した場合、`last_checked_at` を更新しないようにすること(次回実行時に再取得される) - ワークフローがエラーで終了した場合、`last_checked_at` を更新しないようにすること(次回実行時に再取得される)
- APIキーは定期的にローテーションすること変更時は統合システム側の環境変数も同時に更新 - APIキーは定期的にローテーションすること変更時は統合システム側の環境変数も同時に更新

View File

@@ -25,5 +25,5 @@
"required": [] "required": []
} }
} }

View File

@@ -1 +1 @@
defaultTs: bun defaultTs: bun