Compare commits
32 Commits
76db14cf10
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d6adeafdc | ||
|
|
8c90fe79bc | ||
|
|
555940d8f4 | ||
|
|
ef7c9d3c21 | ||
|
|
8ef4cfd81e | ||
|
|
dccca90835 | ||
|
|
bb958b3554 | ||
|
|
70f842f00e | ||
|
|
be5fd5a75b | ||
|
|
0f6a2caa41 | ||
|
|
9dec4b3ace | ||
|
|
d129777bf1 | ||
|
|
5a0a668a8a | ||
|
|
9c67910f3d | ||
|
|
0359072c30 | ||
|
|
f771e6bcf7 | ||
|
|
d5bb7f24dd | ||
|
|
4954cc0741 | ||
|
|
1be261c95f | ||
|
|
07258bb46d | ||
|
|
fe9ee0147c | ||
|
|
ee59724093 | ||
|
|
b2a4012ab5 | ||
|
|
9cbacec4c0 | ||
|
|
dcbf599015 | ||
|
|
9eb3c41584 | ||
|
|
0d9b2758e9 | ||
|
|
1496f4a5e6 | ||
|
|
34107f98a2 | ||
|
|
593d13d8a1 | ||
|
|
e762e230ba | ||
|
|
2d00dd6beb |
@@ -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
|
||||||
|
|||||||
@@ -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で直接操作する
|
||||||
|
|||||||
@@ -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経由でも操作可能
|
||||||
|
|||||||
@@ -9,7 +9,12 @@
|
|||||||
"Bash(git push:*)",
|
"Bash(git push:*)",
|
||||||
"Bash(git config:*)",
|
"Bash(git config:*)",
|
||||||
"Bash(printf:*)",
|
"Bash(printf:*)",
|
||||||
"Bash(~/.git-credentials)"
|
"Bash(~/.git-credentials)",
|
||||||
|
"Bash(git add:*)",
|
||||||
|
"Bash(git commit:*)",
|
||||||
|
"Bash(python -m json.tool)",
|
||||||
|
"Bash(AMAZON_EMAIL=\"akiracraftwork@gmail.com\" AMAZON_PASSWORD=\"Makomanai1225\" node auth4.js)",
|
||||||
|
"Bash(AMAZON_EMAIL=\"akiracraftwork@gmail.com\" AMAZON_PASSWORD=\"txready2\" node auth4.js)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
*.sh text eol=lf
|
||||||
49
.gitignore
vendored
49
.gitignore
vendored
@@ -1,20 +1,29 @@
|
|||||||
# Windmill secrets and sensitive data
|
# Windmill secrets and sensitive data
|
||||||
*.secret.*
|
*.secret.*
|
||||||
variables/
|
variables/
|
||||||
resources/
|
resources/
|
||||||
|
|
||||||
# wmill CLI
|
# Environment / secrets
|
||||||
wmill-lock.yaml
|
.env
|
||||||
|
.env.local
|
||||||
# Node
|
|
||||||
node_modules/
|
# Python
|
||||||
|
.venv/
|
||||||
# OS
|
__pycache__/
|
||||||
.DS_Store
|
*.pyc
|
||||||
Thumbs.db
|
|
||||||
|
# wmill CLI
|
||||||
# Editor
|
wmill-lock.yaml
|
||||||
.vscode/
|
|
||||||
.idea/
|
# Node
|
||||||
*.swp
|
node_modules/
|
||||||
*.swo
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Editor
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|||||||
2
.serena/.gitignore
vendored
Normal file
2
.serena/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/cache
|
||||||
|
/project.local.yml
|
||||||
152
.serena/project.yml
Normal file
152
.serena/project.yml
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
# the name by which the project can be referenced within Serena
|
||||||
|
project_name: "windmill_workflow"
|
||||||
|
|
||||||
|
|
||||||
|
# list of languages for which language servers are started; choose from:
|
||||||
|
# al bash clojure cpp csharp
|
||||||
|
# csharp_omnisharp dart elixir elm erlang
|
||||||
|
# fortran fsharp go groovy haskell
|
||||||
|
# java julia kotlin lua markdown
|
||||||
|
# matlab nix pascal perl php
|
||||||
|
# php_phpactor powershell python python_jedi r
|
||||||
|
# rego ruby ruby_solargraph rust scala
|
||||||
|
# swift terraform toml typescript typescript_vts
|
||||||
|
# vue yaml zig
|
||||||
|
# (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
|
||||||
|
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
|
||||||
|
# Note:
|
||||||
|
# - For C, use cpp
|
||||||
|
# - For JavaScript, use typescript
|
||||||
|
# - For Free Pascal/Lazarus, use pascal
|
||||||
|
# Special requirements:
|
||||||
|
# Some languages require additional setup/installations.
|
||||||
|
# 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.
|
||||||
|
# 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.
|
||||||
|
languages:
|
||||||
|
- python
|
||||||
|
|
||||||
|
# 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
|
||||||
|
encoding: "utf-8"
|
||||||
|
|
||||||
|
# line ending convention to use when writing source files.
|
||||||
|
# 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.
|
||||||
|
line_ending:
|
||||||
|
|
||||||
|
# The language backend to use for this project.
|
||||||
|
# If not set, the global setting from serena_config.yml is used.
|
||||||
|
# Valid values: LSP, JetBrains
|
||||||
|
# Note: the backend is fixed at startup. If a project with a different backend
|
||||||
|
# is activated post-init, an error will be returned.
|
||||||
|
language_backend:
|
||||||
|
|
||||||
|
# whether to use project's .gitignore files to ignore files
|
||||||
|
ignore_all_files_in_gitignore: true
|
||||||
|
|
||||||
|
# list of additional paths to ignore in this project.
|
||||||
|
# Same syntax as gitignore, so you can use * and **.
|
||||||
|
# Note: global ignored_paths from serena_config.yml are also applied additively.
|
||||||
|
ignored_paths: []
|
||||||
|
|
||||||
|
# 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
|
||||||
|
# Added on 2025-04-18
|
||||||
|
read_only: false
|
||||||
|
|
||||||
|
# list of tool names to exclude.
|
||||||
|
# This extends the existing exclusions (e.g. from the global configuration)
|
||||||
|
#
|
||||||
|
# Below is the complete list of tools for convenience.
|
||||||
|
# To make sure you have the latest list of tools, and to view their descriptions,
|
||||||
|
# execute `uv run scripts/print_tool_overview.py`.
|
||||||
|
#
|
||||||
|
# * `activate_project`: Activates a project by name.
|
||||||
|
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
|
||||||
|
# * `create_text_file`: Creates/overwrites a file in the project directory.
|
||||||
|
# * `delete_lines`: Deletes a range of lines within a file.
|
||||||
|
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
|
||||||
|
# * `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_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).
|
||||||
|
# * `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.
|
||||||
|
# * `initial_instructions`: Gets the initial instructions for the current project.
|
||||||
|
# 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.
|
||||||
|
# * `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_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_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).
|
||||||
|
# * `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_memory`: Reads the memory with the given name from Serena's project-specific memory store.
|
||||||
|
# * `remove_project`: Removes a project from the Serena configuration.
|
||||||
|
# * `replace_lines`: Replaces a range of lines within a file with new content.
|
||||||
|
# * `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.
|
||||||
|
# * `search_for_pattern`: Performs a search for a pattern in the project.
|
||||||
|
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
|
||||||
|
# * `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_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.
|
||||||
|
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
|
||||||
|
excluded_tools: []
|
||||||
|
|
||||||
|
# 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).
|
||||||
|
included_optional_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.
|
||||||
|
fixed_tools: []
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply.
|
||||||
|
# Otherwise, this setting overrides the global configuration.
|
||||||
|
# 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.
|
||||||
|
base_modes:
|
||||||
|
|
||||||
|
# list of mode names that are to be activated by default.
|
||||||
|
# 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.
|
||||||
|
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
|
||||||
|
# This setting can, in turn, be overridden by CLI parameters (--mode).
|
||||||
|
default_modes:
|
||||||
|
|
||||||
|
# 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).
|
||||||
|
initial_prompt: ""
|
||||||
|
|
||||||
|
# time budget (seconds) per tool call for the retrieval of additional symbol information
|
||||||
|
# such as docstrings or parameter information.
|
||||||
|
# This overrides the corresponding setting in the global configuration; see the documentation there.
|
||||||
|
# If null or missing, use the setting from the global configuration.
|
||||||
|
symbol_info_budget:
|
||||||
|
|
||||||
|
# list of regex patterns which, when matched, mark a memory entry as read‑only.
|
||||||
|
# Extends the list from the global configuration, merging the two lists.
|
||||||
|
read_only_memory_patterns: []
|
||||||
|
|
||||||
|
# list of regex patterns for memories to completely ignore.
|
||||||
|
# Matching memories will not appear in list_memories or activate_project output
|
||||||
|
# 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.
|
||||||
|
# Extends the list from the global configuration, merging the two lists.
|
||||||
|
# Example: ["_archive/.*", "_episodes/.*"]
|
||||||
|
ignored_memory_patterns: []
|
||||||
|
|
||||||
|
# advanced configuration option allowing to configure language server-specific 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.
|
||||||
|
# No documentation on options means no options are available.
|
||||||
|
ls_specific_settings: {}
|
||||||
123
CLAUDE.md
Normal file
123
CLAUDE.md
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
# Windmill Workflow プロジェクト
|
||||||
|
|
||||||
|
白皇集落営農組合 統合システム向けの Windmill ワークフロー管理リポジトリ。
|
||||||
|
|
||||||
|
## 環境
|
||||||
|
|
||||||
|
| 項目 | 値 |
|
||||||
|
|------|-----|
|
||||||
|
| Windmillサーバー | https://windmill.keinafarm.net |
|
||||||
|
| ワークスペース | `admins` |
|
||||||
|
| APIトークン | `wm-api.sh` に設定済み |
|
||||||
|
| Gitリモート | https://gitea.keinafarm.net/akira/windmill_workflow.git |
|
||||||
|
|
||||||
|
## 重要な制約
|
||||||
|
|
||||||
|
- **`wmill` CLIは使用不可**(CE版のグローバルAPI認証制限)→ `wm-api.sh` または curl で直接REST APIを叩く
|
||||||
|
- **フローの PUT更新は 405**(Windmill CE版の制限)→ DELETE してから POST で再作成する
|
||||||
|
- **`wmill.get_state()` はインラインフロースクリプトで永続化されない**→ 状態管理は必ず `wmill.get_variable()` / `set_variable()` を使う
|
||||||
|
- **`python3` コマンドは Windows環境で失敗する**→ curl の出力は grep等で直接処理する
|
||||||
|
|
||||||
|
## ディレクトリ構成
|
||||||
|
|
||||||
|
```
|
||||||
|
windmill_workflow/
|
||||||
|
├── flows/ # フロー定義JSON
|
||||||
|
│ ├── system_heartbeat.flow.json # Windmill自己診断フロー
|
||||||
|
│ ├── shiraou_notification.flow.json # 白皇集落 変更通知フロー
|
||||||
|
│ └── mail_filter.flow.json # メールフィルタリングフロー
|
||||||
|
├── docs/
|
||||||
|
│ └── shiraou/ # 白皇集落営農組合関連ドキュメント
|
||||||
|
│ ├── 19_windmill_通知ワークフロー連携仕様.md # API仕様書
|
||||||
|
│ └── 20_マスタードキュメント_Windmill通知ワークフロー編.md # マスタードキュメント
|
||||||
|
├── .agent/workflows/ # エージェント作業手順
|
||||||
|
│ ├── windmill-push.md # サーバーへのpush手順
|
||||||
|
│ ├── windmill-pull.md # サーバーからのpull手順
|
||||||
|
│ └── windmill-new-script.md # 新規スクリプト作成手順
|
||||||
|
├── wm-api.sh # Windmill REST APIヘルパー
|
||||||
|
└── wmill.yaml # wmill設定(defaultTs: bun)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 登録済みワークフロー
|
||||||
|
|
||||||
|
| パス | 概要 | スケジュール |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `f/app_custom/system_heartbeat` | Windmill自己診断 | なし(手動) |
|
||||||
|
| `f/shiraou/shiraou_notification` | 白皇集落営農 変更通知 | 5分毎(JST) |
|
||||||
|
| `f/mail/mail_filter` | メールフィルタリング(IMAP→LLM→LINE) | 10分毎(JST)予定 |
|
||||||
|
| `u/antigravity/git_sync` | Git同期 | 30分毎 |
|
||||||
|
|
||||||
|
## wm-api.sh コマンド一覧
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./wm-api.sh version # サーバーバージョン確認
|
||||||
|
./wm-api.sh flows # フロー一覧
|
||||||
|
./wm-api.sh schedules # スケジュール一覧
|
||||||
|
./wm-api.sh get-flow <path> # フロー詳細取得
|
||||||
|
./wm-api.sh create-flow <json-file> # フロー作成
|
||||||
|
./wm-api.sh create-schedule <json-file> # スケジュール作成
|
||||||
|
./wm-api.sh run-flow <path> # フロー手動実行
|
||||||
|
./wm-api.sh job-status <job-id> # ジョブ状態確認
|
||||||
|
```
|
||||||
|
|
||||||
|
## フローのデプロイ手順
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. flows/*.flow.json を編集
|
||||||
|
|
||||||
|
# 2. 削除して再作成(PUTは405のため)
|
||||||
|
curl -sk -X DELETE \
|
||||||
|
-H "Authorization: Bearer qLJ3VPZ61kTDiIwaUPUu1dXszGrsN1Dh" \
|
||||||
|
"https://windmill.keinafarm.net/api/w/admins/flows/delete/<path>"
|
||||||
|
|
||||||
|
curl -sk -X POST \
|
||||||
|
-H "Authorization: Bearer qLJ3VPZ61kTDiIwaUPUu1dXszGrsN1Dh" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d @flows/<file>.flow.json \
|
||||||
|
"https://windmill.keinafarm.net/api/w/admins/flows/create"
|
||||||
|
|
||||||
|
# 3. コミット&プッシュ
|
||||||
|
git add flows/<file>.flow.json
|
||||||
|
git commit -m "..."
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
## Windmill Variables
|
||||||
|
|
||||||
|
| 変数パス | Secret | 説明 |
|
||||||
|
|---------|--------|------|
|
||||||
|
| `u/admin/NOTIFICATION_API_KEY` | ✅ | shiraou APIキー |
|
||||||
|
| `u/admin/LINE_CHANNEL_ACCESS_TOKEN` | ✅ | LINE Messaging APIトークン |
|
||||||
|
| `u/admin/LINE_TO` | ✅ | LINE通知先ID(ユーザーまたはグループ) |
|
||||||
|
| `u/admin/SHIRAOU_LAST_CHECKED_AT` | ❌ | 前回確認時刻(ワークフローが自動更新) |
|
||||||
|
| `u/admin/KEINASYSTEM_API_KEY` | ✅ | Keinasystem MAIL_API_KEY(.envと同じ値) |
|
||||||
|
| `u/admin/KEINASYSTEM_API_URL` | ❌ | `https://keinafarm.net` |
|
||||||
|
| `u/admin/GEMINI_API_KEY` | ✅ | Google Gemini API キー(LLM判定用) |
|
||||||
|
| `u/admin/GMAIL_IMAP_USER` | ✅ | GmailアカウントのIMAPユーザー名(メールアドレス) |
|
||||||
|
| `u/admin/GMAIL_IMAP_PASSWORD` | ✅ | GmailのアプリパスワードIMAPパスワード) |
|
||||||
|
| `u/admin/MAIL_FILTER_GMAIL_LAST_UID` | ❌ | Gmail最終処理UID(ワークフローが自動更新) |
|
||||||
|
| `u/admin/HOTMAIL_IMAP_USER` | ✅ | Hotmail IMAPユーザー名(有効化時に登録) |
|
||||||
|
| `u/admin/HOTMAIL_IMAP_PASSWORD` | ✅ | Hotmail IMAPパスワード(有効化時に登録) |
|
||||||
|
| `u/admin/MAIL_FILTER_HOTMAIL_LAST_UID` | ❌ | Hotmail最終処理UID(有効化時に登録) |
|
||||||
|
| `u/admin/XSERVER_IMAP_USER` | ✅ | Xserver IMAPユーザー名(有効化時に登録) |
|
||||||
|
| `u/admin/XSERVER_IMAP_PASSWORD` | ✅ | Xserver IMAPパスワード(有効化時に登録) |
|
||||||
|
| `u/admin/MAIL_FILTER_XSERVER_LAST_UID` | ❌ | Xserver最終処理UID(有効化時に登録) |
|
||||||
|
|
||||||
|
## マスタードキュメント
|
||||||
|
|
||||||
|
- [白皇集落 Windmill通知ワークフロー](docs/shiraou/20_マスタードキュメント_Windmill通知ワークフロー編.md)
|
||||||
|
|
||||||
|
## メールフィルタリング — アカウント有効化手順
|
||||||
|
|
||||||
|
Gmail → Hotmail → Xserver の順で段階的に有効化する。
|
||||||
|
|
||||||
|
### Gmail 初期設定
|
||||||
|
1. GoogleアカウントでIMAPを有効化(Googleアカウント設定 → セキュリティ → アプリパスワード)
|
||||||
|
2. Windmill Variables に `GMAIL_IMAP_USER`, `GMAIL_IMAP_PASSWORD` を登録
|
||||||
|
3. フローを手動実行(初回: 既存メールスキップ、最大UIDを記録)
|
||||||
|
4. スケジュール登録(10分毎)
|
||||||
|
|
||||||
|
### Hotmail/Xserver 追加時
|
||||||
|
1. Windmill Variables に対応する変数を登録
|
||||||
|
2. `flows/mail_filter.flow.json` の該当アカウントの `"enabled": false` を `true` に変更
|
||||||
|
3. フローを DELETE → POST で再デプロイ
|
||||||
6
alexa-api/.env.example
Normal file
6
alexa-api/.env.example
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# alexa-api/.env
|
||||||
|
# このファイルをコピーして .env を作成し、ALEXA_COOKIE に値を設定する
|
||||||
|
# .env は Git にコミットしない(.gitignore 参照)
|
||||||
|
|
||||||
|
# Amazon Cookie(auth.js を実行して取得)
|
||||||
|
ALEXA_COOKIE=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
2
alexa-api/.gitignore
vendored
Normal file
2
alexa-api/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
.env
|
||||||
14
alexa-api/Dockerfile
Normal file
14
alexa-api/Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 依存パッケージのみ先にコピー(キャッシュ活用)
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install --omit=dev
|
||||||
|
|
||||||
|
# ソースをコピー
|
||||||
|
COPY server.js .
|
||||||
|
|
||||||
|
EXPOSE 3500
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
35
alexa-api/alexa-cookie-deploy.sh
Normal file
35
alexa-api/alexa-cookie-deploy.sh
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ $# -ne 1 ]]; then
|
||||||
|
echo "usage: $0 /tmp/alexa-api.env" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
SRC_ENV="$1"
|
||||||
|
DEST_ENV="/home/claude/alexa-api/.env"
|
||||||
|
DEST_DIR="/home/claude/alexa-api"
|
||||||
|
COMPOSE_FILE="/home/claude/alexa-api/docker-compose.yml"
|
||||||
|
|
||||||
|
if [[ "$SRC_ENV" != /tmp/* ]]; then
|
||||||
|
echo "source env must be under /tmp" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "$SRC_ENV" ]]; then
|
||||||
|
echo "source env not found: $SRC_ENV" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
grep -q '^ALEXA_COOKIE=' "$SRC_ENV" || {
|
||||||
|
echo "ALEXA_COOKIE entry not found in $SRC_ENV" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
install -d -m 755 "$DEST_DIR"
|
||||||
|
install -m 600 "$SRC_ENV" "$DEST_ENV"
|
||||||
|
|
||||||
|
docker compose --env-file "$DEST_ENV" -f "$COMPOSE_FILE" restart
|
||||||
|
|
||||||
|
rm -f "$SRC_ENV"
|
||||||
|
echo "alexa cookie deployed"
|
||||||
1
alexa-api/alexa-cookie-deploy.sudoers
Normal file
1
alexa-api/alexa-cookie-deploy.sudoers
Normal file
@@ -0,0 +1 @@
|
|||||||
|
akira ALL=(root) NOPASSWD: /usr/local/bin/alexa-cookie-deploy.sh /tmp/alexa-api.env
|
||||||
70
alexa-api/auth.js
Normal file
70
alexa-api/auth.js
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* alexa-api/auth.js
|
||||||
|
* ローカル PC で実行して Amazon Cookie を取得するスクリプト。
|
||||||
|
*
|
||||||
|
* 使い方:
|
||||||
|
* cd alexa-api
|
||||||
|
* npm install alexa-cookie2
|
||||||
|
* node auth.js
|
||||||
|
*
|
||||||
|
* → ブラウザで http://localhost:3456 を開く
|
||||||
|
* → Amazon にログイン
|
||||||
|
* → コンソールに Cookie が表示される
|
||||||
|
* → その値を Windmill Variable "u/admin/ALEXA_COOKIE" に登録
|
||||||
|
*/
|
||||||
|
|
||||||
|
const AlexaCookie = require('alexa-cookie2');
|
||||||
|
|
||||||
|
const PROXY_PORT = 3456;
|
||||||
|
|
||||||
|
console.log('==============================================');
|
||||||
|
console.log(' Alexa Cookie 取得ツール');
|
||||||
|
console.log('==============================================');
|
||||||
|
console.log(`\n認証プロキシを起動中... (port ${PROXY_PORT})`);
|
||||||
|
console.log('\n【手順】');
|
||||||
|
console.log(` 1. ブラウザで http://localhost:${PROXY_PORT} を開く`);
|
||||||
|
console.log(' 2. Amazon アカウントにログイン(amazon.co.jp)');
|
||||||
|
console.log(' 3. ログイン完了後、このコンソールに Cookie が表示される\n');
|
||||||
|
|
||||||
|
AlexaCookie.generateAlexaCookie(
|
||||||
|
'',
|
||||||
|
{
|
||||||
|
amazonPage: 'amazon.co.jp',
|
||||||
|
acceptLanguage: 'ja-JP',
|
||||||
|
setupProxy: true,
|
||||||
|
proxyPort: PROXY_PORT,
|
||||||
|
proxyOwnIp: '127.0.0.1',
|
||||||
|
proxyListenBind: '0.0.0.0',
|
||||||
|
logger: (msg) => {
|
||||||
|
if (!msg.includes('verbose') && !msg.includes('DEBUG')) {
|
||||||
|
console.log('[auth]', msg);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
(err, cookie) => {
|
||||||
|
// alexa-cookie2 はブラウザを開くよう促すメッセージも err として渡してくる
|
||||||
|
if (err) {
|
||||||
|
const msg = err.message || String(err);
|
||||||
|
if (msg.includes('Please open')) {
|
||||||
|
// これは実際のエラーではなく「ブラウザで開いて」という指示
|
||||||
|
console.log('\n>>> ブラウザで http://localhost:3456/ を開いて Amazon にログインしてください <<<\n');
|
||||||
|
// プロキシを生かしたまま待機(process.exit しない)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error('\n[ERROR] 認証失敗:', msg);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n==============================================');
|
||||||
|
console.log(' Cookie 取得成功!');
|
||||||
|
console.log('==============================================');
|
||||||
|
console.log('\n以下の値を Windmill Variable に登録してください:');
|
||||||
|
console.log(' パス: u/admin/ALEXA_COOKIE');
|
||||||
|
console.log(' Secret: ON(チェックを入れる)');
|
||||||
|
console.log('\n--- Cookie ---');
|
||||||
|
console.log(cookie);
|
||||||
|
console.log('--- ここまで ---\n');
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
);
|
||||||
76
alexa-api/auth2.js
Normal file
76
alexa-api/auth2.js
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* auth2.js - alexa-remote2 自身の認証フローを使う
|
||||||
|
* alexa-cookie2 より確実(ライブラリ内蔵の OAuth プロキシを使用)
|
||||||
|
*
|
||||||
|
* 使い方:
|
||||||
|
* node auth2.js
|
||||||
|
* → ブラウザで http://localhost:3001/ を開いて Amazon にログイン
|
||||||
|
* → 成功するとコンソールに Cookie が出力される → .env に保存
|
||||||
|
*/
|
||||||
|
|
||||||
|
const AlexaRemote = require('alexa-remote2');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const PORT = 3001;
|
||||||
|
|
||||||
|
console.log('==============================================');
|
||||||
|
console.log(' Alexa 認証ツール (alexa-remote2 内蔵プロキシ)');
|
||||||
|
console.log('==============================================');
|
||||||
|
console.log(`\nプロキシ起動中... (port ${PORT})`);
|
||||||
|
console.log(`\n【手順】ブラウザで http://localhost:${PORT}/ を開いて Amazon にログイン\n`);
|
||||||
|
|
||||||
|
const alexa = new AlexaRemote();
|
||||||
|
|
||||||
|
alexa.init(
|
||||||
|
{
|
||||||
|
cookie: null,
|
||||||
|
alexaServiceHost: 'alexa.amazon.co.jp',
|
||||||
|
amazonPage: 'amazon.co.jp',
|
||||||
|
acceptLanguage: 'ja-JP',
|
||||||
|
useWsMqtt: false,
|
||||||
|
setupProxy: true,
|
||||||
|
proxyOwnIp: '127.0.0.1',
|
||||||
|
proxyPort: PORT,
|
||||||
|
proxyListenBind: '0.0.0.0',
|
||||||
|
logger: console.log,
|
||||||
|
onSucess: (refreshedCookie) => {
|
||||||
|
// 認証成功時にリフレッシュされた Cookie を受け取る
|
||||||
|
console.log('\n[onSucess] Cookie refreshed');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
(err, result) => {
|
||||||
|
if (err) {
|
||||||
|
const msg = err.message || String(err);
|
||||||
|
if (msg.includes('open') && (msg.includes('http://') || msg.includes('localhost'))) {
|
||||||
|
console.log(`\n>>> ブラウザで http://localhost:${PORT}/ を開いてください <<<\n`);
|
||||||
|
// プロキシを生かしたまま待機
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error('\n[ERROR]', msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 認証成功
|
||||||
|
console.log('\n==============================================');
|
||||||
|
console.log(' 認証成功!');
|
||||||
|
console.log('==============================================');
|
||||||
|
|
||||||
|
// alexa-remote2 内部の Cookie を取得
|
||||||
|
const cookie = alexa.cookie || alexa._options?.cookie;
|
||||||
|
if (cookie) {
|
||||||
|
const cookieStr = typeof cookie === 'string' ? cookie : JSON.stringify(cookie);
|
||||||
|
console.log('\n以下を alexa-api/.env の ALEXA_COOKIE に設定してください:\n');
|
||||||
|
console.log('ALEXA_COOKIE=' + cookieStr);
|
||||||
|
|
||||||
|
// .env に自動保存
|
||||||
|
const envPath = path.join(__dirname, '.env');
|
||||||
|
fs.writeFileSync(envPath, 'ALEXA_COOKIE=' + cookieStr + '\n');
|
||||||
|
console.log(`\n.env に自動保存しました: ${envPath}`);
|
||||||
|
} else {
|
||||||
|
console.log('Cookie を取得できませんでした。alexa オブジェクト:', Object.keys(alexa));
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
);
|
||||||
76
alexa-api/auth3.js
Normal file
76
alexa-api/auth3.js
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* auth3.js - メール/パスワードで直接認証(2FA なしのアカウント向け)
|
||||||
|
*
|
||||||
|
* 使い方:
|
||||||
|
* AMAZON_EMAIL="xxx@xxx.com" AMAZON_PASSWORD="yourpass" node auth3.js
|
||||||
|
*
|
||||||
|
* 成功すると .env を更新して終了します。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const AlexaRemote = require('alexa-remote2');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const email = process.env.AMAZON_EMAIL;
|
||||||
|
const password = process.env.AMAZON_PASSWORD;
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
console.error('[ERROR] 環境変数 AMAZON_EMAIL と AMAZON_PASSWORD を設定してください');
|
||||||
|
console.error(' 例: AMAZON_EMAIL="xxx@xxx.com" AMAZON_PASSWORD="pass" node auth3.js');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[INFO] ${email} でログイン試行中...`);
|
||||||
|
|
||||||
|
const alexa = new AlexaRemote();
|
||||||
|
|
||||||
|
alexa.init(
|
||||||
|
{
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
alexaServiceHost: 'alexa.amazon.co.jp',
|
||||||
|
amazonPage: 'amazon.co.jp',
|
||||||
|
acceptLanguage: 'ja-JP',
|
||||||
|
useWsMqtt: false,
|
||||||
|
setupProxy: false,
|
||||||
|
logger: (msg) => {
|
||||||
|
if (!msg.includes('verbose') && !msg.includes('Bearer')) {
|
||||||
|
console.log('[alexa]', msg);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSucess: (refreshedCookie) => {
|
||||||
|
saveCookie(refreshedCookie, 'onSucess refresh');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('[ERROR] 認証失敗(詳細):', err);
|
||||||
|
console.error('\n考えられる原因:');
|
||||||
|
console.error(' - パスワードが違う');
|
||||||
|
console.error(' - Amazon が CAPTCHA を要求している(後で再試行)');
|
||||||
|
console.error(' - 2FA が実際は有効になっている');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 認証成功
|
||||||
|
const cookie = alexa.cookie;
|
||||||
|
saveCookie(cookie, 'init success');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
function saveCookie(cookie, source) {
|
||||||
|
if (!cookie) {
|
||||||
|
console.error(`[${source}] Cookie が空です`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cookieStr = typeof cookie === 'string' ? cookie : JSON.stringify(cookie);
|
||||||
|
const envPath = path.join(__dirname, '.env');
|
||||||
|
fs.writeFileSync(envPath, 'ALEXA_COOKIE=' + cookieStr + '\n');
|
||||||
|
|
||||||
|
console.log('\n==============================================');
|
||||||
|
console.log(' 認証成功!');
|
||||||
|
console.log('==============================================');
|
||||||
|
console.log('.env を更新しました:', envPath);
|
||||||
|
console.log('Cookie length:', cookieStr.length);
|
||||||
|
}
|
||||||
239
alexa-api/auth4-core.js
Normal file
239
alexa-api/auth4-core.js
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
const https = require('https');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const ALEXA_LOGIN_URL =
|
||||||
|
'https://www.amazon.co.jp/ap/signin?' +
|
||||||
|
new URLSearchParams({
|
||||||
|
'openid.assoc_handle': 'amzn_dp_project_dee_jp',
|
||||||
|
'openid.mode': 'checkid_setup',
|
||||||
|
'openid.ns': 'http://specs.openid.net/auth/2.0',
|
||||||
|
'openid.claimed_id': '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',
|
||||||
|
'pageId': 'amzn_dp_project_dee_jp',
|
||||||
|
}).toString();
|
||||||
|
|
||||||
|
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';
|
||||||
|
|
||||||
|
function createLogger(logger) {
|
||||||
|
if (!logger) return function() {};
|
||||||
|
if (typeof logger === 'function') return logger;
|
||||||
|
if (typeof logger.log === 'function') {
|
||||||
|
return function(message) {
|
||||||
|
logger.log(message);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return function() {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRequestState() {
|
||||||
|
const cookieJar = {};
|
||||||
|
|
||||||
|
function setCookies(setCookieHeaders) {
|
||||||
|
if (!setCookieHeaders) return;
|
||||||
|
const headers = Array.isArray(setCookieHeaders) ? setCookieHeaders : [setCookieHeaders];
|
||||||
|
for (const header of headers) {
|
||||||
|
const parts = header.split(';');
|
||||||
|
const kv = parts[0] || '';
|
||||||
|
const index = kv.indexOf('=');
|
||||||
|
if (index <= 0) continue;
|
||||||
|
const key = kv.slice(0, index).trim();
|
||||||
|
const value = kv.slice(index + 1).trim();
|
||||||
|
cookieJar[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCookieHeader() {
|
||||||
|
return Object.entries(cookieJar)
|
||||||
|
.map(([key, value]) => `${key}=${value}`)
|
||||||
|
.join('; ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function request(url, options) {
|
||||||
|
const opts = options || {};
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
const reqOpts = {
|
||||||
|
hostname: parsed.hostname,
|
||||||
|
path: parsed.pathname + parsed.search,
|
||||||
|
method: opts.method || 'GET',
|
||||||
|
headers: {
|
||||||
|
'User-Agent': USER_AGENT,
|
||||||
|
'Accept-Language': 'ja-JP,ja;q=0.9',
|
||||||
|
'Accept': 'text/html,application/xhtml+xml,*/*;q=0.8',
|
||||||
|
'Cookie': getCookieHeader(),
|
||||||
|
...(opts.headers || {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = https.request(reqOpts, (res) => {
|
||||||
|
setCookies(res.headers['set-cookie']);
|
||||||
|
let body = '';
|
||||||
|
res.on('data', (chunk) => {
|
||||||
|
body += chunk;
|
||||||
|
});
|
||||||
|
res.on('end', () => {
|
||||||
|
resolve({ status: res.statusCode, headers: res.headers, body });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
if (opts.body) req.write(opts.body);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
cookieJar,
|
||||||
|
getCookieHeader,
|
||||||
|
request,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractHiddenFields(html) {
|
||||||
|
const fields = {};
|
||||||
|
const re = /<input[^>]+type=["']?hidden["']?[^>]*>/gi;
|
||||||
|
let match;
|
||||||
|
while ((match = re.exec(html)) !== null) {
|
||||||
|
const tag = match[0];
|
||||||
|
const name = (tag.match(/name=["']([^"']+)["']/) || [])[1];
|
||||||
|
const value = (tag.match(/value=["']([^"']*)["']/) || ['', ''])[1];
|
||||||
|
if (name) fields[name] = value;
|
||||||
|
}
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractFormAction(html) {
|
||||||
|
const match = html.match(/id="ap_login_form"[^>]+action="([^"]+)"/);
|
||||||
|
if (match) return match[1].replace(/&/g, '&');
|
||||||
|
const match2 = html.match(/name="signIn"[^>]+action="([^"]+)"/);
|
||||||
|
if (match2) return match2[1].replace(/&/g, '&');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFailureMessage(current, cookieJar) {
|
||||||
|
const keys = Object.keys(cookieJar).join(', ') || '(none)';
|
||||||
|
const hints = [];
|
||||||
|
if (current.body && (current.body.includes('captcha') || current.body.includes('CAPTCHA'))) {
|
||||||
|
hints.push('CAPTCHA が要求されています。少し待ってから再試行してください。');
|
||||||
|
}
|
||||||
|
if (current.body && current.body.includes('auth-mfa-form')) {
|
||||||
|
hints.push('MFA が要求されています。このスクリプトだけでは完了できません。');
|
||||||
|
}
|
||||||
|
if (current.body && current.body.includes('password') && current.body.includes('error')) {
|
||||||
|
hints.push('パスワードが間違っている可能性があります。');
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = [
|
||||||
|
'認証に失敗しました。取得できた Cookie に認証トークンが含まれていません。',
|
||||||
|
`取得済み Cookie キー: ${keys}`,
|
||||||
|
...hints,
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const error = new Error(message);
|
||||||
|
error.code = 'ALEXA_AUTH_FAILED';
|
||||||
|
error.hints = hints;
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAlexaCookie(options) {
|
||||||
|
const opts = options || {};
|
||||||
|
const email = opts.email;
|
||||||
|
const password = opts.password;
|
||||||
|
const log = createLogger(opts.logger);
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
const error = new Error('email と password は必須です');
|
||||||
|
error.code = 'MISSING_CREDENTIALS';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = createRequestState();
|
||||||
|
const request = state.request;
|
||||||
|
const cookieJar = state.cookieJar;
|
||||||
|
|
||||||
|
log('[1] ログインページ取得中...');
|
||||||
|
const page1 = await request(ALEXA_LOGIN_URL);
|
||||||
|
log(` Status: ${page1.status}, Cookies: ${Object.keys(cookieJar).join(', ')}`);
|
||||||
|
|
||||||
|
if (page1.status !== 200) {
|
||||||
|
const error = new Error(`ログインページ取得失敗: ${page1.status}`);
|
||||||
|
error.code = 'LOGIN_PAGE_FETCH_FAILED';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const action = extractFormAction(page1.body);
|
||||||
|
const hidden = extractHiddenFields(page1.body);
|
||||||
|
|
||||||
|
if (!action) {
|
||||||
|
const error = new Error('ログインフォームが見つかりません。Amazon のログイン画面仕様が変わった可能性があります。');
|
||||||
|
error.code = 'LOGIN_FORM_NOT_FOUND';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`[2] フォーム送信先: ${action}`);
|
||||||
|
log(` Hidden fields: ${Object.keys(hidden).join(', ')}`);
|
||||||
|
|
||||||
|
const formData = new URLSearchParams({
|
||||||
|
...hidden,
|
||||||
|
email: email,
|
||||||
|
password: password,
|
||||||
|
rememberMe: 'true',
|
||||||
|
}).toString();
|
||||||
|
|
||||||
|
log('[3] 認証送信中...');
|
||||||
|
const page2 = await request(action, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'Referer': ALEXA_LOGIN_URL,
|
||||||
|
},
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
log(` Status: ${page2.status}`);
|
||||||
|
log(` Location: ${page2.headers.location || '(none)'}`);
|
||||||
|
log(` Cookies after login: ${Object.keys(cookieJar).join(', ')}`);
|
||||||
|
|
||||||
|
let current = page2;
|
||||||
|
let redirectCount = 0;
|
||||||
|
while (current.status >= 300 && current.status < 400 && current.headers.location && redirectCount < 10) {
|
||||||
|
const location = current.headers.location;
|
||||||
|
const nextUrl = location.startsWith('http') ? location : `https://www.amazon.co.jp${location}`;
|
||||||
|
log(`[${4 + redirectCount}] Redirect -> ${nextUrl.substring(0, 80)}...`);
|
||||||
|
current = await request(nextUrl);
|
||||||
|
log(` Status: ${current.status}, New Cookies: ${Object.keys(cookieJar).join(', ')}`);
|
||||||
|
redirectCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookie = state.getCookieHeader();
|
||||||
|
const hasAlexaToken = cookie.includes('at-acbjp') || cookie.includes('session-token');
|
||||||
|
if (!hasAlexaToken) throw buildFailureMessage(current, cookieJar);
|
||||||
|
|
||||||
|
return {
|
||||||
|
cookie,
|
||||||
|
cookieLength: cookie.length,
|
||||||
|
cookieKeys: Object.keys(cookieJar),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveCookieToEnv(cookie, envPath) {
|
||||||
|
const targetEnvPath = envPath || path.join(__dirname, '.env');
|
||||||
|
fs.writeFileSync(targetEnvPath, `ALEXA_COOKIE=${cookie}\n`);
|
||||||
|
return targetEnvPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAlexaCookieAndSave(options) {
|
||||||
|
const result = await fetchAlexaCookie(options);
|
||||||
|
const envPath = saveCookieToEnv(result.cookie, options && options.envPath);
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
envPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
fetchAlexaCookie,
|
||||||
|
fetchAlexaCookieAndSave,
|
||||||
|
saveCookieToEnv,
|
||||||
|
};
|
||||||
369
alexa-api/auth4-web.js
Normal file
369
alexa-api/auth4-web.js
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { execFile } = require('child_process');
|
||||||
|
const { fetchAlexaCookieAndSave } = require('./auth4-core');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.AUTH4_WEB_PORT || 3678;
|
||||||
|
const LOCAL_ENV_PATH = __dirname + '/.env';
|
||||||
|
const DEFAULT_SSH_TARGET = process.env.ALEXA_DEPLOY_SSH_TARGET || 'keinafarm';
|
||||||
|
const DEFAULT_REMOTE_UPLOAD_PATH =
|
||||||
|
process.env.ALEXA_DEPLOY_REMOTE_UPLOAD_PATH || '/tmp/alexa-api.env';
|
||||||
|
const DEFAULT_DEPLOY_COMMAND =
|
||||||
|
process.env.ALEXA_DEPLOY_COMMAND ||
|
||||||
|
'sudo /usr/local/bin/alexa-cookie-deploy.sh /tmp/alexa-api.env';
|
||||||
|
|
||||||
|
app.use(express.urlencoded({ extended: false }));
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value || '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPage(options) {
|
||||||
|
const opts = options || {};
|
||||||
|
const email = escapeHtml(opts.email || '');
|
||||||
|
const logs = Array.isArray(opts.logs) ? opts.logs : [];
|
||||||
|
const result = opts.result || null;
|
||||||
|
const error = opts.error || '';
|
||||||
|
const statusClass = result ? 'ok' : (error ? 'error' : '');
|
||||||
|
const deploy = opts.deploy || {};
|
||||||
|
const deployTarget = escapeHtml(deploy.target || DEFAULT_SSH_TARGET);
|
||||||
|
const deployUploadPath = escapeHtml(deploy.remoteUploadPath || DEFAULT_REMOTE_UPLOAD_PATH);
|
||||||
|
const deployCommand = escapeHtml(deploy.deployCommand || DEFAULT_DEPLOY_COMMAND);
|
||||||
|
const deployLogs = Array.isArray(deploy.logs) ? deploy.logs : [];
|
||||||
|
const deployError = deploy.error || '';
|
||||||
|
const deployResult = deploy.result || null;
|
||||||
|
const messageHtml = result
|
||||||
|
? `<div class="notice ok">Cookie を更新しました。<br>保存先: <code>${escapeHtml(result.envPath)}</code><br>Cookie 長さ: ${escapeHtml(result.cookieLength)}</div>`
|
||||||
|
: (error ? `<div class="notice error">${escapeHtml(error).replace(/\n/g, '<br>')}</div>` : '');
|
||||||
|
const logHtml = logs.length
|
||||||
|
? `<pre>${escapeHtml(logs.join('\n'))}</pre>`
|
||||||
|
: '<pre>ここにログが表示されます。</pre>';
|
||||||
|
const deployMessageHtml = deployResult
|
||||||
|
? `<div class="notice ok">サーバー反映が完了しました。<br>転送先: <code>${deployTarget}:${deployUploadPath}</code></div>`
|
||||||
|
: (deployError ? `<div class="notice error">${escapeHtml(deployError).replace(/\n/g, '<br>')}</div>` : '');
|
||||||
|
const deployLogHtml = deployLogs.length
|
||||||
|
? `<pre>${escapeHtml(deployLogs.join('\n'))}</pre>`
|
||||||
|
: '<pre>ここに転送と再起動のログが表示されます。</pre>';
|
||||||
|
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="ja">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Alexa Cookie 更新</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #f3ede2;
|
||||||
|
--card: #fffaf2;
|
||||||
|
--ink: #1f2937;
|
||||||
|
--muted: #6b7280;
|
||||||
|
--line: #d9cdb7;
|
||||||
|
--accent: #1d6b57;
|
||||||
|
--accent-strong: #114e3f;
|
||||||
|
--danger: #a63b2b;
|
||||||
|
--danger-bg: #fff1ee;
|
||||||
|
--ok-bg: #edf8f3;
|
||||||
|
--shadow: 0 20px 60px rgba(76, 56, 31, 0.12);
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Hiragino Sans", "Yu Gothic", sans-serif;
|
||||||
|
color: var(--ink);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(29, 107, 87, 0.14), transparent 28%),
|
||||||
|
radial-gradient(circle at right, rgba(177, 107, 32, 0.12), transparent 24%),
|
||||||
|
linear-gradient(180deg, #f8f3ea 0%, var(--bg) 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 32px 16px;
|
||||||
|
}
|
||||||
|
.wrap {
|
||||||
|
max-width: 760px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: color-mix(in srgb, var(--card) 92%, white 8%);
|
||||||
|
border: 1px solid rgba(217, 205, 183, 0.9);
|
||||||
|
border-radius: 24px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 28px;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: clamp(28px, 4vw, 42px);
|
||||||
|
line-height: 1.05;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
margin: 0 0 20px;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
form {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
.section {
|
||||||
|
margin-top: 28px;
|
||||||
|
padding-top: 24px;
|
||||||
|
border-top: 1px solid rgba(217, 205, 183, 0.9);
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 14px 15px;
|
||||||
|
font: inherit;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
input:focus {
|
||||||
|
outline: 2px solid rgba(29, 107, 87, 0.18);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 14px 22px;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
color: white;
|
||||||
|
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-strong) 100%);
|
||||||
|
cursor: pointer;
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
.notice {
|
||||||
|
margin: 20px 0 0;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.notice.ok {
|
||||||
|
background: var(--ok-bg);
|
||||||
|
border-color: rgba(29, 107, 87, 0.2);
|
||||||
|
}
|
||||||
|
.notice.error {
|
||||||
|
background: var(--danger-bg);
|
||||||
|
border-color: rgba(166, 59, 43, 0.2);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
.meta {
|
||||||
|
margin-top: 22px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
pre {
|
||||||
|
margin: 14px 0 0;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #1d2430;
|
||||||
|
color: #eef2f7;
|
||||||
|
overflow: auto;
|
||||||
|
line-height: 1.55;
|
||||||
|
min-height: 160px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
code {
|
||||||
|
font-family: "SFMono-Regular", Consolas, monospace;
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="card ${statusClass}">
|
||||||
|
<h1>Alexa Cookie 更新</h1>
|
||||||
|
<p>メールアドレスとパスワードをその場で入力して、<code>auth4.js</code> と同じ認証フローで <code>.env</code> を更新します。入力値は保存しません。</p>
|
||||||
|
<form method="post" action="/login">
|
||||||
|
<label>
|
||||||
|
Amazon メールアドレス
|
||||||
|
<input type="email" name="email" value="${email}" autocomplete="username" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Amazon パスワード
|
||||||
|
<input type="password" name="password" autocomplete="current-password" required>
|
||||||
|
</label>
|
||||||
|
<button type="submit">Cookie を更新する</button>
|
||||||
|
</form>
|
||||||
|
${messageHtml}
|
||||||
|
<div class="meta">ローカル専用の簡易 GUI です。CAPTCHA や MFA が出た場合は失敗ログを表示します。</div>
|
||||||
|
${logHtml}
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h1>サーバー反映</h1>
|
||||||
|
<p>更新済みの <code>.env</code> を一時パスへ転送し、<code>sudoers</code> で許可した専用スクリプトだけを実行します。SSH の接続先は必要に応じて変えてください。</p>
|
||||||
|
<form method="post" action="/deploy">
|
||||||
|
<label>
|
||||||
|
SSH 接続先
|
||||||
|
<input type="text" name="target" value="${deployTarget}" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
リモート一時アップロード先
|
||||||
|
<input type="text" name="remoteUploadPath" value="${deployUploadPath}" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
実行する専用コマンド
|
||||||
|
<input type="text" name="deployCommand" value="${deployCommand}" required>
|
||||||
|
</label>
|
||||||
|
<button type="submit">サーバーへ反映する</button>
|
||||||
|
</form>
|
||||||
|
${deployMessageHtml}
|
||||||
|
<div class="hint">このPCの SSH 設定では <code>keinafarm</code> が使えます。初回はサーバー側に <code>/usr/local/bin/alexa-cookie-deploy.sh</code> と <code>sudoers</code> 設定が必要です。</div>
|
||||||
|
${deployLogHtml}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runCommand(command, args) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
execFile(command, args, { maxBuffer: 1024 * 1024 }, function(error, stdout, stderr) {
|
||||||
|
if (error) {
|
||||||
|
error.stdout = stdout || '';
|
||||||
|
error.stderr = stderr || '';
|
||||||
|
reject(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve({
|
||||||
|
stdout: stdout || '',
|
||||||
|
stderr: stderr || '',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDeployState(body, extra) {
|
||||||
|
const payload = body || {};
|
||||||
|
const override = extra || {};
|
||||||
|
return {
|
||||||
|
target: override.target || payload.target || DEFAULT_SSH_TARGET,
|
||||||
|
remoteUploadPath: override.remoteUploadPath || payload.remoteUploadPath || DEFAULT_REMOTE_UPLOAD_PATH,
|
||||||
|
deployCommand: override.deployCommand || payload.deployCommand || DEFAULT_DEPLOY_COMMAND,
|
||||||
|
logs: override.logs || [],
|
||||||
|
error: override.error || '',
|
||||||
|
result: override.result || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/', function(req, res) {
|
||||||
|
res.type('html').send(renderPage({
|
||||||
|
deploy: buildDeployState({}),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/login', async function(req, res) {
|
||||||
|
const email = (req.body && req.body.email) || '';
|
||||||
|
const password = (req.body && req.body.password) || '';
|
||||||
|
const logs = [];
|
||||||
|
const logger = function(message) {
|
||||||
|
logs.push(message);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
res.status(400).type('html').send(renderPage({
|
||||||
|
email,
|
||||||
|
error: 'メールアドレスとパスワードは必須です。',
|
||||||
|
logs,
|
||||||
|
deploy: buildDeployState({}),
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await fetchAlexaCookieAndSave({ email, password, logger });
|
||||||
|
logs.push('');
|
||||||
|
logs.push('==============================================');
|
||||||
|
logs.push('認証成功');
|
||||||
|
logs.push(`保存先: ${result.envPath}`);
|
||||||
|
logs.push(`Cookie 長さ: ${result.cookieLength}`);
|
||||||
|
res.type('html').send(renderPage({
|
||||||
|
email,
|
||||||
|
result,
|
||||||
|
logs,
|
||||||
|
deploy: buildDeployState({}),
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
logs.push('');
|
||||||
|
logs.push('[ERROR] ' + error.message);
|
||||||
|
res.status(500).type('html').send(renderPage({
|
||||||
|
email,
|
||||||
|
error: error.message,
|
||||||
|
logs,
|
||||||
|
deploy: buildDeployState({}),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/deploy', async function(req, res) {
|
||||||
|
const deploy = buildDeployState(req.body);
|
||||||
|
const logs = [];
|
||||||
|
|
||||||
|
if (!deploy.target || !deploy.remoteUploadPath || !deploy.deployCommand) {
|
||||||
|
res.status(400).type('html').send(renderPage({
|
||||||
|
deploy: buildDeployState(req.body, {
|
||||||
|
logs,
|
||||||
|
error: 'SSH 接続先、一時アップロード先、専用コマンドは必須です。',
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
logs.push('[1] .env をサーバーの一時パスへ転送中...');
|
||||||
|
logs.push(`scp ${LOCAL_ENV_PATH} ${deploy.target}:${deploy.remoteUploadPath}`);
|
||||||
|
const scpResult = await runCommand('scp', [LOCAL_ENV_PATH, `${deploy.target}:${deploy.remoteUploadPath}`]);
|
||||||
|
if (scpResult.stdout.trim()) logs.push(scpResult.stdout.trim());
|
||||||
|
if (scpResult.stderr.trim()) logs.push(scpResult.stderr.trim());
|
||||||
|
|
||||||
|
logs.push('');
|
||||||
|
logs.push('[2] 専用デプロイスクリプトを実行中...');
|
||||||
|
logs.push(`ssh ${deploy.target} ${deploy.deployCommand}`);
|
||||||
|
const sshResult = await runCommand('ssh', [deploy.target, deploy.deployCommand]);
|
||||||
|
if (sshResult.stdout.trim()) logs.push(sshResult.stdout.trim());
|
||||||
|
if (sshResult.stderr.trim()) logs.push(sshResult.stderr.trim());
|
||||||
|
|
||||||
|
logs.push('');
|
||||||
|
logs.push('反映完了');
|
||||||
|
res.type('html').send(renderPage({
|
||||||
|
deploy: buildDeployState(req.body, {
|
||||||
|
logs,
|
||||||
|
result: { ok: true },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
if (error.stdout && error.stdout.trim()) logs.push(error.stdout.trim());
|
||||||
|
if (error.stderr && error.stderr.trim()) logs.push(error.stderr.trim());
|
||||||
|
logs.push('');
|
||||||
|
logs.push('[ERROR] ' + error.message);
|
||||||
|
res.status(500).type('html').send(renderPage({
|
||||||
|
deploy: buildDeployState(req.body, {
|
||||||
|
logs,
|
||||||
|
error: error.stderr || error.message,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(PORT, '127.0.0.1', function() {
|
||||||
|
console.log('[INFO] auth4-web listening on http://127.0.0.1:' + PORT);
|
||||||
|
});
|
||||||
31
alexa-api/auth4.js
Normal file
31
alexa-api/auth4.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* auth4.js - CLI 版の Alexa Cookie 更新
|
||||||
|
*/
|
||||||
|
|
||||||
|
const EMAIL = process.env.AMAZON_EMAIL;
|
||||||
|
const PASSWORD = process.env.AMAZON_PASSWORD;
|
||||||
|
const { fetchAlexaCookieAndSave } = require('./auth4-core');
|
||||||
|
|
||||||
|
if (!EMAIL || !PASSWORD) {
|
||||||
|
console.error('[ERROR] 環境変数 AMAZON_EMAIL と AMAZON_PASSWORD を設定してください');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const result = await fetchAlexaCookieAndSave({
|
||||||
|
email: EMAIL,
|
||||||
|
password: PASSWORD,
|
||||||
|
logger: console.log,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('\n==============================================');
|
||||||
|
console.log(' 認証成功!');
|
||||||
|
console.log('==============================================');
|
||||||
|
console.log(`.env を保存しました: ${result.envPath}`);
|
||||||
|
console.log(`Cookie 長さ: ${result.cookieLength} 文字`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('[FATAL]', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
19
alexa-api/docker-compose.yml
Normal file
19
alexa-api/docker-compose.yml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
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
|
||||||
1257
alexa-api/package-lock.json
generated
Normal file
1257
alexa-api/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
alexa-api/package.json
Normal file
18
alexa-api/package.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "alexa-api",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Alexa TTS API server for Windmill integration",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js",
|
||||||
|
"auth": "node auth4.js",
|
||||||
|
"auth:web": "node auth4-web.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.18.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"alexa-cookie2": "^5.0.3",
|
||||||
|
"alexa-remote2": "^5.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
223
alexa-api/server.js
Normal file
223
alexa-api/server.js
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
/**
|
||||||
|
* alexa-api/server.js
|
||||||
|
* Windmill から Echo デバイスに TTS を送る API サーバー
|
||||||
|
* 直接 Alexa API を叩く実装(alexa-remote2 不使用)
|
||||||
|
*
|
||||||
|
* Endpoints:
|
||||||
|
* POST /speak { device: "デバイス名 or serial", text: "しゃべる内容" }
|
||||||
|
* GET /devices デバイス一覧取得
|
||||||
|
* GET /health ヘルスチェック
|
||||||
|
*/
|
||||||
|
|
||||||
|
const https = require('https');
|
||||||
|
const express = require('express');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 3500;
|
||||||
|
const ALEXA_HOST = 'alexa.amazon.co.jp';
|
||||||
|
const ALEXA_COOKIE = process.env.ALEXA_COOKIE;
|
||||||
|
|
||||||
|
if (!ALEXA_COOKIE) {
|
||||||
|
console.error('[ERROR] ALEXA_COOKIE 環境変数が設定されていません。');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- キャッシュ ----
|
||||||
|
|
||||||
|
let cachedDevices = null;
|
||||||
|
let cachedCustomerId = null;
|
||||||
|
let deviceCacheExpires = 0;
|
||||||
|
|
||||||
|
// ---- HTTP ヘルパー ----
|
||||||
|
|
||||||
|
function httpsRequest(path, options, extraCookies) {
|
||||||
|
options = options || {};
|
||||||
|
extraCookies = extraCookies || '';
|
||||||
|
return new Promise(function(resolve, reject) {
|
||||||
|
var allCookies = ALEXA_COOKIE + (extraCookies ? '; ' + extraCookies : '');
|
||||||
|
// bodyBuf はバイト列変換(マルチバイト文字に対応)
|
||||||
|
var bodyBuf = options.body ? Buffer.from(options.body, 'utf8') : null;
|
||||||
|
var reqOpts = {
|
||||||
|
hostname: ALEXA_HOST,
|
||||||
|
path: path,
|
||||||
|
method: options.method || 'GET',
|
||||||
|
headers: Object.assign({
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||||
|
'Accept': 'application/json, text/plain, */*',
|
||||||
|
'Accept-Language': 'ja-JP,ja;q=0.9',
|
||||||
|
'Cookie': allCookies,
|
||||||
|
// Content-Length は送らない(test_tts.js で動作実績あり、Amazonが自動判定)
|
||||||
|
}, options.headers || {}),
|
||||||
|
};
|
||||||
|
var req = https.request(reqOpts, function(res) {
|
||||||
|
var body = '';
|
||||||
|
res.on('data', function(d) { body += d; });
|
||||||
|
res.on('end', function() { resolve({ status: res.statusCode, headers: res.headers, body: body }); });
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
if (bodyBuf) req.write(bodyBuf);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Alexa API ヘルパー ----
|
||||||
|
|
||||||
|
async function getCsrfToken() {
|
||||||
|
var res = await httpsRequest('/api/language');
|
||||||
|
var setCookies = res.headers['set-cookie'] || [];
|
||||||
|
var csrfCookieStr = setCookies.find(function(c) { return c.startsWith('csrf='); });
|
||||||
|
if (!csrfCookieStr) throw new Error('CSRF token not found');
|
||||||
|
return csrfCookieStr.split('=')[1].split(';')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCustomerId() {
|
||||||
|
if (cachedCustomerId) return cachedCustomerId;
|
||||||
|
var res = await httpsRequest('/api/bootstrap');
|
||||||
|
if (res.status !== 200) throw new Error('Bootstrap API failed: ' + res.status);
|
||||||
|
var data = JSON.parse(res.body);
|
||||||
|
cachedCustomerId = data.authentication && data.authentication.customerId;
|
||||||
|
if (!cachedCustomerId) throw new Error('customerId not found');
|
||||||
|
return cachedCustomerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDevices(force) {
|
||||||
|
var now = Date.now();
|
||||||
|
if (!force && cachedDevices && now < deviceCacheExpires) return cachedDevices;
|
||||||
|
var res = await httpsRequest('/api/devices-v2/device?cached=false');
|
||||||
|
if (res.status !== 200) throw new Error('Devices API failed: ' + res.status);
|
||||||
|
var data = JSON.parse(res.body);
|
||||||
|
cachedDevices = data.devices || [];
|
||||||
|
deviceCacheExpires = now + 5 * 60 * 1000;
|
||||||
|
return cachedDevices;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findDevice(devices, nameOrSerial) {
|
||||||
|
var bySerial = devices.find(function(d) { return d.serialNumber === nameOrSerial; });
|
||||||
|
if (bySerial) return bySerial;
|
||||||
|
var lower = nameOrSerial.toLowerCase();
|
||||||
|
var byName = devices.find(function(d) { return d.accountName && d.accountName.toLowerCase() === lower; });
|
||||||
|
if (byName) return byName;
|
||||||
|
return devices.find(function(d) { return d.accountName && d.accountName.toLowerCase().includes(lower); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- API エンドポイント ----
|
||||||
|
|
||||||
|
// POST /speak
|
||||||
|
app.post('/speak', async function(req, res) {
|
||||||
|
var body = req.body || {};
|
||||||
|
var device = body.device;
|
||||||
|
var text = body.text;
|
||||||
|
|
||||||
|
if (!device || !text) {
|
||||||
|
return res.status(400).json({ error: 'device と text は必須です' });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[SPEAK] device="' + device + '" text="' + text + '"');
|
||||||
|
|
||||||
|
try {
|
||||||
|
var results = await Promise.all([getCsrfToken(), getCustomerId(), getDevices()]);
|
||||||
|
var csrfToken = results[0];
|
||||||
|
var customerId = results[1];
|
||||||
|
var devices = results[2];
|
||||||
|
|
||||||
|
var target = findDevice(devices, device);
|
||||||
|
if (!target) {
|
||||||
|
var names = devices.map(function(d) { return d.accountName; }).join(', ');
|
||||||
|
return res.status(404).json({ error: 'デバイス "' + device + '" が見つかりません', available: names });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(' -> ' + target.accountName + ' (type=' + target.deviceType + ', serial=' + target.serialNumber + ')');
|
||||||
|
|
||||||
|
// ★ 重要: sequenceJson の non-ASCII(日本語等)を \uXXXX エスケープに変換してから送る
|
||||||
|
// raw UTF-8 のまま送ると Amazon 側でフィルタリングされ日本語が発話されない(解決済み 2026-03-03)
|
||||||
|
var sequenceObj = {
|
||||||
|
'@type': 'com.amazon.alexa.behaviors.model.Sequence',
|
||||||
|
startNode: {
|
||||||
|
'@type': 'com.amazon.alexa.behaviors.model.OpaquePayloadOperationNode',
|
||||||
|
type: 'Alexa.Speak',
|
||||||
|
operationPayload: {
|
||||||
|
deviceType: target.deviceType,
|
||||||
|
deviceSerialNumber: target.serialNumber,
|
||||||
|
customerId: customerId,
|
||||||
|
locale: 'ja-JP',
|
||||||
|
textToSpeak: text
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var rawSequenceJson = JSON.stringify(sequenceObj).replace(
|
||||||
|
/[\u0080-\uffff]/g,
|
||||||
|
function(c) { return '\\u' + ('0000' + c.charCodeAt(0).toString(16)).slice(-4); }
|
||||||
|
);
|
||||||
|
|
||||||
|
var bodyStr = JSON.stringify({
|
||||||
|
behaviorId: 'PREVIEW',
|
||||||
|
sequenceJson: rawSequenceJson,
|
||||||
|
status: 'ENABLED',
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[DEBUG] textToSpeak:', text);
|
||||||
|
|
||||||
|
var ttsRes = await httpsRequest('/api/behaviors/preview', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'csrf': csrfToken,
|
||||||
|
'Referer': 'https://alexa.amazon.co.jp/spa/index.html',
|
||||||
|
'Origin': 'https://alexa.amazon.co.jp',
|
||||||
|
},
|
||||||
|
body: bodyStr,
|
||||||
|
}, 'csrf=' + csrfToken);
|
||||||
|
|
||||||
|
// Amazonからのレスポンスをログ出力
|
||||||
|
console.log('[DEBUG] Alexa API response: ' + ttsRes.status + ' body=' + ttsRes.body.substring(0, 200));
|
||||||
|
|
||||||
|
if (ttsRes.status === 200 || ttsRes.status === 202) {
|
||||||
|
console.log(' [OK] TTS sent to ' + target.accountName);
|
||||||
|
res.json({ ok: true, device: target.accountName, text: text });
|
||||||
|
} else {
|
||||||
|
console.error(' [ERROR] TTS failed: ' + ttsRes.status + ' ' + ttsRes.body);
|
||||||
|
res.status(502).json({ error: 'Alexa API error: ' + ttsRes.status, body: ttsRes.body });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ERROR] /speak:', err.message);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /devices
|
||||||
|
app.get('/devices', async function(req, res) {
|
||||||
|
try {
|
||||||
|
var devices = await getDevices(true);
|
||||||
|
res.json(devices.map(function(d) {
|
||||||
|
return { name: d.accountName, type: d.deviceType, serial: d.serialNumber, online: d.online, family: d.deviceFamily };
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ERROR] /devices:', err.message);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /health
|
||||||
|
app.get('/health', function(req, res) {
|
||||||
|
res.json({ ok: true, cookieLength: ALEXA_COOKIE.length });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- 起動 ----
|
||||||
|
|
||||||
|
app.listen(PORT, '0.0.0.0', async function() {
|
||||||
|
console.log('[INFO] alexa-api server listening on port ' + PORT);
|
||||||
|
try {
|
||||||
|
var customerId = await getCustomerId();
|
||||||
|
console.log('[INFO] Customer ID: ' + customerId);
|
||||||
|
var devices = await getDevices();
|
||||||
|
var echoDevices = devices.filter(function(d) {
|
||||||
|
return (d.deviceType && (d.deviceType.startsWith('A4ZXE') || d.deviceType.startsWith('ASQZWP')));
|
||||||
|
});
|
||||||
|
console.log('[INFO] Echo devices: ' + echoDevices.map(function(d) { return d.accountName; }).join(', '));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[WARN] Startup init failed:', err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
130
alexa-api/test_tts.js
Normal file
130
alexa-api/test_tts.js
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
/**
|
||||||
|
* test_tts.js - TTS API テスト
|
||||||
|
* node test_tts.js
|
||||||
|
*/
|
||||||
|
const https = require('https');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const envContent = fs.readFileSync(path.join(__dirname, '.env'), 'utf8');
|
||||||
|
const COOKIE_STR = envContent.match(/ALEXA_COOKIE=(.+)/)[1].trim();
|
||||||
|
|
||||||
|
function makeRequest(url, options = {}, extraCookies = '') {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
const allCookies = COOKIE_STR + (extraCookies ? '; ' + extraCookies : '');
|
||||||
|
const reqOpts = {
|
||||||
|
hostname: parsed.hostname,
|
||||||
|
path: parsed.pathname + parsed.search,
|
||||||
|
method: options.method || 'GET',
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||||
|
'Accept': 'application/json, text/plain, */*',
|
||||||
|
'Accept-Language': 'ja-JP,ja;q=0.9',
|
||||||
|
'Cookie': allCookies,
|
||||||
|
...(options.headers || {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const req = https.request(reqOpts, (res) => {
|
||||||
|
let body = '';
|
||||||
|
res.on('data', d => body += d);
|
||||||
|
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body }));
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
if (options.body) req.write(options.body);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
// 1. CSRFトークン取得
|
||||||
|
console.log('[1] CSRF token取得...');
|
||||||
|
const langRes = await makeRequest('https://alexa.amazon.co.jp/api/language');
|
||||||
|
const setCookies = langRes.headers['set-cookie'] || [];
|
||||||
|
const csrfCookieStr = setCookies.find(c => c.startsWith('csrf='));
|
||||||
|
const csrfToken = csrfCookieStr ? csrfCookieStr.split('=')[1].split(';')[0] : null;
|
||||||
|
console.log(' CSRF token:', csrfToken);
|
||||||
|
console.log(' Status:', langRes.status);
|
||||||
|
|
||||||
|
if (!csrfToken) { console.error('CSRF token not found!'); process.exit(1); }
|
||||||
|
|
||||||
|
// 2. デバイス一覧取得
|
||||||
|
console.log('[2] デバイス一覧取得...');
|
||||||
|
const devRes = await makeRequest('https://alexa.amazon.co.jp/api/devices-v2/device?cached=false');
|
||||||
|
console.log(' Status:', devRes.status);
|
||||||
|
const devices = JSON.parse(devRes.body).devices || [];
|
||||||
|
console.log(' Device count:', devices.length);
|
||||||
|
|
||||||
|
// デバイス一覧表示
|
||||||
|
devices.filter(d => d.deviceFamily !== 'TABLET').forEach(d => {
|
||||||
|
console.log(` - ${d.accountName} (type=${d.deviceType}, serial=${d.serialNumber})`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// プレハブを探す
|
||||||
|
const target = devices.find(d => d.serialNumber === 'G0922H08525302K5'); // オフィスの右エコー(以前成功したデバイス)
|
||||||
|
console.log('\nTarget device:', target ? `${target.accountName}` : 'NOT FOUND');
|
||||||
|
|
||||||
|
if (!target) { process.exit(1); }
|
||||||
|
|
||||||
|
// 2.5. カスタマーIDを取得
|
||||||
|
const bootstrapRes = await makeRequest('https://alexa.amazon.co.jp/api/bootstrap');
|
||||||
|
const bootstrap = JSON.parse(bootstrapRes.body);
|
||||||
|
const customerId = bootstrap.authentication?.customerId;
|
||||||
|
console.log(' Customer ID:', customerId);
|
||||||
|
|
||||||
|
// 3. TTSリクエスト(新Cookie + Alexa.Speak + locale:'ja-JP' + 日本語テキスト)
|
||||||
|
const sequenceObj = {
|
||||||
|
'@type': 'com.amazon.alexa.behaviors.model.Sequence',
|
||||||
|
startNode: {
|
||||||
|
'@type': 'com.amazon.alexa.behaviors.model.OpaquePayloadOperationNode',
|
||||||
|
type: 'Alexa.Speak',
|
||||||
|
operationPayload: {
|
||||||
|
deviceType: target.deviceType,
|
||||||
|
deviceSerialNumber: target.serialNumber,
|
||||||
|
customerId: customerId,
|
||||||
|
locale: 'ja-JP',
|
||||||
|
textToSpeak: '\u3053\u308c\u306f\u65e5\u672c\u8a9e\u306e\u30c6\u30b9\u30c8\u3067\u3059', // 「これは日本語のテストです」
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// non-ASCII を \uXXXX に強制エスケープ
|
||||||
|
// Amazon のパーサーが sequenceJson 内の raw UTF-8 を処理できない場合の回避策
|
||||||
|
const rawSequenceJson = JSON.stringify(sequenceObj).replace(
|
||||||
|
/[\u0080-\uffff]/g,
|
||||||
|
c => '\\u' + ('0000' + c.charCodeAt(0).toString(16)).slice(-4)
|
||||||
|
);
|
||||||
|
const bodyObj = {
|
||||||
|
behaviorId: 'PREVIEW',
|
||||||
|
sequenceJson: rawSequenceJson,
|
||||||
|
status: 'ENABLED',
|
||||||
|
};
|
||||||
|
const body = JSON.stringify(bodyObj);
|
||||||
|
|
||||||
|
console.log('\n[3] TTS送信...');
|
||||||
|
// 送信内容確認(textToSpeakの部分が\uXXXXエスケープになっているか)
|
||||||
|
const ttsIdx = body.indexOf('textToSpeak');
|
||||||
|
console.log(' textToSpeak部分:', body.substring(ttsIdx, ttsIdx + 80));
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const ttsRes = await makeRequest('https://alexa.amazon.co.jp/api/behaviors/preview', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'csrf': csrfToken,
|
||||||
|
'Referer': 'https://alexa.amazon.co.jp/spa/index.html',
|
||||||
|
'Origin': 'https://alexa.amazon.co.jp',
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
}, `csrf=${csrfToken}`);
|
||||||
|
|
||||||
|
console.log('TTS status:', ttsRes.status);
|
||||||
|
console.log('TTS response:', ttsRes.body.substring(0, 500));
|
||||||
|
|
||||||
|
if (ttsRes.status === 200 || ttsRes.status === 202) {
|
||||||
|
console.log('\n成功!エコーがしゃべるはずです。');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error);
|
||||||
15
autonomous_windmill/.env.example
Normal file
15
autonomous_windmill/.env.example
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Windmill 接続設定
|
||||||
|
WINDMILL_URL=https://windmill.keinafarm.net
|
||||||
|
WINDMILL_TOKEN=your_token_here
|
||||||
|
WINDMILL_WORKSPACE=admins
|
||||||
|
|
||||||
|
# Ollama 設定
|
||||||
|
OLLAMA_URL=http://localhost:11434
|
||||||
|
OLLAMA_MODEL=qwen2.5-coder:14b
|
||||||
|
|
||||||
|
# 動作設定
|
||||||
|
DEV_PATH_PREFIX=f/dev
|
||||||
|
MAX_RETRIES=3
|
||||||
|
MAX_JSON_RETRIES=2
|
||||||
|
POLL_INTERVAL=5
|
||||||
|
POLL_MAX_COUNT=30
|
||||||
22
autonomous_windmill/config.py
Normal file
22
autonomous_windmill/config.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
"""設定値の一元管理。環境変数 or .env ファイルから読み込む。"""
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv(Path(__file__).parent / ".env")
|
||||||
|
|
||||||
|
WINDMILL_URL = os.environ.get("WINDMILL_URL", "https://windmill.keinafarm.net")
|
||||||
|
WINDMILL_TOKEN = os.environ.get("WINDMILL_TOKEN", "")
|
||||||
|
WINDMILL_WORKSPACE = os.environ.get("WINDMILL_WORKSPACE", "admins")
|
||||||
|
|
||||||
|
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434")
|
||||||
|
OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "qwen2.5-coder:14b")
|
||||||
|
|
||||||
|
DEV_PATH_PREFIX = os.environ.get("DEV_PATH_PREFIX", "f/dev")
|
||||||
|
MAX_RETRIES = int(os.environ.get("MAX_RETRIES", "3"))
|
||||||
|
MAX_JSON_RETRIES = int(os.environ.get("MAX_JSON_RETRIES", "2"))
|
||||||
|
POLL_INTERVAL = int(os.environ.get("POLL_INTERVAL", "5"))
|
||||||
|
POLL_MAX_COUNT = int(os.environ.get("POLL_MAX_COUNT", "30"))
|
||||||
|
|
||||||
|
if not WINDMILL_TOKEN:
|
||||||
|
raise EnvironmentError("WINDMILL_TOKEN が設定されていません。.env ファイルを確認してください。")
|
||||||
148
autonomous_windmill/controller.py
Normal file
148
autonomous_windmill/controller.py
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
"""
|
||||||
|
自律ループ制御。全体のオーケストレーションのみを行う。
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python controller.py <flow_name> <task_description>
|
||||||
|
|
||||||
|
Example:
|
||||||
|
python controller.py hello_world "print Hello World in Python"
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from config import DEV_PATH_PREFIX, MAX_RETRIES, MAX_JSON_RETRIES
|
||||||
|
from state_manager import State, is_duplicate, register_hash
|
||||||
|
from validator import validate
|
||||||
|
from windmill_client import create_flow, update_flow, flow_exists, run_flow
|
||||||
|
from job_poller import poll_until_done, JobTimeout
|
||||||
|
from llm_interface import generate_flow, fix_flow
|
||||||
|
|
||||||
|
|
||||||
|
def _log(prefix: str, msg: str) -> None:
|
||||||
|
print(f"{prefix} {msg}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def run(task_description: str, flow_name: str) -> bool:
|
||||||
|
"""
|
||||||
|
自律ループを実行する。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True = 成功, False = 失敗
|
||||||
|
"""
|
||||||
|
# パス制限: f/dev/* のみ(controller 側で強制)
|
||||||
|
flow_path = f"{DEV_PATH_PREFIX}/{flow_name}"
|
||||||
|
state = State(retry_count=MAX_RETRIES)
|
||||||
|
is_first = True
|
||||||
|
json_fail_count = 0
|
||||||
|
|
||||||
|
_log("[START]", f"タスク: {task_description}")
|
||||||
|
_log("[START]", f"フローパス: {flow_path}")
|
||||||
|
|
||||||
|
while state.retry_count > 0:
|
||||||
|
attempt = MAX_RETRIES - state.retry_count + 1
|
||||||
|
prefix = f"[試行 {attempt}/{MAX_RETRIES}]"
|
||||||
|
|
||||||
|
# ── 1. LLM 生成 ─────────────────────────────────────────
|
||||||
|
_log(prefix, "フロー生成中...")
|
||||||
|
if is_first:
|
||||||
|
raw = generate_flow(task_description)
|
||||||
|
else:
|
||||||
|
prev_json = json.dumps(state.current_flow, ensure_ascii=False)
|
||||||
|
raw = fix_flow(prev_json, state.last_error or "")
|
||||||
|
|
||||||
|
# ── 2 & 3. JSON 検証 ─────────────────────────────────────
|
||||||
|
try:
|
||||||
|
flow_dict = validate(raw)
|
||||||
|
json_fail_count = 0
|
||||||
|
_log(prefix, "JSON検証: OK")
|
||||||
|
except ValueError as e:
|
||||||
|
_log(prefix, f"JSON検証: NG - {e}")
|
||||||
|
json_fail_count += 1
|
||||||
|
if json_fail_count >= MAX_JSON_RETRIES:
|
||||||
|
_log(prefix, f"JSON検証 {MAX_JSON_RETRIES} 回連続失敗 → リトライ消費")
|
||||||
|
state.retry_count -= 1
|
||||||
|
state.last_error = str(e)
|
||||||
|
json_fail_count = 0
|
||||||
|
is_first = False
|
||||||
|
continue
|
||||||
|
|
||||||
|
# ── 5. ハッシュ比較 ──────────────────────────────────────
|
||||||
|
if is_duplicate(state, flow_dict):
|
||||||
|
_log("[STOP]", "同一JSON検出 → 即停止")
|
||||||
|
return False
|
||||||
|
|
||||||
|
register_hash(state, flow_dict)
|
||||||
|
|
||||||
|
# ── 6. create / update ───────────────────────────────────
|
||||||
|
summary = flow_dict["summary"]
|
||||||
|
value = flow_dict["value"]
|
||||||
|
try:
|
||||||
|
if flow_exists(flow_path):
|
||||||
|
update_flow(flow_path, summary, value)
|
||||||
|
_log(prefix, f"フロー更新: {flow_path}")
|
||||||
|
else:
|
||||||
|
create_flow(flow_path, summary, value)
|
||||||
|
_log(prefix, f"フロー作成: {flow_path}")
|
||||||
|
except Exception as e:
|
||||||
|
_log(prefix, f"フロー送信エラー: {e}")
|
||||||
|
state.retry_count -= 1
|
||||||
|
state.last_error = str(e)
|
||||||
|
is_first = False
|
||||||
|
continue
|
||||||
|
|
||||||
|
# API 送信成功直後に current_flow を更新(run 前・失敗時は更新しない)
|
||||||
|
state.current_flow = flow_dict
|
||||||
|
|
||||||
|
# ── 7. run ──────────────────────────────────────────────
|
||||||
|
try:
|
||||||
|
job_id = run_flow(flow_path)
|
||||||
|
state.job_id = job_id
|
||||||
|
_log(prefix, f"ジョブ実行: {job_id}")
|
||||||
|
except Exception as e:
|
||||||
|
_log(prefix, f"ジョブ起動エラー: {e}")
|
||||||
|
state.retry_count -= 1
|
||||||
|
state.last_error = str(e)
|
||||||
|
is_first = False
|
||||||
|
continue
|
||||||
|
|
||||||
|
# ── 8. ジョブ完了待ち ────────────────────────────────────
|
||||||
|
_log(prefix, "ジョブ完了待ち...")
|
||||||
|
try:
|
||||||
|
success, logs = poll_until_done(job_id)
|
||||||
|
except JobTimeout as e:
|
||||||
|
_log(prefix, f"タイムアウト: {e}")
|
||||||
|
state.retry_count -= 1
|
||||||
|
state.last_error = "タイムアウト"
|
||||||
|
state.last_logs = None
|
||||||
|
is_first = False
|
||||||
|
continue
|
||||||
|
|
||||||
|
state.last_logs = logs
|
||||||
|
|
||||||
|
# ── 9. ステータス判定 ────────────────────────────────────
|
||||||
|
if success:
|
||||||
|
_log(prefix, "実行結果: SUCCESS")
|
||||||
|
_log("[最終]", "状態: 成功")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
excerpt = logs[:300] if logs else "(ログなし)"
|
||||||
|
_log(prefix, "実行結果: FAILURE")
|
||||||
|
_log(prefix, f"エラー内容: {excerpt}")
|
||||||
|
state.retry_count -= 1
|
||||||
|
state.last_error = logs or "不明なエラー"
|
||||||
|
is_first = False
|
||||||
|
|
||||||
|
_log("[最終]", f"状態: {MAX_RETRIES} 回失敗で停止")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
print("Usage: python controller.py <flow_name> <task_description>")
|
||||||
|
print("Example: python controller.py hello_world 'print Hello World in Python'")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
_flow_name = sys.argv[1]
|
||||||
|
_task = " ".join(sys.argv[2:])
|
||||||
|
|
||||||
|
ok = run(_task, _flow_name)
|
||||||
|
sys.exit(0 if ok else 1)
|
||||||
48
autonomous_windmill/job_poller.py
Normal file
48
autonomous_windmill/job_poller.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"""ジョブ完了待ちポーリング。Windmill の success フィールドで判定する。"""
|
||||||
|
import time
|
||||||
|
from windmill_client import get_job, get_job_logs
|
||||||
|
from config import POLL_INTERVAL, POLL_MAX_COUNT
|
||||||
|
|
||||||
|
|
||||||
|
class JobTimeout(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def poll_until_done(job_id: str) -> tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
ジョブが完了するまでポーリングする。
|
||||||
|
|
||||||
|
判定優先順位:
|
||||||
|
1. success is False → 失敗(即返却)
|
||||||
|
2. success is True → 成功(即返却)
|
||||||
|
3. それ以外 → 継続待機
|
||||||
|
|
||||||
|
ログ文字列は主判定に使わない(誤検知防止)。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(success: bool, logs: str)
|
||||||
|
Raises:
|
||||||
|
JobTimeout: POLL_MAX_COUNT * POLL_INTERVAL 秒以内に完了しなかった場合
|
||||||
|
"""
|
||||||
|
for _ in range(POLL_MAX_COUNT):
|
||||||
|
job = get_job(job_id)
|
||||||
|
success = job.get("success")
|
||||||
|
|
||||||
|
if success is False:
|
||||||
|
logs = get_job_logs(job_id)
|
||||||
|
# result.error があればログに付加(ログが空でもエラー詳細を取得できる)
|
||||||
|
result_error = job.get("result", {}) or {}
|
||||||
|
error_detail = result_error.get("error", {}) or {}
|
||||||
|
error_msg = error_detail.get("message", "")
|
||||||
|
if error_msg and error_msg not in (logs or ""):
|
||||||
|
logs = f"{logs}\n[result.error] {error_msg}".strip()
|
||||||
|
return False, logs
|
||||||
|
|
||||||
|
if success is True:
|
||||||
|
logs = get_job_logs(job_id)
|
||||||
|
return True, logs
|
||||||
|
|
||||||
|
time.sleep(POLL_INTERVAL)
|
||||||
|
|
||||||
|
timeout_sec = POLL_MAX_COUNT * POLL_INTERVAL
|
||||||
|
raise JobTimeout(f"ジョブ {job_id} が {timeout_sec} 秒以内に完了しませんでした")
|
||||||
99
autonomous_windmill/llm_interface.py
Normal file
99
autonomous_windmill/llm_interface.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
"""Ollama へのプロンプト送信と JSON 抽出。"""
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import httpx
|
||||||
|
from config import OLLAMA_URL, OLLAMA_MODEL
|
||||||
|
|
||||||
|
_SYSTEM_PROMPT = """\
|
||||||
|
あなたはWindmillフロー生成AIです。
|
||||||
|
以下のルールを必ず守ってください:
|
||||||
|
- JSONのみ出力すること
|
||||||
|
- Markdownのコードブロック(```)は使わない
|
||||||
|
- 説明文・コメントは一切出力しない
|
||||||
|
- フィールド順は必ず summary → value の順にすること
|
||||||
|
- 出力するJSONは必ず以下のスキーマに従うこと:
|
||||||
|
|
||||||
|
{
|
||||||
|
"summary": "<タスクを一言で表す英語の説明>",
|
||||||
|
"value": {
|
||||||
|
"modules": [
|
||||||
|
{
|
||||||
|
"id": "a",
|
||||||
|
"value": {
|
||||||
|
"type": "rawscript",
|
||||||
|
"language": "python3",
|
||||||
|
"content": "<タスクを実行するPython3コード>",
|
||||||
|
"input_transforms": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
【必須ルール】
|
||||||
|
- content のコードは必ず def main(): で始めること(Windmillのエントリーポイント)
|
||||||
|
- main() がない場合は AttributeError になるため絶対に省略しないこと
|
||||||
|
- content の内容はユーザーのタスク説明に従って書くこと(テンプレートをそのままコピーしないこと)
|
||||||
|
- content 内の改行は \\n でエスケープすること(リテラル改行を入れると JSON パースエラーになる)
|
||||||
|
- modules.id は a, b, c... の連番。追加フィールド禁止。
|
||||||
|
|
||||||
|
【出力例1】タスク: 「おはよう」と表示する
|
||||||
|
{"summary":"Print greeting","value":{"modules":[{"id":"a","value":{"type":"rawscript","language":"python3","content":"def main():\\n print('おはよう')","input_transforms":{}}}]}}
|
||||||
|
|
||||||
|
【出力例2】タスク: 1から5までの数字を表示する
|
||||||
|
{"summary":"Print numbers 1 to 5","value":{"modules":[{"id":"a","value":{"type":"rawscript","language":"python3","content":"def main():\\n for i in range(1, 6):\\n print(i)","input_transforms":{}}}]}}
|
||||||
|
|
||||||
|
【出力例3】タスク: 現在の日時を表示する
|
||||||
|
{"summary":"Display current datetime","value":{"modules":[{"id":"a","value":{"type":"rawscript","language":"python3","content":"def main():\\n from datetime import datetime\\n print(datetime.now().strftime('%Y-%m-%d %H:%M:%S'))","input_transforms":{}}}]}}\
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _chat(messages: list[dict]) -> str:
|
||||||
|
resp = httpx.post(
|
||||||
|
f"{OLLAMA_URL}/api/chat",
|
||||||
|
json={
|
||||||
|
"model": OLLAMA_MODEL,
|
||||||
|
"messages": messages,
|
||||||
|
"stream": False,
|
||||||
|
"options": {"temperature": 0.1, "top_p": 0.9},
|
||||||
|
},
|
||||||
|
timeout=120,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
raw = resp.json()["message"]["content"].strip()
|
||||||
|
return _extract_json(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_json(raw: str) -> str:
|
||||||
|
"""LLM がコードブロックで囲んでしまった場合でも JSON 部分を取り出す。"""
|
||||||
|
# ```json ... ``` または ``` ... ``` を除去
|
||||||
|
match = re.search(r"```(?:json)?\s*([\s\S]+?)\s*```", raw)
|
||||||
|
if match:
|
||||||
|
return match.group(1).strip()
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
def generate_flow(task_description: str) -> str:
|
||||||
|
"""初回生成:タスク説明からフロー JSON を生成する。"""
|
||||||
|
messages = [
|
||||||
|
{"role": "system", "content": _SYSTEM_PROMPT},
|
||||||
|
{"role": "user", "content": f"以下のフローをJSON形式で生成してください。\n要件: {task_description}"},
|
||||||
|
]
|
||||||
|
return _chat(messages)
|
||||||
|
|
||||||
|
|
||||||
|
def fix_flow(previous_flow_json: str, error_log: str) -> str:
|
||||||
|
"""リトライ生成:前回の JSON + エラーログから修正版を生成する。"""
|
||||||
|
messages = [
|
||||||
|
{"role": "system", "content": _SYSTEM_PROMPT},
|
||||||
|
{"role": "user", "content": (
|
||||||
|
"前回のフロー実行でエラーが発生しました。修正したフローをJSON形式で出力してください。\n\n"
|
||||||
|
f"--- 前回のフローJSON ---\n{previous_flow_json}\n\n"
|
||||||
|
f"--- エラーログ ---\n{error_log}\n\n"
|
||||||
|
"--- 修正指示 ---\n"
|
||||||
|
"- 前回と同一のJSONは絶対に出力しないこと\n"
|
||||||
|
"- エラーの原因箇所のみ修正すること\n"
|
||||||
|
"- スキーマは変えないこと"
|
||||||
|
)},
|
||||||
|
]
|
||||||
|
return _chat(messages)
|
||||||
3
autonomous_windmill/requirements.txt
Normal file
3
autonomous_windmill/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
httpx
|
||||||
|
jsonschema
|
||||||
|
python-dotenv
|
||||||
81
autonomous_windmill/run_gui.py
Normal file
81
autonomous_windmill/run_gui.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
"""
|
||||||
|
GUIダイアログでフロー名とタスク説明を入力してから controller.py を起動する。
|
||||||
|
VS Code タスクから呼び出す用。
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tkinter as tk
|
||||||
|
from tkinter import messagebox
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
root = tk.Tk()
|
||||||
|
root.title("Windmill フロー生成")
|
||||||
|
root.resizable(True, True)
|
||||||
|
|
||||||
|
pad = {"padx": 12, "pady": 4}
|
||||||
|
|
||||||
|
# ── フロー名 ──────────────────────────────────
|
||||||
|
tk.Label(root, text="フロー名", anchor="w").grid(
|
||||||
|
row=0, column=0, sticky="ew", **pad
|
||||||
|
)
|
||||||
|
flow_var = tk.StringVar()
|
||||||
|
flow_entry = tk.Entry(root, textvariable=flow_var, width=46)
|
||||||
|
flow_entry.grid(row=1, column=0, sticky="ew", padx=12, pady=(0, 8))
|
||||||
|
|
||||||
|
# ── タスク説明 ────────────────────────────────
|
||||||
|
tk.Label(root, text="タスク説明", anchor="w").grid(
|
||||||
|
row=2, column=0, sticky="ew", **pad
|
||||||
|
)
|
||||||
|
task_text = tk.Text(root, width=46, height=8, wrap=tk.WORD)
|
||||||
|
task_text.grid(row=3, column=0, sticky="nsew", padx=12, pady=(0, 8))
|
||||||
|
|
||||||
|
# ── ボタン ────────────────────────────────────
|
||||||
|
btn_frame = tk.Frame(root)
|
||||||
|
btn_frame.grid(row=4, column=0, sticky="e", padx=12, pady=(4, 12))
|
||||||
|
|
||||||
|
def on_cancel():
|
||||||
|
root.destroy()
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
def on_run():
|
||||||
|
flow_name = flow_var.get().strip()
|
||||||
|
task_desc = task_text.get("1.0", tk.END).strip()
|
||||||
|
|
||||||
|
if not flow_name:
|
||||||
|
messagebox.showwarning("入力エラー", "フロー名を入力してください。", parent=root)
|
||||||
|
flow_entry.focus()
|
||||||
|
return
|
||||||
|
if not task_desc:
|
||||||
|
messagebox.showwarning("入力エラー", "タスク説明を入力してください。", parent=root)
|
||||||
|
task_text.focus()
|
||||||
|
return
|
||||||
|
|
||||||
|
root.destroy()
|
||||||
|
|
||||||
|
# controller.py をカレントプロセスと置き換えて実行
|
||||||
|
# → ターミナルにそのままログが流れる
|
||||||
|
script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "controller.py")
|
||||||
|
os.execv(sys.executable, [sys.executable, script, flow_name, task_desc])
|
||||||
|
|
||||||
|
tk.Button(btn_frame, text="キャンセル", width=10, command=on_cancel).pack(
|
||||||
|
side=tk.LEFT, padx=(0, 6)
|
||||||
|
)
|
||||||
|
tk.Button(btn_frame, text="実行", width=10, command=on_run, default=tk.ACTIVE).pack(
|
||||||
|
side=tk.LEFT
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── レイアウト調整 ────────────────────────────
|
||||||
|
root.columnconfigure(0, weight=1)
|
||||||
|
root.rowconfigure(3, weight=1)
|
||||||
|
|
||||||
|
# Enter キーで実行、Escape でキャンセル
|
||||||
|
root.bind("<Return>", lambda _: on_run())
|
||||||
|
root.bind("<Escape>", lambda _: on_cancel())
|
||||||
|
|
||||||
|
flow_entry.focus()
|
||||||
|
root.mainloop()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
33
autonomous_windmill/state_manager.py
Normal file
33
autonomous_windmill/state_manager.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"""State オブジェクトの管理とハッシュ比較。"""
|
||||||
|
import json
|
||||||
|
import hashlib
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Optional, Set
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class State:
|
||||||
|
retry_count: int = 3
|
||||||
|
current_flow: Optional[dict] = None
|
||||||
|
last_logs: Optional[str] = None
|
||||||
|
last_error: Optional[str] = None
|
||||||
|
flow_hashes: Set[str] = field(default_factory=set)
|
||||||
|
job_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical(flow_dict: dict) -> str:
|
||||||
|
"""キー順を固定した JSON 文字列を返す(ハッシュ安定化)。"""
|
||||||
|
return json.dumps(flow_dict, sort_keys=True, separators=(',', ':'))
|
||||||
|
|
||||||
|
|
||||||
|
def compute_hash(flow_dict: dict) -> str:
|
||||||
|
return hashlib.sha256(_canonical(flow_dict).encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def is_duplicate(state: State, flow_dict: dict) -> bool:
|
||||||
|
"""過去に同一の JSON を出力済みかどうかを判定する。"""
|
||||||
|
return compute_hash(flow_dict) in state.flow_hashes
|
||||||
|
|
||||||
|
|
||||||
|
def register_hash(state: State, flow_dict: dict) -> None:
|
||||||
|
state.flow_hashes.add(compute_hash(flow_dict))
|
||||||
57
autonomous_windmill/validator.py
Normal file
57
autonomous_windmill/validator.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"""LLM 出力の JSON 構文・スキーマ検証。"""
|
||||||
|
import json
|
||||||
|
import jsonschema
|
||||||
|
|
||||||
|
# LLM が出力すべき JSON の最小スキーマ
|
||||||
|
_FLOW_SCHEMA = {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["summary", "value"],
|
||||||
|
"additionalProperties": False,
|
||||||
|
"properties": {
|
||||||
|
"summary": {"type": "string", "minLength": 1},
|
||||||
|
"value": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["modules"],
|
||||||
|
"properties": {
|
||||||
|
"modules": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["id", "value"],
|
||||||
|
"properties": {
|
||||||
|
"id": {"type": "string"},
|
||||||
|
"value": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["type", "language", "content"],
|
||||||
|
"properties": {
|
||||||
|
"type": {"type": "string"},
|
||||||
|
"language": {"type": "string"},
|
||||||
|
"content": {"type": "string"},
|
||||||
|
"input_transforms": {"type": "object"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def validate(raw: str) -> dict:
|
||||||
|
"""JSON 文字列を構文・スキーマ検証して dict を返す。失敗時は ValueError を投げる。"""
|
||||||
|
# 構文チェック
|
||||||
|
try:
|
||||||
|
data = json.loads(raw)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
raise ValueError(f"JSON構文エラー: {e}")
|
||||||
|
|
||||||
|
# スキーマチェック
|
||||||
|
try:
|
||||||
|
jsonschema.validate(data, _FLOW_SCHEMA)
|
||||||
|
except jsonschema.ValidationError as e:
|
||||||
|
raise ValueError(f"JSONスキーマ不正: {e.message}")
|
||||||
|
|
||||||
|
return data
|
||||||
50
autonomous_windmill/windmill_client.py
Normal file
50
autonomous_windmill/windmill_client.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
"""Windmill REST API の薄いラッパー。MCP は使わず直接 HTTPS で叩く。"""
|
||||||
|
import httpx
|
||||||
|
from config import WINDMILL_URL, WINDMILL_TOKEN, WINDMILL_WORKSPACE
|
||||||
|
|
||||||
|
|
||||||
|
def _headers() -> dict:
|
||||||
|
return {"Authorization": f"Bearer {WINDMILL_TOKEN}"}
|
||||||
|
|
||||||
|
|
||||||
|
def _api(path: str) -> str:
|
||||||
|
return f"{WINDMILL_URL}/api/w/{WINDMILL_WORKSPACE}/{path}"
|
||||||
|
|
||||||
|
|
||||||
|
def flow_exists(path: str) -> bool:
|
||||||
|
resp = httpx.get(_api(f"flows/get/{path}"), headers=_headers(), timeout=30)
|
||||||
|
return resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def create_flow(path: str, summary: str, value: dict) -> None:
|
||||||
|
payload = {"path": path, "summary": summary, "description": "", "value": value}
|
||||||
|
resp = httpx.post(_api("flows/create"), headers=_headers(), json=payload, timeout=30)
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
|
||||||
|
def update_flow(path: str, summary: str, value: dict) -> None:
|
||||||
|
# 正しいエンドポイントは /flows/update/{path}(/flows/edit/ は404になる)
|
||||||
|
payload = {"path": path, "summary": summary, "description": "", "value": value}
|
||||||
|
resp = httpx.post(_api(f"flows/update/{path}"), headers=_headers(), json=payload, timeout=30)
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
|
||||||
|
def run_flow(path: str) -> str:
|
||||||
|
"""フローを実行して job_id を返す。"""
|
||||||
|
resp = httpx.post(_api(f"jobs/run/f/{path}"), headers=_headers(), json={}, timeout=30)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.text.strip().strip('"')
|
||||||
|
|
||||||
|
|
||||||
|
def get_job(job_id: str) -> dict:
|
||||||
|
"""ジョブの状態を取得する。success フィールド: True=成功, False=失敗, None=実行中。"""
|
||||||
|
resp = httpx.get(_api(f"jobs_u/get/{job_id}"), headers=_headers(), timeout=30)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def get_job_logs(job_id: str) -> str:
|
||||||
|
resp = httpx.get(_api(f"jobs_u/getlogs/{job_id}"), headers=_headers(), timeout=30)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
return resp.text
|
||||||
|
return ""
|
||||||
1
butler.pid
Normal file
1
butler.pid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
34292
|
||||||
@@ -1,458 +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分毎にポーリング
|
統合システム上で予約・実績の変更が発生
|
||||||
└→ 変更があればLINEにプッシュ通知
|
└→ Windmill が5分毎にポーリング
|
||||||
└→ 管理者がLINEで変更内容を確認
|
└→ 変更があればLINEにプッシュ通知
|
||||||
```
|
└→ 管理者がLINEで変更内容を確認
|
||||||
|
```
|
||||||
### 通知される操作一覧
|
|
||||||
|
### 通知される操作一覧
|
||||||
| 区分 | 操作 | 説明 |
|
|
||||||
|------|------|------|
|
| 区分 | 操作 | 説明 |
|
||||||
| 予約 | `create` | 予約が作成された |
|
|------|------|------|
|
||||||
| 予約 | `update` | 予約の日時・機械が変更された |
|
| 予約 | `create` | 予約が作成された |
|
||||||
| 予約 | `cancel` | 予約がキャンセルされた |
|
| 予約 | `update` | 予約の日時・機械が変更された |
|
||||||
| 実績 | `create` | 実績が登録された |
|
| 予約 | `cancel` | 予約がキャンセルされた |
|
||||||
| 実績 | `update` | 実績が修正された |
|
| 実績 | `create` | 実績が登録された |
|
||||||
| 実績 | `delete` | 実績が削除された |
|
| 実績 | `update` | 実績が修正された |
|
||||||
|
| 実績 | `delete` | 実績が削除された |
|
||||||
---
|
|
||||||
|
---
|
||||||
## 2. フロー設計
|
|
||||||
|
## 2. フロー設計
|
||||||
### Windmillフロー情報
|
|
||||||
|
### Windmillフロー情報
|
||||||
| 項目 | 値 |
|
|
||||||
|------|-----|
|
| 項目 | 値 |
|
||||||
| パス | `f/shiraou/shiraou_notification` |
|
|------|-----|
|
||||||
| 概要 | 白皇集落営農 変更通知 |
|
| パス | `f/shiraou/shiraou_notification` |
|
||||||
| ステップ数 | 1(単一Pythonスクリプト) |
|
| 概要 | 白皇集落営農 変更通知 |
|
||||||
| スケジュール | `0 */5 * * * *`(5分毎、JST) |
|
| ステップ数 | 1(単一Pythonスクリプト) |
|
||||||
| スケジュールパス | `f/shiraou/shiraou_notification_every_5min` |
|
| スケジュール | `0 */5 * * * *`(5分毎、JST) |
|
||||||
|
| スケジュールパス | `f/shiraou/shiraou_notification_every_5min` |
|
||||||
### 実行フロー(擬似コード)
|
|
||||||
|
### 実行フロー(擬似コード)
|
||||||
```python
|
|
||||||
# Step 1: シークレット・前回実行時刻を取得
|
```python
|
||||||
api_key = get_variable("u/admin/NOTIFICATION_API_KEY")
|
# Step 1: シークレット・前回実行時刻を取得
|
||||||
line_token = get_variable("u/admin/LINE_CHANNEL_ACCESS_TOKEN")
|
api_key = get_variable("u/admin/NOTIFICATION_API_KEY")
|
||||||
line_to = get_variable("u/admin/LINE_TO")
|
line_token = get_variable("u/admin/LINE_CHANNEL_ACCESS_TOKEN")
|
||||||
last_checked = get_variable("u/admin/SHIRAOU_LAST_CHECKED_AT") # 空なら初回
|
line_to = get_variable("u/admin/LINE_TO")
|
||||||
|
last_checked = get_variable("u/admin/SHIRAOU_LAST_CHECKED_AT") # 空なら初回
|
||||||
since = last_checked or (now() - 10分)
|
|
||||||
|
since = last_checked or (now() - 10分)
|
||||||
# Step 2: 変更履歴を取得
|
|
||||||
response = GET "https://shiraou.keinafarm.net/reservations/api/changes/?since={since}"
|
# Step 2: 変更履歴を取得
|
||||||
headers: { "X-API-Key": api_key }
|
response = GET "https://shiraou.keinafarm.net/reservations/api/changes/?since={since}"
|
||||||
|
headers: { "X-API-Key": api_key }
|
||||||
# Step 3: 変更があればLINE通知
|
|
||||||
if response.reservations or response.usages:
|
# Step 3: 変更があればLINE通知
|
||||||
message = format_message(response)
|
if response.reservations or response.usages:
|
||||||
POST "https://api.line.me/v2/bot/message/push"
|
message = format_message(response)
|
||||||
body: { "to": line_to, "messages": [{"type": "text", "text": message}] }
|
POST "https://api.line.me/v2/bot/message/push"
|
||||||
|
body: { "to": line_to, "messages": [{"type": "text", "text": message}] }
|
||||||
# Step 4: 前回実行時刻を更新(正常完了時のみ)
|
|
||||||
set_variable("u/admin/SHIRAOU_LAST_CHECKED_AT", response.checked_at)
|
# Step 4: 前回実行時刻を更新(正常完了時のみ)
|
||||||
```
|
set_variable("u/admin/SHIRAOU_LAST_CHECKED_AT", response.checked_at)
|
||||||
|
```
|
||||||
### エラー時の挙動
|
|
||||||
|
### エラー時の挙動
|
||||||
- API呼び出し失敗、LINE送信失敗のいずれでも例外が発生
|
|
||||||
- 例外が発生した場合、`SHIRAOU_LAST_CHECKED_AT` は更新されない
|
- API呼び出し失敗、LINE送信失敗のいずれでも例外が発生
|
||||||
- 次回実行時に同じ `since` で再試行される(変更の取り漏れ防止)
|
- 例外が発生した場合、`SHIRAOU_LAST_CHECKED_AT` は更新されない
|
||||||
|
- 次回実行時に同じ `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` の形式例**:
|
|
||||||
- `2026-02-21T10:00:00+09:00`(タイムゾーン付き、推奨)
|
**`since` の形式例**:
|
||||||
- `2026-02-21T10:00:00`(ナイーブ、JSTとして扱われる)
|
- `2026-02-21T10:00:00+09:00`(タイムゾーン付き、推奨)
|
||||||
|
- `2026-02-21T10:00:00`(ナイーブ、JSTとして扱われる)
|
||||||
### レスポンス(200 OK)
|
|
||||||
|
### レスポンス(200 OK)
|
||||||
```json
|
|
||||||
{
|
```json
|
||||||
"checked_at": "2026-02-21T12:00:00+09:00",
|
{
|
||||||
"since": "2026-02-21T10:00:00+09:00",
|
"checked_at": "2026-02-21T12:00:00+09:00",
|
||||||
"reservations": [
|
"since": "2026-02-21T10:00:00+09:00",
|
||||||
{
|
"reservations": [
|
||||||
"operation": "create",
|
{
|
||||||
"reservation_id": 123,
|
"operation": "create",
|
||||||
"user_name": "田中太郎",
|
"reservation_id": 123,
|
||||||
"machine_name": "トラクター",
|
"user_name": "田中太郎",
|
||||||
"start_at": "2026-02-25T09:00:00+09:00",
|
"machine_name": "トラクター",
|
||||||
"end_at": "2026-02-25T12:00:00+09:00",
|
"start_at": "2026-02-25T09:00:00+09:00",
|
||||||
"operated_at": "2026-02-21T11:30:00+09:00",
|
"end_at": "2026-02-25T12:00:00+09:00",
|
||||||
"operator_name": "田中太郎",
|
"operated_at": "2026-02-21T11:30:00+09:00",
|
||||||
"reason": ""
|
"operator_name": "田中太郎",
|
||||||
}
|
"reason": ""
|
||||||
],
|
}
|
||||||
"usages": [
|
],
|
||||||
{
|
"usages": [
|
||||||
"operation": "update",
|
{
|
||||||
"usage_id": 456,
|
"operation": "update",
|
||||||
"user_name": "山田次郎",
|
"usage_id": 456,
|
||||||
"machine_name": "コンバイン",
|
"user_name": "山田次郎",
|
||||||
"amount": 4.0,
|
"machine_name": "コンバイン",
|
||||||
"unit": "時間",
|
"amount": 4.0,
|
||||||
"start_at": "2026-02-20T08:00:00+09:00",
|
"unit": "時間",
|
||||||
"end_at": "2026-02-20T12:00:00+09:00",
|
"start_at": "2026-02-20T08:00:00+09:00",
|
||||||
"operated_at": "2026-02-21T11:55:00+09:00",
|
"end_at": "2026-02-20T12:00:00+09:00",
|
||||||
"operator_name": "管理者A",
|
"operated_at": "2026-02-21T11:55:00+09:00",
|
||||||
"reason": "記録ミスのため修正"
|
"operator_name": "管理者A",
|
||||||
}
|
"reason": "記録ミスのため修正"
|
||||||
]
|
}
|
||||||
}
|
]
|
||||||
```
|
}
|
||||||
|
```
|
||||||
### 変更なし時のレスポンス
|
|
||||||
|
### 変更なし時のレスポンス
|
||||||
```json
|
|
||||||
{
|
```json
|
||||||
"checked_at": "2026-02-21T12:05:00+09:00",
|
{
|
||||||
"since": "2026-02-21T12:00:00+09:00",
|
"checked_at": "2026-02-21T12:05:00+09:00",
|
||||||
"reservations": [],
|
"since": "2026-02-21T12:00:00+09:00",
|
||||||
"usages": []
|
"reservations": [],
|
||||||
}
|
"usages": []
|
||||||
```
|
}
|
||||||
|
```
|
||||||
### エラーレスポンス
|
|
||||||
|
### エラーレスポンス
|
||||||
| ステータス | 原因 |
|
|
||||||
|-----------|------|
|
| ステータス | 原因 |
|
||||||
| `401 Unauthorized` | APIキーが不正または未設定 |
|
|-----------|------|
|
||||||
| `400 Bad Request` | `since` パラメータが欠落または不正な日時形式 |
|
| `401 Unauthorized` | APIキーが不正または未設定 |
|
||||||
|
| `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
|
```
|
||||||
Authorization: Bearer <LINE_CHANNEL_ACCESS_TOKEN>
|
POST https://api.line.me/v2/bot/message/push
|
||||||
Content-Type: application/json
|
Authorization: Bearer <LINE_CHANNEL_ACCESS_TOKEN>
|
||||||
```
|
Content-Type: application/json
|
||||||
|
```
|
||||||
### リクエストボディ
|
|
||||||
|
### リクエストボディ
|
||||||
```json
|
|
||||||
{
|
```json
|
||||||
"to": "<LINE_TO>",
|
{
|
||||||
"messages": [
|
"to": "<LINE_TO>",
|
||||||
{
|
"messages": [
|
||||||
"type": "text",
|
{
|
||||||
"text": "<フォーマット済みメッセージ>"
|
"type": "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時間
|
利用者: 山田次郎
|
||||||
日: 2026-02-20
|
利用量: 4.0時間
|
||||||
理由: 記録ミスのため修正
|
日: 2026-02-20
|
||||||
```
|
理由: 記録ミスのため修正
|
||||||
|
```
|
||||||
### アイコン規則
|
|
||||||
|
### アイコン規則
|
||||||
| アイコン | 意味 |
|
|
||||||
|---------|------|
|
| アイコン | 意味 |
|
||||||
| 🟢 | 作成(create / 予約作成 / 実績登録) |
|
|---------|------|
|
||||||
| 🔵 | 変更(update / 予約変更 / 実績修正) |
|
| 🟢 | 作成(create / 予約作成 / 実績登録) |
|
||||||
| 🔴 | 削除・キャンセル(cancel / delete) |
|
| 🔵 | 変更(update / 予約変更 / 実績修正) |
|
||||||
|
| 🔴 | 削除・キャンセル(cancel / delete) |
|
||||||
### 通知先の種別
|
|
||||||
|
### 通知先の種別
|
||||||
`LINE_TO` にはユーザーIDまたはグループIDを設定する。
|
|
||||||
|
`LINE_TO` にはユーザーIDまたはグループIDを設定する。
|
||||||
| 種別 | ID形式 |
|
|
||||||
|------|-------|
|
| 種別 | ID形式 |
|
||||||
| ユーザー | `U` で始まる文字列 |
|
|------|-------|
|
||||||
| グループ | `C` で始まる文字列 |
|
| ユーザー | `U` で始まる文字列 |
|
||||||
|
| グループ | `C` で始まる文字列 |
|
||||||
---
|
|
||||||
|
---
|
||||||
## 5. Windmill設定仕様
|
|
||||||
|
## 5. Windmill設定仕様
|
||||||
### Windmill Variables(シークレット)
|
|
||||||
|
### Windmill Variables(シークレット)
|
||||||
以下の変数を Windmill UI(Variables ページ)で作成・管理する。
|
|
||||||
|
以下の変数を Windmill UI(Variables ページ)で作成・管理する。
|
||||||
| 変数パス | Secret | 説明 | 取得元 |
|
|
||||||
|---------|--------|------|-------|
|
| 変数パス | Secret | 説明 | 取得元 |
|
||||||
| `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/NOTIFICATION_API_KEY` | ✅ | shiraou.keinafarm.net のAPIキー | Djangoサーバー側 `NOTIFICATION_API_KEY` 環境変数と同一値 |
|
||||||
| `u/admin/LINE_TO` | ✅ | 通知先のLINEユーザーIDまたはグループID | LINE webhook / profile API |
|
| `u/admin/LINE_CHANNEL_ACCESS_TOKEN` | ✅ | LINE Messaging API チャネルアクセストークン | LINE Developers Console |
|
||||||
| `u/admin/SHIRAOU_LAST_CHECKED_AT` | ❌ | 前回確認時刻(ワークフローが自動更新) | ワークフローが自動管理(初期値: 空文字) |
|
| `u/admin/LINE_TO` | ✅ | 通知先のLINEユーザーIDまたはグループID | LINE webhook / profile API |
|
||||||
|
| `u/admin/SHIRAOU_LAST_CHECKED_AT` | ❌ | 前回確認時刻(ワークフローが自動更新) | ワークフローが自動管理(初期値: 空文字) |
|
||||||
### Django側の設定(shiraou.keinafarm.net)
|
|
||||||
|
### Django側の設定(shiraou.keinafarm.net)
|
||||||
`docker-compose.yml` に以下の環境変数を追加:
|
|
||||||
|
`docker-compose.yml` に以下の環境変数を追加:
|
||||||
```yaml
|
|
||||||
environment:
|
```yaml
|
||||||
- NOTIFICATION_API_KEY=<NOTIFICATION_API_KEYと同一の値>
|
environment:
|
||||||
```
|
- NOTIFICATION_API_KEY=<NOTIFICATION_API_KEYと同一の値>
|
||||||
|
```
|
||||||
APIキー生成コマンド:
|
|
||||||
```bash
|
APIキー生成コマンド:
|
||||||
openssl rand -hex 32
|
```bash
|
||||||
```
|
openssl rand -hex 32
|
||||||
|
```
|
||||||
---
|
|
||||||
|
---
|
||||||
## 6. 状態管理仕様
|
|
||||||
|
## 6. 状態管理仕様
|
||||||
### 状態変数: `SHIRAOU_LAST_CHECKED_AT`
|
|
||||||
|
### 状態変数: `SHIRAOU_LAST_CHECKED_AT`
|
||||||
| 項目 | 内容 |
|
|
||||||
|------|------|
|
| 項目 | 内容 |
|
||||||
| 格納場所 | Windmill Variable `u/admin/SHIRAOU_LAST_CHECKED_AT` |
|
|------|------|
|
||||||
| 型 | ISO8601文字列(例: `2026-02-21T15:30:00+09:00`) |
|
| 格納場所 | Windmill Variable `u/admin/SHIRAOU_LAST_CHECKED_AT` |
|
||||||
| 初期値 | 空文字(初回実行時は `現在時刻 - 10分` を使用) |
|
| 型 | ISO8601文字列(例: `2026-02-21T15:30:00+09:00`) |
|
||||||
| 更新タイミング | フロー正常完了時のみ、APIレスポンスの `checked_at` を保存 |
|
| 初期値 | 空文字(初回実行時は `現在時刻 - 10分` を使用) |
|
||||||
| 参照タイミング | フロー実行開始時、`since` パラメータとして使用 |
|
| 更新タイミング | フロー正常完了時のみ、APIレスポンスの `checked_at` を保存 |
|
||||||
|
| 参照タイミング | フロー実行開始時、`since` パラメータとして使用 |
|
||||||
### 重複通知防止の仕組み
|
|
||||||
|
### 重複通知防止の仕組み
|
||||||
```
|
|
||||||
実行1: since=T0, checked_at=T1 → LAST_CHECKED_AT = T1
|
```
|
||||||
実行2: since=T1, checked_at=T2 → T1以降の変更のみ取得
|
実行1: since=T0, checked_at=T1 → LAST_CHECKED_AT = T1
|
||||||
```
|
実行2: since=T1, checked_at=T2 → T1以降の変更のみ取得
|
||||||
|
```
|
||||||
- `since` に `checked_at`(APIが確認した時刻)を使うことで、変更の取りこぼしが発生しない
|
|
||||||
- `since`(リクエストに渡した時刻)ではなく `checked_at`(サーバーが確認した時刻)を保存するのがポイント
|
- `since` に `checked_at`(APIが確認した時刻)を使うことで、変更の取りこぼしが発生しない
|
||||||
|
- `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` を保存しない)
|
|
||||||
- `checked_at`: APIサーバーが「この時刻まで確認した」という保証付きの時刻
|
1. **`SHIRAOU_LAST_CHECKED_AT` には `checked_at` を保存すること**(`since` を保存しない)
|
||||||
- 同じ変更が2度通知されることを防ぐ
|
- `checked_at`: APIサーバーが「この時刻まで確認した」という保証付きの時刻
|
||||||
|
- 同じ変更が2度通知されることを防ぐ
|
||||||
2. **状態更新は正常完了後のみ行うこと**
|
|
||||||
- API呼び出し失敗・LINE送信失敗時は `SHIRAOU_LAST_CHECKED_AT` を更新しない
|
2. **状態更新は正常完了後のみ行うこと**
|
||||||
- 次回実行で同じ範囲を再取得し、通知の取り漏れを防ぐ
|
- API呼び出し失敗・LINE送信失敗時は `SHIRAOU_LAST_CHECKED_AT` を更新しない
|
||||||
|
- 次回実行で同じ範囲を再取得し、通知の取り漏れを防ぐ
|
||||||
3. **`wmill.get_state()` は使用しないこと**
|
|
||||||
- Windmillのインラインフロースクリプトでは実行をまたいで保存されない
|
3. **`wmill.get_state()` は使用しないこと**
|
||||||
- 状態管理は必ず Windmill Variable を使うこと
|
- Windmillのインラインフロースクリプトでは実行をまたいで保存されない
|
||||||
|
- 状態管理は必ず Windmill Variable を使うこと
|
||||||
### 設計判断
|
|
||||||
|
### 設計判断
|
||||||
| 判断 | 理由 |
|
|
||||||
|------|------|
|
| 判断 | 理由 |
|
||||||
| 単一ステップフロー | 状態管理を1か所に集約するため。`get_state()`/`set_state()` のスコープ問題を回避 |
|
|------|------|
|
||||||
| SSL検証スキップ | shiraou.keinafarm.net が自己署名証明書の可能性があるため |
|
| 単一ステップフロー | 状態管理を1か所に集約するため。`get_state()`/`set_state()` のスコープ問題を回避 |
|
||||||
| タイムアウト 30秒 | 農業用途で多少の応答遅延を許容しつつ、無限待機を防ぐ |
|
| SSL検証スキップ | shiraou.keinafarm.net が自己署名証明書の可能性があるため |
|
||||||
| 5分ポーリング間隔 | 農業機械の予約用途では数分の遅延は許容範囲。リアルタイム不要 |
|
| タイムアウト 30秒 | 農業用途で多少の応答遅延を許容しつつ、無限待機を防ぐ |
|
||||||
|
| 5分ポーリング間隔 | 農業機械の予約用途では数分の遅延は許容範囲。リアルタイム不要 |
|
||||||
---
|
|
||||||
|
---
|
||||||
## 8. 運用手順
|
|
||||||
|
## 8. 運用手順
|
||||||
### フローを手動実行
|
|
||||||
|
### フローを手動実行
|
||||||
```bash
|
|
||||||
curl -sk -X POST \
|
```bash
|
||||||
-H "Authorization: Bearer qLJ3VPZ61kTDiIwaUPUu1dXszGrsN1Dh" \
|
curl -sk -X POST \
|
||||||
-H "Content-Type: application/json" \
|
-H "Authorization: Bearer qLJ3VPZ61kTDiIwaUPUu1dXszGrsN1Dh" \
|
||||||
-d '{}' \
|
-H "Content-Type: application/json" \
|
||||||
"https://windmill.keinafarm.net/api/w/admins/jobs/run/f/f/shiraou/shiraou_notification"
|
-d '{}' \
|
||||||
```
|
"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: <キー>" \
|
# 変更なし確認
|
||||||
"https://shiraou.keinafarm.net/reservations/api/changes/?since=2026-02-21T11:59:00%2B09:00"
|
curl -H "X-API-Key: <キー>" \
|
||||||
|
"https://shiraou.keinafarm.net/reservations/api/changes/?since=2026-02-21T11:59:00%2B09:00"
|
||||||
# 広い範囲で変更取得(初期確認用)
|
|
||||||
curl -H "X-API-Key: <キー>" \
|
# 広い範囲で変更取得(初期確認用)
|
||||||
"https://shiraou.keinafarm.net/reservations/api/changes/?since=2026-01-01T00:00:00"
|
curl -H "X-API-Key: <キー>" \
|
||||||
```
|
"https://shiraou.keinafarm.net/reservations/api/changes/?since=2026-01-01T00:00:00"
|
||||||
|
```
|
||||||
### フローの更新デプロイ手順
|
|
||||||
|
### フローの更新デプロイ手順
|
||||||
```bash
|
|
||||||
cd /path/to/windmill_workflow
|
```bash
|
||||||
|
cd /path/to/windmill_workflow
|
||||||
# 1. flows/shiraou_notification.flow.json を編集
|
|
||||||
|
# 1. flows/shiraou_notification.flow.json を編集
|
||||||
# 2. 既存フローを削除して再作成(PUTは405のため)
|
|
||||||
curl -sk -X DELETE \
|
# 2. 既存フローを削除して再作成(PUTは405のため)
|
||||||
-H "Authorization: Bearer qLJ3VPZ61kTDiIwaUPUu1dXszGrsN1Dh" \
|
curl -sk -X DELETE \
|
||||||
"https://windmill.keinafarm.net/api/w/admins/flows/delete/f/shiraou/shiraou_notification"
|
-H "Authorization: Bearer qLJ3VPZ61kTDiIwaUPUu1dXszGrsN1Dh" \
|
||||||
|
"https://windmill.keinafarm.net/api/w/admins/flows/delete/f/shiraou/shiraou_notification"
|
||||||
curl -sk -X POST \
|
|
||||||
-H "Authorization: Bearer qLJ3VPZ61kTDiIwaUPUu1dXszGrsN1Dh" \
|
curl -sk -X POST \
|
||||||
-H "Content-Type: application/json" \
|
-H "Authorization: Bearer qLJ3VPZ61kTDiIwaUPUu1dXszGrsN1Dh" \
|
||||||
-d @flows/shiraou_notification.flow.json \
|
-H "Content-Type: application/json" \
|
||||||
"https://windmill.keinafarm.net/api/w/admins/flows/create"
|
-d @flows/shiraou_notification.flow.json \
|
||||||
|
"https://windmill.keinafarm.net/api/w/admins/flows/create"
|
||||||
# 3. スケジュールは再作成不要(フローの削除・再作成でも維持される)
|
|
||||||
```
|
# 3. スケジュールは再作成不要(フローの削除・再作成でも維持される)
|
||||||
|
```
|
||||||
### APIキーローテーション手順
|
|
||||||
|
### APIキーローテーション手順
|
||||||
1. Djangoサーバー側で新しいキーを生成: `openssl rand -hex 32`
|
|
||||||
2. `docker-compose.yml` の `NOTIFICATION_API_KEY` を更新してデプロイ
|
1. Djangoサーバー側で新しいキーを生成: `openssl rand -hex 32`
|
||||||
3. Windmill UI で `u/admin/NOTIFICATION_API_KEY` の値を同じ新しいキーに更新
|
2. `docker-compose.yml` の `NOTIFICATION_API_KEY` を更新してデプロイ
|
||||||
4. フローを手動実行して動作確認
|
3. Windmill UI で `u/admin/NOTIFICATION_API_KEY` の値を同じ新しいキーに更新
|
||||||
|
4. フローを手動実行して動作確認
|
||||||
### 過去のジョブ結果確認
|
|
||||||
|
### 過去のジョブ結果確認
|
||||||
```bash
|
|
||||||
curl -sk -H "Authorization: Bearer qLJ3VPZ61kTDiIwaUPUu1dXszGrsN1Dh" \
|
```bash
|
||||||
"https://windmill.keinafarm.net/api/w/admins/jobs/list?per_page=10&script_path_exact=f/shiraou/shiraou_notification&is_flow=true"
|
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"
|
||||||
|
```
|
||||||
---
|
|
||||||
|
---
|
||||||
## 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",
|
{
|
||||||
"value": {
|
"path": "f/shiraou/shiraou_notification",
|
||||||
"modules": [
|
"value": {
|
||||||
{
|
"modules": [
|
||||||
"id": "a",
|
{
|
||||||
"value": {
|
"id": "a",
|
||||||
"type": "rawscript",
|
"value": {
|
||||||
"language": "python3",
|
"type": "rawscript",
|
||||||
"content": "..."
|
"language": "python3",
|
||||||
}
|
"content": "..."
|
||||||
}
|
}
|
||||||
]
|
}
|
||||||
}
|
]
|
||||||
}
|
}
|
||||||
```
|
}
|
||||||
|
```
|
||||||
### ヘルパースクリプト
|
|
||||||
|
### ヘルパースクリプト
|
||||||
| ファイル | 説明 |
|
|
||||||
|---------|------|
|
| ファイル | 説明 |
|
||||||
| [wm-api.sh](../../wm-api.sh) | Windmill REST API操作ヘルパー。フロー作成・スケジュール管理に使用 |
|
|---------|------|
|
||||||
|
| [wm-api.sh](../../wm-api.sh) | Windmill REST API操作ヘルパー。フロー作成・スケジュール管理に使用 |
|
||||||
**主要コマンド**:
|
|
||||||
```
|
**主要コマンド**:
|
||||||
create-flow <file> JSONファイルからフローを作成
|
```
|
||||||
create-schedule <file> JSONファイルからスケジュールを作成
|
create-flow <file> JSONファイルからフローを作成
|
||||||
flows フロー一覧取得
|
create-schedule <file> JSONファイルからスケジュールを作成
|
||||||
schedules スケジュール一覧取得
|
flows フロー一覧取得
|
||||||
```
|
schedules スケジュール一覧取得
|
||||||
|
```
|
||||||
### ドキュメント
|
|
||||||
|
### ドキュメント
|
||||||
| ファイル | 説明 |
|
|
||||||
|---------|------|
|
| ファイル | 説明 |
|
||||||
| [docs/shiraou/19_windmill_通知ワークフロー連携仕様.md](19_windmill_通知ワークフロー連携仕様.md) | 仕様書。API仕様・メッセージフォーマットの原典 |
|
|---------|------|
|
||||||
| [docs/shiraou/20_マスタードキュメント_Windmill通知ワークフロー編.md](20_マスタードキュメント_Windmill通知ワークフロー編.md) | 本ドキュメント |
|
| [docs/shiraou/19_windmill_通知ワークフロー連携仕様.md](19_windmill_通知ワークフロー連携仕様.md) | 仕様書。API仕様・メッセージフォーマットの原典 |
|
||||||
|
| [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-new-script.md](../../.agent/workflows/windmill-new-script.md) | 新規スクリプト作成手順 |
|
| [.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) | 新規スクリプト作成手順 |
|
||||||
---
|
|
||||||
|
---
|
||||||
## 更新履歴
|
|
||||||
|
## 更新履歴
|
||||||
| 日付 | 変更内容 |
|
|
||||||
|------|---------|
|
| 日付 | 変更内容 |
|
||||||
| 2026-02-21 | 初版作成。フロー登録・スケジュール設定・状態管理バグ修正を含む |
|
|------|---------|
|
||||||
|
| 2026-02-21 | 初版作成。フロー登録・スケジュール設定・状態管理バグ修正を含む |
|
||||||
661
docs/30_マスタードキュメント_Alexa_TTS_API編.md
Normal file
661
docs/30_マスタードキュメント_Alexa_TTS_API編.md
Normal file
@@ -0,0 +1,661 @@
|
|||||||
|
# マスタードキュメント - Alexa TTS API 編
|
||||||
|
|
||||||
|
> **最終更新**: 2026-03-04
|
||||||
|
> **対象システム**: windmill.keinafarm.net(ワークスペース: admins)
|
||||||
|
> **目的**: このドキュメントだけで Alexa TTS API の全容を把握し、作業を継続できること
|
||||||
|
|
||||||
|
> **関連ドキュメント**: `docs/31_Alexa_Cookie更新GUI運用.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 目次
|
||||||
|
|
||||||
|
1. [機能概要](#1-機能概要)
|
||||||
|
2. [システム構成](#2-システム構成)
|
||||||
|
3. [ファイル構成](#3-ファイル構成)
|
||||||
|
4. [Windmillスクリプト仕様](#4-windmillスクリプト仕様)
|
||||||
|
5. [APIサーバー仕様](#5-apiサーバー仕様)
|
||||||
|
6. [Alexa API の仕組み(重要な知識)](#6-alexa-api-の仕組み重要な知識)
|
||||||
|
7. [認証・Cookie管理](#7-認証cookie管理)
|
||||||
|
8. [デプロイ手順](#8-デプロイ手順)
|
||||||
|
9. [デバイス一覧](#9-デバイス一覧)
|
||||||
|
10. [運用手順・コマンド集](#10-運用手順コマンド集)
|
||||||
|
11. [既知の問題・落とし穴](#11-既知の問題落とし穴)
|
||||||
|
12. [ソースファイル索引](#12-ソースファイル索引)
|
||||||
|
13. [実装の経緯(試行錯誤記録)](#13-実装の経緯試行錯誤記録)
|
||||||
|
14. [更新履歴](#14-更新履歴)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 機能概要
|
||||||
|
|
||||||
|
### 目的
|
||||||
|
|
||||||
|
Windmill のワークフローから、家の各部屋に設置した Amazon Echo デバイスに対して、任意の日本語テキストを読み上げさせる。
|
||||||
|
|
||||||
|
### ユーザーフロー
|
||||||
|
|
||||||
|
```
|
||||||
|
Windmill ワークフロー
|
||||||
|
└→ POST http://alexa_api:3500/speak
|
||||||
|
└→ alexa-api サーバー(Dockerコンテナ)
|
||||||
|
└→ HTTPS: alexa.amazon.co.jp/api/behaviors/preview
|
||||||
|
└→ Amazon サーバーが Echo デバイスに指示
|
||||||
|
└→ Echo デバイスが日本語で読み上げる
|
||||||
|
```
|
||||||
|
|
||||||
|
### 現在の状態
|
||||||
|
|
||||||
|
**✅ 完全動作中(2026-03-04 時点)**
|
||||||
|
|
||||||
|
- ローカルPCからもサーバーのDockerコンテナからも、日本語テキストの読み上げが動作する
|
||||||
|
- 解決の鍵: `sequenceJson` 内の日本語文字を `\uXXXX` 形式にエスケープして送信する
|
||||||
|
- 補足: `u/admin/alexa_speak` を API 更新した直後、Windmill UI の入力欄が即時更新されない場合がある(後述の運用回避策を適用)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. システム構成
|
||||||
|
|
||||||
|
```
|
||||||
|
[ローカルPC (Windows)]
|
||||||
|
c:\Users\akira\Develop\windmill_workflow\alexa-api\
|
||||||
|
├── 開発・編集
|
||||||
|
├── auth4.js でCookie取得(ローカルのみ実行可能)
|
||||||
|
└── gitea でサーバーと同期(push は scp を使う)
|
||||||
|
|
||||||
|
[VPSサーバー (keinafarm.net)]
|
||||||
|
/home/claude/alexa-api/ ← git とは別にコピーして管理
|
||||||
|
├── server.js
|
||||||
|
├── Dockerfile
|
||||||
|
├── docker-compose.yml
|
||||||
|
└── .env (ALEXA_COOKIE を保管)
|
||||||
|
|
||||||
|
[Docker コンテナ: alexa_api]
|
||||||
|
├── ネットワーク: windmill_windmill-internal
|
||||||
|
├── ポート: 3500(外部非公開)
|
||||||
|
└── Windmill ワーカーから http://alexa_api:3500 でアクセス
|
||||||
|
|
||||||
|
[Windmill]
|
||||||
|
スクリプト: u/admin/alexa_speak
|
||||||
|
└→ http://alexa_api:3500/speak を呼び出す
|
||||||
|
```
|
||||||
|
|
||||||
|
### ネットワーク設計のポイント
|
||||||
|
|
||||||
|
- `alexa_api` コンテナは外部に公開しない(セキュリティ)
|
||||||
|
- Windmill ワーカーと同じ Docker 内部ネットワーク `windmill_windmill-internal` に接続
|
||||||
|
- Windmill から `http://alexa_api:3500` でアクセス可能
|
||||||
|
|
||||||
|
### Cookie 更新の権限設計
|
||||||
|
|
||||||
|
- `akira` に `windmill` への自由な切り替え権限は与えない
|
||||||
|
- 代わりに `sudoers` で `/usr/local/bin/alexa-cookie-deploy.sh /tmp/alexa-api.env` だけを許可する
|
||||||
|
- ローカル GUI は `.env` を `/tmp/alexa-api.env` へ転送したあと、この専用スクリプトだけを `sudo` で実行する
|
||||||
|
- 専用スクリプト自身は root で `.env` 反映と `docker compose restart` を完了する
|
||||||
|
- これにより Cookie 更新だけを安全寄りに GUI 化できる
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. ファイル構成
|
||||||
|
|
||||||
|
| ファイル | 場所 | 役割 | 備考 |
|
||||||
|
|---------|------|------|------|
|
||||||
|
| `server.js` | `alexa-api/` | Express API サーバー本体 | 本番コード。変更したらビルド・再デプロイが必要 |
|
||||||
|
| `Dockerfile` | `alexa-api/` | Docker イメージ定義 | node:20-alpine ベース |
|
||||||
|
| `docker-compose.yml` | `alexa-api/` | コンテナ起動設定 | windmill_windmill-internal に接続 |
|
||||||
|
| `package.json` | `alexa-api/` | npm 依存関係 | 本番は express のみ |
|
||||||
|
| `.env.example` | `alexa-api/` | 環境変数テンプレート | `ALEXA_COOKIE=xxx` の形式 |
|
||||||
|
| `.env` | `alexa-api/`(.gitignore 対象) | 実際の Cookie 保管 | Git にコミットしない |
|
||||||
|
| `auth4.js` | `alexa-api/` | Amazon 認証・Cookie 取得スクリプト | CLI版。ローカルのみで実行 |
|
||||||
|
| `auth4-web.js` | `alexa-api/` | Cookie 更新 GUI | ブラウザで認証し、サーバー反映まで実行可能 |
|
||||||
|
| `auth4-core.js` | `alexa-api/` | Cookie 更新の共通ロジック | CLI版とGUI版で共通利用 |
|
||||||
|
| `alexa-cookie-deploy.sh` | `alexa-api/` | サーバー側専用反映スクリプト | `/tmp/alexa-api.env` を本番 `.env` に反映し、root で再起動 |
|
||||||
|
| `alexa-cookie-deploy.sudoers` | `alexa-api/` | sudoers 設定例 | `akira` から専用反映スクリプトだけ実行許可 |
|
||||||
|
| `auth.js` / `auth2.js` / `auth3.js` | `alexa-api/` | auth4.js の旧バージョン | 参考用。実際は auth4.js を使う |
|
||||||
|
| `test_tts.js` | `alexa-api/` | ローカルテスト用スクリプト | `.env` を読んで直接 alexa.amazon.co.jp を叩く。テスト対象デバイスはシリアル `G0922H08525302K5`(オフィスの右エコー)にハードコード。TABLET は一覧表示から除外。 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Windmillスクリプト仕様
|
||||||
|
|
||||||
|
### スクリプトパス
|
||||||
|
|
||||||
|
```
|
||||||
|
u/admin/alexa_speak
|
||||||
|
```
|
||||||
|
|
||||||
|
### スクリプト本体(TypeScript / Bun)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### スキーマ
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"required": ["device", "text"],
|
||||||
|
"properties": {
|
||||||
|
"device": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "デバイス名またはシリアル番号"
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "読み上げるテキスト"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 呼び出し例
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// デバイス名で指定
|
||||||
|
await main("オフィスの右エコー", "来客がありました");
|
||||||
|
|
||||||
|
// シリアル番号で指定(確実)
|
||||||
|
await main("G0922H08525302K5", "来客がありました");
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. APIサーバー仕様
|
||||||
|
|
||||||
|
### エンドポイント一覧
|
||||||
|
|
||||||
|
| メソッド | パス | 説明 |
|
||||||
|
|---------|------|------|
|
||||||
|
| `POST` | `/speak` | テキスト読み上げ |
|
||||||
|
| `GET` | `/devices` | デバイス一覧取得 |
|
||||||
|
| `GET` | `/health` | ヘルスチェック |
|
||||||
|
|
||||||
|
### POST /speak
|
||||||
|
|
||||||
|
**リクエスト**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"device": "オフィスの右エコー",
|
||||||
|
"text": "読み上げる日本語テキスト"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `device`: デバイス名(日本語)またはシリアル番号。部分一致も可能
|
||||||
|
- `text`: 読み上げるテキスト(日本語OK)
|
||||||
|
|
||||||
|
**レスポンス(成功)**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"device": "オフィスの右エコー",
|
||||||
|
"text": "読み上げる日本語テキスト"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Amazon は **200 または 202** を返す(どちらも成功として扱う。202 は非同期処理を示す)
|
||||||
|
|
||||||
|
**レスポンス(失敗)**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "デバイス \"xxxxx\" が見つかりません",
|
||||||
|
"available": "プレハブ, リビングエコー1, ..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /health
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "ok": true, "cookieLength": 1234 }
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /devices
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "name": "オフィスの右エコー", "type": "A4ZXE0RM7LQ7A", "serial": "G0922H08525302K5", "online": true, "family": "ECHO" },
|
||||||
|
...
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
- `force=true` で**常にキャッシュを無効化**して最新一覧を取得する(`/speak` の5分キャッシュとは独立)
|
||||||
|
- TABLET や Alexa アプリなど、Echo 以外のデバイスも含む全デバイスを返す
|
||||||
|
|
||||||
|
### デバイス検索ロジック(findDevice)
|
||||||
|
|
||||||
|
`/speak` の `device` パラメータは以下の優先順位で検索する:
|
||||||
|
|
||||||
|
1. **シリアル番号完全一致**(`serialNumber === device`)
|
||||||
|
2. **アカウント名完全一致**(大文字小文字を無視)
|
||||||
|
3. **アカウント名部分一致**(`includes()` 、大文字小文字を無視)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 例: "右エコー" でも "オフィスの右エコー" を見つけられる
|
||||||
|
```
|
||||||
|
|
||||||
|
### キャッシュ仕様
|
||||||
|
|
||||||
|
| 対象 | TTL | 備考 |
|
||||||
|
|------|-----|------|
|
||||||
|
| `customerId` | サーバー再起動まで永続 | Bootstrap API から取得 |
|
||||||
|
| デバイス一覧(`/speak` 経由) | 5分 | 期限切れ後は自動更新 |
|
||||||
|
| デバイス一覧(`/devices` 経由) | なし(毎回強制取得) | 診断・確認用途 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Alexa API の仕組み(重要な知識)
|
||||||
|
|
||||||
|
### 直接 API 実装の理由
|
||||||
|
|
||||||
|
`alexa-remote2` ライブラリは、取得済みの Cookie 文字列を受け付けず内部で再認証しようとして失敗するため、使用しない。すべて自前で HTTPS リクエストを組み立てる。
|
||||||
|
|
||||||
|
### API 呼び出しシーケンス
|
||||||
|
|
||||||
|
```
|
||||||
|
1. GET /api/language
|
||||||
|
→ Set-Cookie: csrf=XXXXX を取得(毎リクエストごとに必要)
|
||||||
|
|
||||||
|
2. GET /api/bootstrap
|
||||||
|
→ customerId を取得(キャッシュ: サーバー起動中は永続)
|
||||||
|
→ customerId = "A1AE8HXD8IJ61L"
|
||||||
|
|
||||||
|
3. GET /api/devices-v2/device?cached=false
|
||||||
|
→ デバイス一覧取得(5分キャッシュ)
|
||||||
|
|
||||||
|
4. POST /api/behaviors/preview
|
||||||
|
→ シーケンス JSON を送信して読み上げ実行
|
||||||
|
```
|
||||||
|
|
||||||
|
### サーバー起動時の自動初期化
|
||||||
|
|
||||||
|
`app.listen()` の直後に非同期で初期化処理を実行する:
|
||||||
|
|
||||||
|
1. `getCustomerId()` を呼び出して `customerId` をキャッシュ(成功すると `[INFO] Customer ID: xxx` をログ出力)
|
||||||
|
2. `getDevices()` を呼び出してデバイス一覧をキャッシュ(5分 TTL)
|
||||||
|
3. `deviceType` が `A4ZXE` または `ASQZWP` で始まるデバイス(Echo 系)のみをログ出力
|
||||||
|
|
||||||
|
**失敗してもサーバーは起動し続ける**(`[WARN] Startup init failed:` と出力して続行)。ただし Cookie が無効な場合、その後の `/speak` リクエストも全て失敗する。
|
||||||
|
|
||||||
|
### POST /api/behaviors/preview のリクエスト構造
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"behaviorId": "PREVIEW",
|
||||||
|
"sequenceJson": "<エスケープ済みJSON文字列>",
|
||||||
|
"status": "ENABLED"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`sequenceJson` の中身**(JSON文字列化 + `\uXXXX` エスケープ後):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"@type": "com.amazon.alexa.behaviors.model.Sequence",
|
||||||
|
"startNode": {
|
||||||
|
"@type": "com.amazon.alexa.behaviors.model.OpaquePayloadOperationNode",
|
||||||
|
"type": "Alexa.Speak",
|
||||||
|
"operationPayload": {
|
||||||
|
"deviceType": "A4ZXE0RM7LQ7A",
|
||||||
|
"deviceSerialNumber": "G0922H08525302K5",
|
||||||
|
"customerId": "A1AE8HXD8IJ61L",
|
||||||
|
"locale": "ja-JP",
|
||||||
|
"textToSpeak": "読み上げるテキスト"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ⚠️ 最重要ポイント: `\uXXXX` エスケープ
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// ★ これをしないと日本語が発話されない!
|
||||||
|
var rawSequenceJson = JSON.stringify(sequenceObj).replace(
|
||||||
|
/[\u0080-\uffff]/g,
|
||||||
|
function(c) { return '\\u' + ('0000' + c.charCodeAt(0).toString(16)).slice(-4); }
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**なぜ必要か**: `sequenceJson` に raw UTF-8 の日本語文字が含まれていると、Amazon のパーサーが日本語 Unicode 文字をフィルタリングして除去してしまい、発話されない。`\uXXXX` 形式の JSON エスケープシーケンスに変換することで回避できる。
|
||||||
|
|
||||||
|
### ヘッダー要件
|
||||||
|
|
||||||
|
```
|
||||||
|
Content-Type: application/json
|
||||||
|
csrf: <CSRFトークン> ← ヘッダーに必要
|
||||||
|
Referer: https://alexa.amazon.co.jp/spa/index.html
|
||||||
|
Origin: https://alexa.amazon.co.jp
|
||||||
|
Cookie: <ALEXA_COOKIE>; csrf=<CSRFトークン> ← Cookieにも必要
|
||||||
|
```
|
||||||
|
|
||||||
|
- CSRF トークンはヘッダー(`csrf:`)と Cookie(`csrf=`)の **両方に必要**
|
||||||
|
- `Content-Length` は不要(Amazon が自動判定)
|
||||||
|
|
||||||
|
### locale パラメータ
|
||||||
|
|
||||||
|
| 値 | 動作 |
|
||||||
|
|----|------|
|
||||||
|
| `"ja-JP"` | ✅ 日本語で発話(`\uXXXX` エスケープが前提) |
|
||||||
|
| `""` (空文字) | 英語のみ発話。日本語は除去される |
|
||||||
|
| locale なし | 英語音声として扱われる |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 認証・Cookie管理
|
||||||
|
|
||||||
|
### Cookie の役割
|
||||||
|
|
||||||
|
Amazon Alexa の非公式 API は Cookie 認証を使用する。Alexa アプリのログイン状態を模倣する。
|
||||||
|
|
||||||
|
### Cookie の取得方法(auth4.js)
|
||||||
|
|
||||||
|
**ローカル PC(Windows)でのみ実行可能**(Amazon のログインフローにブラウザーリダイレクトが必要なため)。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# alexa-api ディレクトリで実行
|
||||||
|
cd alexa-api
|
||||||
|
AMAZON_EMAIL="メールアドレス" AMAZON_PASSWORD="パスワード" node auth4.js
|
||||||
|
```
|
||||||
|
|
||||||
|
成功すると `alexa-api/.env` が生成・更新される。
|
||||||
|
|
||||||
|
### auth4.js のログインフロー
|
||||||
|
|
||||||
|
1. `GET https://www.amazon.co.jp/ap/signin?openid.assoc_handle=amzn_dp_project_dee_jp`
|
||||||
|
- `openid.assoc_handle: 'amzn_dp_project_dee_jp'` が **Alexa Japan 専用のハンドル**(他のAmazonサービスとは異なる)
|
||||||
|
- `openid.return_to: 'https://alexa.amazon.co.jp/api/apps/v1/token'` にリダイレクト先を指定
|
||||||
|
2. hidden フィールド(anti-csrftoken-a2z, appActionToken, workflowState 等)を HTML から抽出
|
||||||
|
3. POST でメール/パスワードを `rememberMe: 'true'` と一緒に送信(長期Cookie取得のため重要)
|
||||||
|
4. 3xx リダイレクトを最大10回たどる(`alexa.amazon.co.jp/api/apps/v1/token` 等)
|
||||||
|
5. 取得した Cookie(at-acbjp, sess-at-acbjp, sst-acbjp, session-token 等)を `.env` に保存
|
||||||
|
|
||||||
|
**成功判定**: Cookie に `at-acbjp` または `session-token` が含まれているかで判定。
|
||||||
|
|
||||||
|
**失敗時のエラー検出**:
|
||||||
|
- CAPTCHA が要求されている場合: `※ CAPTCHA が要求されています。しばらく待ってから再試行してください。`
|
||||||
|
- パスワードが間違っている場合: `※ パスワードが間違っている可能性があります。`
|
||||||
|
|
||||||
|
### Cookie の有効期限
|
||||||
|
|
||||||
|
数日〜数週間で期限切れになる。期限切れの症状: `/health` を叩くと Cookie 長は正常だが、`/speak` が 400 や 403 を返す。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. デプロイ手順
|
||||||
|
|
||||||
|
### A. コード変更時のデプロイ(ビルドが必要)
|
||||||
|
|
||||||
|
`server.js` / `Dockerfile` / `package.json` を変更した場合:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Step 1: ローカルで編集後、scp でサーバーに転送
|
||||||
|
scp alexa-api/server.js keinafarm-claude:/home/claude/alexa-api/server.js
|
||||||
|
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
|
||||||
|
|
||||||
|
# Step 2: サーバーでビルドして再起動
|
||||||
|
ssh keinafarm-claude
|
||||||
|
cd /home/claude/alexa-api
|
||||||
|
sudo docker compose build
|
||||||
|
sudo docker compose up -d
|
||||||
|
|
||||||
|
# Step 3: Traefik 再起動(コンテナ再作成後は必須)
|
||||||
|
sudo docker restart traefik
|
||||||
|
```
|
||||||
|
|
||||||
|
> **⚠️ 重要**: `docker compose restart` はイメージをリビルドしない。コード変更は `build + up -d` が必要。
|
||||||
|
|
||||||
|
### B. Cookie 更新時のデプロイ(ビルド不要)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. ローカルで GUI を起動
|
||||||
|
cd /home/akira/develop/windmill_workflow/alexa-api
|
||||||
|
npm run auth:web
|
||||||
|
|
||||||
|
# 2. ブラウザで http://127.0.0.1:3678 を開き、Amazon の認証情報を入力
|
||||||
|
|
||||||
|
# 3. 同じ画面の「サーバーへ反映する」を実行
|
||||||
|
# デフォルト値:
|
||||||
|
# SSH 接続先: keinafarm
|
||||||
|
# リモート一時アップロード先: /tmp/alexa-api.env
|
||||||
|
# 実行する専用コマンド: sudo /usr/local/bin/alexa-cookie-deploy.sh /tmp/alexa-api.env
|
||||||
|
```
|
||||||
|
|
||||||
|
### B-1. Cookie 更新 GUI の初回セットアップ
|
||||||
|
|
||||||
|
サーバー側で一度だけ以下を実施する:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 専用反映スクリプトを配置
|
||||||
|
scp alexa-api/alexa-cookie-deploy.sh keinafarm:/tmp/alexa-cookie-deploy.sh
|
||||||
|
ssh keinafarm 'sudo install -m 755 /tmp/alexa-cookie-deploy.sh /usr/local/bin/alexa-cookie-deploy.sh'
|
||||||
|
|
||||||
|
# 2. sudoers を配置
|
||||||
|
scp alexa-api/alexa-cookie-deploy.sudoers keinafarm:/tmp/alexa-cookie-deploy.sudoers
|
||||||
|
ssh keinafarm 'sudo install -m 440 /tmp/alexa-cookie-deploy.sudoers /etc/sudoers.d/alexa-cookie-deploy'
|
||||||
|
|
||||||
|
# 3. 動作確認
|
||||||
|
ssh keinafarm 'sudo /usr/local/bin/alexa-cookie-deploy.sh /tmp/not-found.env' || true
|
||||||
|
```
|
||||||
|
|
||||||
|
- `akira` から許可するのは `sudo /usr/local/bin/alexa-cookie-deploy.sh /tmp/alexa-api.env` だけ
|
||||||
|
- `windmill` への自由な `su` や広い `sudo` 権限は与えない
|
||||||
|
- 専用スクリプトは `/tmp/alexa-api.env` を `/home/claude/alexa-api/.env` に反映し、root で `docker compose restart` を実行する
|
||||||
|
|
||||||
|
### Traefik 再起動が必要な理由
|
||||||
|
|
||||||
|
`docker compose up -d` はコンテナを「再作成」するため、Docker 内部 IP アドレスが変わる。Traefik が古い IP を参照したまま 502/504 エラーを返すため、`sudo docker restart traefik` で新しい IP を再検出させる。
|
||||||
|
|
||||||
|
`docker compose restart` はコンテナ再起動のみ(IP 不変)なので Traefik 再起動は不要。
|
||||||
|
|
||||||
|
### docker-compose.yml
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
alexa-api:
|
||||||
|
build: .
|
||||||
|
container_name: alexa_api
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- PORT=3500
|
||||||
|
networks:
|
||||||
|
- windmill_windmill-internal
|
||||||
|
# デバッグ時は以下のコメントを外す:
|
||||||
|
# ports:
|
||||||
|
# - "127.0.0.1:3500:3500"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
windmill_windmill-internal:
|
||||||
|
external: true
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. デバイス一覧
|
||||||
|
|
||||||
|
| 名前 | deviceType | serialNumber |
|
||||||
|
|------|-----------|-------------|
|
||||||
|
| プレハブ | A4ZXE0RM7LQ7A | G0922H085165007R |
|
||||||
|
| リビングエコー1 | ASQZWP4GPYUT7 | G8M2DB08522600RL |
|
||||||
|
| リビングエコー2 | ASQZWP4GPYUT7 | G8M2DB08522503WF |
|
||||||
|
| オフィスの右エコー | A4ZXE0RM7LQ7A | G0922H08525302K5 |
|
||||||
|
| オフィスの左エコー | A4ZXE0RM7LQ7A | G0922H08525302J9 |
|
||||||
|
| 寝室のエコー | ASQZWP4GPYUT7 | G8M2HN08534302XH |
|
||||||
|
|
||||||
|
Windmill スクリプトから `device` パラメータに名前またはシリアル番号を渡す。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 運用手順・コマンド集
|
||||||
|
|
||||||
|
### サーバー上での確認コマンド
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# コンテナ状態確認
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
### 動作確認(Windmill ワーカーコンテナ内から)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ヘルスチェック
|
||||||
|
curl http://alexa_api:3500/health
|
||||||
|
|
||||||
|
# デバイス一覧確認
|
||||||
|
curl http://alexa_api:3500/devices
|
||||||
|
|
||||||
|
# TTS テスト
|
||||||
|
curl -X POST http://alexa_api:3500/speak \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"device":"オフィスの右エコー","text":"テストです"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Windmill からスクリプト実行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST \
|
||||||
|
-H "Authorization: Bearer <WindmillトークンWIND>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"device":"オフィスの右エコー","text":"テストです"}' \
|
||||||
|
"https://windmill.keinafarm.net/api/w/admins/jobs/run_wait_result/p/u/admin/alexa_speak"
|
||||||
|
```
|
||||||
|
|
||||||
|
### API反映後にUI入力欄が変わらない場合(2026-03-04 追記)
|
||||||
|
|
||||||
|
`u/admin/alexa_speak` を `create-script` で更新後、API上の `schema` は更新済みでも、Input フォームが旧表示のまま残ることがある。
|
||||||
|
|
||||||
|
対応手順:
|
||||||
|
|
||||||
|
1. APIで最新状態を確認する
|
||||||
|
2. `hash` 更新と `schema.properties.device` の以下2項目を確認する
|
||||||
|
- `format = "dynselect-device"`
|
||||||
|
- `originalType = "DynSelect_device"`
|
||||||
|
3. Windmill UI を `Ctrl + Shift + R` でハードリロードする
|
||||||
|
4. 反映されない場合は `Edit -> Deploy` を1回実行する
|
||||||
|
5. Input フォームで `Device` がドロップダウン表示になったことを確認する
|
||||||
|
|
||||||
|
実運用上は「API反映成功」と「UIフォーム反映成功」を別チェックとして扱う。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 既知の問題・落とし穴
|
||||||
|
|
||||||
|
| 問題 | 原因・対処 |
|
||||||
|
|------|-----------|
|
||||||
|
| `docker compose restart` してもコードが古い | `restart` はリビルドしない。`build + up -d` を使う |
|
||||||
|
| コンテナ再作成後に 502/504 エラー | Traefik が古い IP を参照。`sudo docker restart traefik` で解消 |
|
||||||
|
| alexa-remote2 は使えない | 取得済み Cookie を受け付けず内部再認証で失敗。直接 API 実装が必要 |
|
||||||
|
| CSRF トークンはヘッダーと Cookie の両方に必要 | 片方だけでは 401 になる |
|
||||||
|
| `operationPayload` に `customerId` が必須 | なければ 400 エラー |
|
||||||
|
| `sequenceJson` の日本語を `\uXXXX` エスケープしないと無音 | Amazon パーサーが raw UTF-8 の日本語をフィルタリングする |
|
||||||
|
| `Alexa.SpeakSsml` は動作しない | `/api/behaviors/preview` では使えない。`Alexa.Speak` のみ有効 |
|
||||||
|
| `AlexaAnnouncement` は別用途 | コンテンツでなくノード名が読まれる |
|
||||||
|
| レート制限 | 短時間連続リクエストで HTTP 429 または無音。通知用途では問題なし |
|
||||||
|
| Gitea push がブロックされる | pre-receive フックでエラー。ファイル転送は scp を使う |
|
||||||
|
| 起動ログに Echo デバイスが出ない | deviceType が `A4ZXE` or `ASQZWP` で始まるもののみ表示。新デバイス追加時は確認を |
|
||||||
|
| test_tts.js のテスト対象が固定 | シリアル `G0922H08525302K5`(オフィスの右エコー)にハードコード。他デバイスでテストする場合は一時的に書き換える |
|
||||||
|
| auth4.js で CAPTCHA が出る | Amazon のレート制限。しばらく時間を置いてから再実行 |
|
||||||
|
| `/devices` と `/speak` のキャッシュが異なる | `/devices` は毎回最新取得、`/speak` は5分キャッシュ。新しいデバイス追加直後に `/speak` が失敗する場合、コンテナ再起動でキャッシュクリア |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. ソースファイル索引
|
||||||
|
|
||||||
|
### コアコード
|
||||||
|
|
||||||
|
| ファイル | 説明 |
|
||||||
|
|---------|------|
|
||||||
|
| `alexa-api/server.js` | Express API サーバー。Alexa への直接 HTTPS 実装 |
|
||||||
|
| `alexa-api/Dockerfile` | node:20-alpine ベース。**`npm install --omit=dev`** で devDependencies(alexa-remote2, alexa-cookie2)を除外してビルド。コピーするのは `server.js` のみ(auth4.js 等はコンテナに含まれない) |
|
||||||
|
| `alexa-api/docker-compose.yml` | windmill_windmill-internal ネットワーク接続設定 |
|
||||||
|
| `alexa-api/auth4.js` | Amazon 認証・Cookie 取得(ローカルのみ) |
|
||||||
|
| `alexa-api/test_tts.js` | ローカルテスト用スクリプト |
|
||||||
|
| `alexa-api/.env.example` | 環境変数テンプレート |
|
||||||
|
|
||||||
|
### ドキュメント
|
||||||
|
|
||||||
|
| ファイル | 説明 |
|
||||||
|
|---------|------|
|
||||||
|
| `docs/archive/alexa-tts/10_Alexa TTS API 実装記録 (2026-03-02).md` | Claude Code による実装記録(アーカイブ) |
|
||||||
|
| `docs/archive/alexa-tts/11_色々やってダメだった.txt` | ChatGPT との試行錯誤チャットログ(アーカイブ) |
|
||||||
|
| `docs/archive/alexa-tts/12_ローカルで試したこと.md` | 日本語TTS問題の調査記録(アーカイブ) |
|
||||||
|
| `docs/archive/alexa-tts/21_引き継ぎ_alexa_speak_API反映後にUIドロップダウンが変わらない件.md` | API反映後のUI未反映事象の切り分けと回避策(アーカイブ) |
|
||||||
|
| `docs/archive/alexa-tts/README.md` | Alexa TTS 関連の中間資料アーカイブ索引 |
|
||||||
|
| `docs/30_マスタードキュメント_Alexa_TTS_API編.md` | 本ドキュメント |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 実装の経緯(試行錯誤記録)
|
||||||
|
|
||||||
|
### フェーズ1: alexa-remote2 の断念(2026-03-02 以前)
|
||||||
|
|
||||||
|
当初は `alexa-remote2` ライブラリを使用しようとしたが、取得済みの Cookie 文字列を渡しても内部で再認証を試みて失敗することがわかり、断念。Amazon Alexa API への直接 HTTPS 実装に切り替えた。
|
||||||
|
|
||||||
|
### フェーズ2: 英語は動くが日本語が出ない(2026-03-02〜03)
|
||||||
|
|
||||||
|
`Alexa.Speak` で英語は正常に発話されるが、日本語テキストが発話されない問題が発生。試行した内容:
|
||||||
|
|
||||||
|
| 試行内容 | 結果 |
|
||||||
|
|---------|------|
|
||||||
|
| `speakType: 'ssml'` を operationPayload に追加 | 変化なし(このフィールドは無効) |
|
||||||
|
| `type: 'Alexa.SpeakSsml'` に変更 | 英語も含め完全無音 |
|
||||||
|
| `<lang xml:lang="ja-JP">` SSML タグを text に含める | 英語のみ発話(日本語部分は無音) |
|
||||||
|
| `locale: ''` (空文字) | 英語は読めるが日本語は除去 |
|
||||||
|
| `locale: 'ja-JP'` | 日本語が除去される(VPSから) |
|
||||||
|
| Cookie 新規取得 | 変化なし(Cookie は原因ではなかった) |
|
||||||
|
| `AlexaAnnouncement` ノード | ノード名自体が読まれる(別用途) |
|
||||||
|
| Unicodeエスケープ `\u3053\u308c...` をテキストに | 変化なし |
|
||||||
|
|
||||||
|
### フェーズ3: 根本原因の特定と解決(2026-03-03)
|
||||||
|
|
||||||
|
**決定的な観察**: 日本語と英語が混在したテキスト `'あいうえおThis is Testあいうえお'` を送ると、英語部分(`This is Test`)のみが読まれ、日本語部分(`あいうえお`)は完全に無視された。
|
||||||
|
|
||||||
|
**根本原因**: `sequenceJson` パラメータに raw UTF-8 の日本語文字が含まれていると、Amazon のパーサーがそれをフィルタリングして除去する。文字コードの問題ではなく(`\u3053\u308c...` でも同じ結果)、JSON の文字列値の中の非 ASCII 文字の扱いの問題だった。
|
||||||
|
|
||||||
|
**解決策**: `JSON.stringify()` 後に non-ASCII 文字を `\uXXXX` 形式の JSON エスケープシーケンスに変換する。
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
var rawSequenceJson = JSON.stringify(sequenceObj).replace(
|
||||||
|
/[\u0080-\uffff]/g,
|
||||||
|
function(c) { return '\\u' + ('0000' + c.charCodeAt(0).toString(16)).slice(-4); }
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
この修正により「これは日本語のテストです」が完璧に発話されることを確認。`server.js` と `test_tts.js` の両方に適用済み。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. 更新履歴
|
||||||
|
|
||||||
|
| 日付 | 変更内容 |
|
||||||
|
|------|---------|
|
||||||
|
| 2026-03-02 | alexa-remote2 断念、直接 API 実装開始 |
|
||||||
|
| 2026-03-02〜03 | 日本語TTS問題の調査・試行錯誤 |
|
||||||
|
| 2026-03-03 | `\uXXXX` エスケープで日本語TTS完全解決。server.js・test_tts.js に反映 |
|
||||||
|
| 2026-03-03 | 本マスタードキュメント作成 |
|
||||||
|
| 2026-03-03 | findDevice 検索ロジック、キャッシュ仕様、起動初期化、auth4.js 詳細、Dockerfile 仕様を追記 |
|
||||||
|
| 2026-03-04 | `u/admin/alexa_speak` の API 反映後にUIドロップダウンが即時反映されない事象と標準対応(`Edit -> Deploy`)を統合。中間資料のアーカイブ索引を追加 |
|
||||||
211
docs/31_Alexa_Cookie更新GUI運用.md
Normal file
211
docs/31_Alexa_Cookie更新GUI運用.md
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
# Alexa Cookie更新 GUI 運用ドキュメント
|
||||||
|
|
||||||
|
> 最終更新: 2026-04-04
|
||||||
|
> 対象: `/home/akira/develop/windmill_workflow/alexa-api`
|
||||||
|
> 目的: Alexa Cookie の更新を、ローカル GUI と権限制限付きのサーバー反映で安全に運用する
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 何を作ったか
|
||||||
|
|
||||||
|
Alexa Cookie の更新作業を、手作業のコマンド列ではなく GUI 中心で進められるようにした。
|
||||||
|
|
||||||
|
今回追加・変更したもの:
|
||||||
|
|
||||||
|
| ファイル | 役割 |
|
||||||
|
|---------|------|
|
||||||
|
| `alexa-api/auth4-core.js` | Amazon 認証と Cookie 取得の共通ロジック |
|
||||||
|
| `alexa-api/auth4.js` | CLI 版の Cookie 更新ツール。`auth4-core.js` を利用 |
|
||||||
|
| `alexa-api/auth4-web.js` | ローカルで起動する GUI。Cookie 更新とサーバー反映を行う |
|
||||||
|
| `alexa-api/alexa-cookie-deploy.sh` | サーバー側の専用反映スクリプト。`/tmp/alexa-api.env` を本番 `.env` に反映し、`docker compose restart` を実行 |
|
||||||
|
| `alexa-api/alexa-cookie-deploy.sudoers` | `akira` から専用反映スクリプトだけを `sudo` 実行できるようにする sudoers 設定例 |
|
||||||
|
| `flows/hourly_chime.flow.json` | LINE 通知文面を GUI 手順に合わせて更新 |
|
||||||
|
|
||||||
|
この構成にした理由:
|
||||||
|
|
||||||
|
- `akira` に広い `sudo` 権限や `windmill` への自由な切り替え権限を与えたくない
|
||||||
|
- それでも Cookie 更新だけは迷わず再実行できるようにしたい
|
||||||
|
- そのため「GUI から呼べる専用コマンドだけを sudoers で許可する」構成にした
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 全体の流れ
|
||||||
|
|
||||||
|
通常運用の流れは以下の通り。
|
||||||
|
|
||||||
|
1. ローカルで GUI を起動する
|
||||||
|
2. ブラウザで Amazon のメールアドレスとパスワードを入力する
|
||||||
|
3. `alexa-api/.env` の `ALEXA_COOKIE` を更新する
|
||||||
|
4. GUI からサーバーへ `/tmp/alexa-api.env` を転送する
|
||||||
|
5. GUI から `sudo /usr/local/bin/alexa-cookie-deploy.sh /tmp/alexa-api.env` を実行する
|
||||||
|
6. サーバー側で `.env` 反映と `alexa_api` コンテナ再起動が行われる
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 初回セットアップ
|
||||||
|
|
||||||
|
このセットアップはサーバーで一度だけ必要。
|
||||||
|
|
||||||
|
### 3-1. 専用スクリプトを配置
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scp /home/akira/develop/windmill_workflow/alexa-api/alexa-cookie-deploy.sh keinafarm:/tmp/alexa-cookie-deploy.sh
|
||||||
|
ssh keinafarm 'sudo install -m 755 /tmp/alexa-cookie-deploy.sh /usr/local/bin/alexa-cookie-deploy.sh'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3-2. sudoers を配置
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scp /home/akira/develop/windmill_workflow/alexa-api/alexa-cookie-deploy.sudoers keinafarm:/tmp/alexa-cookie-deploy.sudoers
|
||||||
|
ssh keinafarm 'sudo install -m 440 /tmp/alexa-cookie-deploy.sudoers /etc/sudoers.d/alexa-cookie-deploy'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3-3. 何が許可されるか
|
||||||
|
|
||||||
|
`alexa-cookie-deploy.sudoers` の内容:
|
||||||
|
|
||||||
|
```sudoers
|
||||||
|
akira ALL=(root) NOPASSWD: /usr/local/bin/alexa-cookie-deploy.sh /tmp/alexa-api.env
|
||||||
|
```
|
||||||
|
|
||||||
|
許可しているのはこれだけ:
|
||||||
|
|
||||||
|
- `akira` が root として `/usr/local/bin/alexa-cookie-deploy.sh /tmp/alexa-api.env` を実行すること
|
||||||
|
|
||||||
|
許可していないもの:
|
||||||
|
|
||||||
|
- `windmill` への自由な `su`
|
||||||
|
- 任意コマンドの `sudo`
|
||||||
|
- 任意パスへのコピーや任意の Docker 操作
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. ふだんの使い方
|
||||||
|
|
||||||
|
### 4-1. GUI を起動
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/akira/develop/windmill_workflow/alexa-api
|
||||||
|
npm run auth:web
|
||||||
|
```
|
||||||
|
|
||||||
|
ブラウザで次を開く:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://127.0.0.1:3678
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4-2. Cookie を更新
|
||||||
|
|
||||||
|
画面上部の「Alexa Cookie 更新」で次を入力する。
|
||||||
|
|
||||||
|
- Amazon メールアドレス
|
||||||
|
- Amazon パスワード
|
||||||
|
|
||||||
|
「Cookie を更新する」を押すと、成功時は `alexa-api/.env` が更新される。
|
||||||
|
|
||||||
|
### 4-3. サーバーへ反映
|
||||||
|
|
||||||
|
画面下部の「サーバー反映」で次を確認する。
|
||||||
|
|
||||||
|
- SSH 接続先: `keinafarm`
|
||||||
|
- リモート一時アップロード先: `/tmp/alexa-api.env`
|
||||||
|
- 実行する専用コマンド: `sudo /usr/local/bin/alexa-cookie-deploy.sh /tmp/alexa-api.env`
|
||||||
|
|
||||||
|
「サーバーへ反映する」を押すと、内部では次を実行する。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scp /home/akira/develop/windmill_workflow/alexa-api/.env keinafarm:/tmp/alexa-api.env
|
||||||
|
ssh keinafarm 'sudo /usr/local/bin/alexa-cookie-deploy.sh /tmp/alexa-api.env'
|
||||||
|
```
|
||||||
|
|
||||||
|
成功時はログに以下のように表示される。
|
||||||
|
|
||||||
|
```text
|
||||||
|
alexa cookie deployed
|
||||||
|
Container alexa_api Restarting
|
||||||
|
Container alexa_api Started
|
||||||
|
反映完了
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 各ファイルの使い分け
|
||||||
|
|
||||||
|
### `auth4-web.js`
|
||||||
|
|
||||||
|
通常はこちらを使う。人が毎回コマンドを思い出さなくてよい。
|
||||||
|
|
||||||
|
### `auth4.js`
|
||||||
|
|
||||||
|
CLI で直接更新したい時のために残してある。基本運用は GUI を推奨。
|
||||||
|
|
||||||
|
例:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/akira/develop/windmill_workflow/alexa-api
|
||||||
|
AMAZON_EMAIL="xxx" AMAZON_PASSWORD="xxx" node auth4.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### `auth4-core.js`
|
||||||
|
|
||||||
|
直接実行するものではない。CLI 版と GUI 版の共通認証ロジック。
|
||||||
|
|
||||||
|
### `alexa-cookie-deploy.sh`
|
||||||
|
|
||||||
|
サーバー側で root として動く専用スクリプト。役割は次の3つ。
|
||||||
|
|
||||||
|
1. `/tmp/alexa-api.env` に `ALEXA_COOKIE=` があるか確認
|
||||||
|
2. `/home/claude/alexa-api/.env` を更新
|
||||||
|
3. `docker compose --env-file /home/claude/alexa-api/.env -f /home/claude/alexa-api/docker-compose.yml restart` を実行
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. LINE 通知との関係
|
||||||
|
|
||||||
|
`flows/hourly_chime.flow.json` の失敗時通知は、この GUI 運用に合わせて更新済み。
|
||||||
|
|
||||||
|
通知では次の導線を案内する。
|
||||||
|
|
||||||
|
1. `npm run auth:web`
|
||||||
|
2. `http://127.0.0.1:3678` を開く
|
||||||
|
3. GUI 上でサーバー反映まで実行する
|
||||||
|
|
||||||
|
これにより「あとでどう直すのか分からない」状態になりにくくしている。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. トラブルシュート
|
||||||
|
|
||||||
|
### `Could not resolve hostname ...`
|
||||||
|
|
||||||
|
SSH 接続先の別名が `~/.ssh/config` に存在しない。
|
||||||
|
|
||||||
|
今回の環境では使える別名は `keinafarm`。
|
||||||
|
|
||||||
|
### `Permission denied` で `/home/claude/alexa-api/.env` に書けない
|
||||||
|
|
||||||
|
`scp` で直接 `/home/claude/alexa-api/.env` へ置こうとすると起きる。
|
||||||
|
現在は `/tmp/alexa-api.env` に送ってから、専用スクリプトで反映する設計に変更済み。
|
||||||
|
|
||||||
|
### `stat /home/claude/alexa-api/.env: permission denied`
|
||||||
|
|
||||||
|
旧版の専用スクリプトで、権限不足のユーザーに切り替えて `docker compose` を実行していた時の症状。
|
||||||
|
現在の `alexa-cookie-deploy.sh` は root のまま `.env` 反映と再起動を完了する。
|
||||||
|
|
||||||
|
### CAPTCHA や MFA が出る
|
||||||
|
|
||||||
|
Amazon 側で追加認証が必要。GUI のログに失敗理由が出る。
|
||||||
|
この場合は一発で通らない可能性がある。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 関連ファイル
|
||||||
|
|
||||||
|
- `docs/30_マスタードキュメント_Alexa_TTS_API編.md`
|
||||||
|
- `alexa-api/auth4-web.js`
|
||||||
|
- `alexa-api/auth4.js`
|
||||||
|
- `alexa-api/auth4-core.js`
|
||||||
|
- `alexa-api/alexa-cookie-deploy.sh`
|
||||||
|
- `alexa-api/alexa-cookie-deploy.sudoers`
|
||||||
|
- `flows/hourly_chime.flow.json`
|
||||||
148
docs/archive/alexa-tts/10_Alexa TTS API 実装記録 (2026-03-02).md
Normal file
148
docs/archive/alexa-tts/10_Alexa TTS API 実装記録 (2026-03-02).md
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
Alexa TTS API マスタードキュメント
|
||||||
|
|
||||||
|
最終更新: 2026-03-03 状態: サーバーからの日本語TTS未解決(調査中)
|
||||||
|
------------
|
||||||
|
2026/03/03 10:24 akira記録
|
||||||
|
akiraが下記の変更をしましたので、内容を読んでください。
|
||||||
|
|
||||||
|
1) 構成とサーバーへのファイル受け渡し方法を変更しました
|
||||||
|
/home/claude/windmill_workflow
|
||||||
|
に、https://gitea.keinafarm.net/akira/windmill_workflow.gitをcloneしました
|
||||||
|
これにより、
|
||||||
|
C:\Users\akira\Develop\windmill_workflow
|
||||||
|
とのやり取りはgiteaを使って出来るようになります。
|
||||||
|
|
||||||
|
2) docker compose up -dで、「 Docker 内部の IP アドレスが変わり、Traefik のルーティングが古い IP を参照したまま 502/504 エラーになる」のは、原因があるはずです。(他のコンテナで、問題になっていないので)
|
||||||
|
調査して、Traefik 再起動が不必要になるようにしたいです
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
------------
|
||||||
|
|
||||||
|
概要
|
||||||
|
Windmill から家中の Echo デバイスに任意のテキストを読み上げさせる API サーバー。Node.js + Express で実装し、Docker コンテナとして Windmill サーバー上で稼働する。
|
||||||
|
|
||||||
|
⚠ 現在の問題: ローカル PC からは日本語TTS動作確認済み。しかしサーバー(keinafarm.net)のコンテナからリクエストすると日本語文字が Amazon 側で除去されて発話されない。原因は Amazon の IP ベースフィルタリング(海外IPからは日本語 textToSpeak を無視する模様)。調査継続中。
|
||||||
|
|
||||||
|
ファイル構成
|
||||||
|
ファイル 場所 役割 備考
|
||||||
|
server.js alexa-api/(リポジトリ) Express API サーバー本体 本番コード。変更したらビルド・再デプロイが必要
|
||||||
|
Dockerfile alexa-api/(リポジトリ) Docker イメージ定義 node:20-alpine ベース。server.js と package*.json をコピー
|
||||||
|
docker-compose.yml alexa-api/(リポジトリ) コンテナ起動設定 windmill_windmill-internal ネットワーク接続。外部ポート非公開
|
||||||
|
package.json / package-lock.json alexa-api/(リポジトリ) npm 依存関係 本番: express のみ。devDeps に alexa-remote2(不使用)
|
||||||
|
.env.example alexa-api/(リポジトリ) 環境変数テンプレート ALEXA_COOKIE=xxx の形式
|
||||||
|
.env alexa-api/(リポジトリ、.gitignore 対象) 実際の Cookie 保管 Git にコミットしない。ローカル作業後に scp でサーバーへ転送
|
||||||
|
auth4.js alexa-api/(リポジトリ) Amazon 認証・Cookie 取得スクリプト ローカルのみで実行(Windows PC)
|
||||||
|
auth.js / auth2.js / auth3.js alexa-api/(リポジトリ) auth4.js の旧バージョン 参考用。実際は auth4.js を使う
|
||||||
|
test_tts.js alexa-api/(リポジトリ) ローカルテスト用スクリプト 直接 alexa.amazon.co.jp を叩いて動作確認
|
||||||
|
サーバー上のファイル場所: /home/claude/alexa-api/(git リポジトリとは別にコピーして管理)
|
||||||
|
|
||||||
|
サーバーへのデプロイ手順
|
||||||
|
server.js や Dockerfile、package.json を変更した場合は以下の手順でサーバーに反映する。
|
||||||
|
|
||||||
|
Step 1: ローカルでファイルを編集
|
||||||
|
リポジトリ(c:\Users\akira\Develop\windmill_workflow\alexa-api\)でファイルを編集する。
|
||||||
|
|
||||||
|
Step 2: 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
|
||||||
|
|
||||||
|
Step 3: サーバーでビルドして再起動
|
||||||
|
⚠ 重要: 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
|
||||||
|
|
||||||
|
Step 4: 動作確認
|
||||||
|
# ヘルスチェック(Windmill ワーカーコンテナ内から) curl http://alexa_api:3500/health # ログ確認 sudo docker logs alexa_api -f # デバイス一覧確認 curl http://alexa_api:3500/devices
|
||||||
|
|
||||||
|
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 の再起動は不要(コンテナ再作成しないため)
|
||||||
|
|
||||||
|
Traefik 再起動が必要な理由
|
||||||
|
docker compose up -d はコンテナを「再作成」する(docker compose restart は既存コンテナを再起動するだけ)。コンテナが再作成されると Docker 内部の IP アドレスが変わり、Traefik のルーティングが古い IP を参照したまま 502/504 エラーになる。
|
||||||
|
|
||||||
|
対処: sudo docker restart traefik で Traefik に新しい IP を再検出させる。
|
||||||
|
|
||||||
|
この問題は Traefik の設定で watch: true にすれば自動解消できるが、現状はコンテナ再作成のたびに手動で Traefik を再起動する運用としている。
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
認証方法(auth4.js)
|
||||||
|
Amazon Japan OpenID フローを自前で実装。ローカル PC(Windows)でのみ実行する:
|
||||||
|
|
||||||
|
# ローカルPC の alexa-api ディレクトリで実行 cd alexa-api AMAZON_EMAIL="メールアドレス" AMAZON_PASSWORD="パスワード" node auth4.js
|
||||||
|
|
||||||
|
成功すると alexa-api/.env が生成または更新される。
|
||||||
|
|
||||||
|
ログインフローの概要:
|
||||||
|
|
||||||
|
GET https://www.amazon.co.jp/ap/signin?openid.assoc_handle=amzn_dp_project_dee_jp
|
||||||
|
hidden フィールド(anti-csrftoken-a2z, appActionToken, workflowState 等)を抽出
|
||||||
|
POST でメール/パスワードを送信
|
||||||
|
alexa.amazon.co.jp/api/apps/v1/token へのリダイレクトをたどる
|
||||||
|
取得した Cookie(at-acbjp, sess-at-acbjp, sst-acbjp, session-token 等)を .env に保存
|
||||||
|
TTS の仕組み(server.js)
|
||||||
|
alexa-remote2 は使わない直接 API 実装。Endpoints:
|
||||||
|
|
||||||
|
POST /speak — { device: "デバイス名 or serial", text: "しゃべる内容" }
|
||||||
|
GET /devices — デバイス一覧
|
||||||
|
GET /health — ヘルスチェック
|
||||||
|
内部の API 呼び出し順序:
|
||||||
|
|
||||||
|
GET /api/language → Set-Cookie: csrf=XXXXX を取得(毎リクエストごと)
|
||||||
|
GET /api/bootstrap → customerId を取得(キャッシュ: 永続)(A1AE8HXD8IJ61L)
|
||||||
|
GET /api/devices-v2/device → デバイス一覧(5分キャッシュ)
|
||||||
|
POST /api/behaviors/preview にシーケンス JSON を送信
|
||||||
|
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" }
|
||||||
|
|
||||||
|
ヘッダーに csrf: XXXXX と Cookie に csrf=XXXXX の両方が必要。Content-Length は Buffer.byteLength で計算(マルチバイト文字対応)。
|
||||||
|
|
||||||
|
⚠ locale パラメータについて(重要・未解決)
|
||||||
|
locale 値 ローカル PC から サーバー(keinafarm.net)から
|
||||||
|
""(空文字) ✅ 日本語・英語・漢字全て発話 ❌ 英語TTSになり日本語部分が発話されない
|
||||||
|
"ja-JP" ❌ 一瞬音が出るだけ(失敗) ❌ 日本語文字が Amazon 側で除去され英字のみ発話
|
||||||
|
現在 server.js では locale: "ja-JP" に設定している。
|
||||||
|
|
||||||
|
仮説: Amazon が海外IP(keinafarm.net = 非日本IP)からのリクエストを IP ベースでフィルタリングし、textToSpeak の日本語文字を除去している。Alexa.TextCommand は同じ問題がない(異なる API パス)。
|
||||||
|
|
||||||
|
確認済み事実: alexa_api の server.js ログには日本語テキストが正しく届いている。除去は Amazon サーバー側で発生。
|
||||||
|
|
||||||
|
次の調査候補:
|
||||||
|
|
||||||
|
SSML の <lang xml:lang="ja-JP"> タグで強制的に日本語 TTS を指定できるか
|
||||||
|
Alexa.TextCommand で「次を読み上げて:{text}」形式が使えるか
|
||||||
|
ローカルブリッジ方式(ユーザーのローカル PC で小さなプロキシサーバーを動かし、クラウドサーバーからローカル経由で alexa.amazon.co.jp を叩く)
|
||||||
|
デバイス一覧(Echo デバイスのみ)
|
||||||
|
名前 deviceType serialNumber
|
||||||
|
プレハブ A4ZXE0RM7LQ7A G0922H085165007R
|
||||||
|
リビングエコー1 ASQZWP4GPYUT7 G8M2DB08522600RL
|
||||||
|
リビングエコー2 ASQZWP4GPYUT7 G8M2DB08522503WF
|
||||||
|
オフィスの右エコー A4ZXE0RM7LQ7A G0922H08525302K5
|
||||||
|
オフィスの左エコー A4ZXE0RM7LQ7A G0922H08525302J9
|
||||||
|
寝室のエコー ASQZWP4GPYUT7 G8M2HN08534302XH
|
||||||
|
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(); }
|
||||||
|
|
||||||
|
device はデバイス名(日本語)またはシリアル番号で指定可能。Windmill ワーカーから http://alexa_api:3500 でアクセス(windmill_windmill-internal ネットワーク経由)。
|
||||||
|
|
||||||
|
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 再起動は不要(コンテナ再作成なし)
|
||||||
|
|
||||||
|
既知の問題・落とし穴
|
||||||
|
docker compose restart ≠ リビルド: server.js を変更しても restart ではコンテナ内のコードは古いまま。build + up -d が必要。
|
||||||
|
コンテナ再作成後は Traefik 再起動必須: up -d でコンテナ再作成すると Docker 内部 IP が変わり Traefik が 502/504 を返す。sudo docker restart traefik で解消。
|
||||||
|
alexa-remote2 は使えない: 取得した Cookie 文字列を受け付けない(内部で再認証しようとして失敗)。直接 API 実装が必要。
|
||||||
|
CSRF トークンは Cookie と ヘッダーの両方に必要: csrf ヘッダーだけ、または Cookie だけでは認証失敗。
|
||||||
|
operationPayload に customerId 必須: ないと 400 エラー。
|
||||||
|
レート制限: 短時間に連続リクエストすると HTTP 429 または 200 で音が出ない。通常の通知用途では問題なし。
|
||||||
|
git push がブロックされる: Gitea の pre-receive フック(remote: Gitea: User permission denied for writing)で push が失敗する。根本原因は未調査。ファイル転送は scp で行っている。
|
||||||
|
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
|
||||||
|
|
||||||
2129
docs/archive/alexa-tts/11_色々やってダメだった.txt
Normal file
2129
docs/archive/alexa-tts/11_色々やってダメだった.txt
Normal file
File diff suppressed because it is too large
Load Diff
163
docs/archive/alexa-tts/12_ローカルで試したこと.md
Normal file
163
docs/archive/alexa-tts/12_ローカルで試したこと.md
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
# Alexa 日本語 TTS 問題 試行記録
|
||||||
|
|
||||||
|
最終更新: 2026-03-03
|
||||||
|
担当: akira + AI (Antigravity)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 現在の問題
|
||||||
|
|
||||||
|
`/api/behaviors/preview` + `Alexa.Speak` を使って日本語テキストを TTSで発話させようとしているが、
|
||||||
|
**日本語Unicode文字だけが Amazon 側でフィルタリングされ、発話されない。**
|
||||||
|
ASCII文字(英語)は正常に発話される。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 環境
|
||||||
|
|
||||||
|
- テスト用スクリプト: `alexa-api/test_tts.js`(ローカルPCから直接 alexa.amazon.co.jp を叩く)
|
||||||
|
- 本番: `alexa-api/server.js`(VPS上のDockerコンテナ)
|
||||||
|
- テストデバイス: オフィスの右エコー (serial: G0922H08525302K5, type: A4ZXE0RM7LQ7A)
|
||||||
|
- Alexaアプリでデバイス言語設定: **日本語** に設定済み(確認済み)
|
||||||
|
- VPS IP: 162.43.33.56(大阪・Xserver Inc. = 日本国内 ✅ )
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 試行ログ(時系列)
|
||||||
|
|
||||||
|
### 【サーバー側での試行】(ChatGPT との会話ログより、2026-03-02〜03)
|
||||||
|
|
||||||
|
#### ❌ `speakType: 'ssml'` を `operationPayload` に追加
|
||||||
|
```json
|
||||||
|
"type": "Alexa.Speak",
|
||||||
|
"operationPayload": { ..., "speakType": "ssml" }
|
||||||
|
```
|
||||||
|
→ 変化なし。`Alexa.Speak` はSSML非対応のため無効。
|
||||||
|
|
||||||
|
#### ❌ `type: 'Alexa.SpeakSsml'` に変更 + `textToSpeak` にSSMLなし
|
||||||
|
```json
|
||||||
|
"type": "Alexa.SpeakSsml",
|
||||||
|
"operationPayload": { ..., "textToSpeak": text }
|
||||||
|
```
|
||||||
|
→ 英語も含めて完全無音(LEDも反応なし)。
|
||||||
|
|
||||||
|
#### ❌ `Alexa.SpeakSsml` + `textToSpeak: '<speak>'+text+'</speak>'`
|
||||||
|
→ 英語も無音。`Alexa.SpeakSsml` は `textToSpeak` ではなく別キーを要求する模様。
|
||||||
|
|
||||||
|
#### ❌ `Alexa.SpeakSsml` + `ssml: ssml`(キー名を変更)
|
||||||
|
→ 英語も発話せず。
|
||||||
|
|
||||||
|
**ChatGPTの最終見解:** `/api/behaviors/preview` では `Alexa.SpeakSsml` は動作しない(APIの癖)。`Alexa.Speak` に戻すしかない。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 【ローカルPCでの試行】(2026-03-03 午前)
|
||||||
|
|
||||||
|
#### ❌ `locale: 'ja-JP'` + 日本語テキスト(test_tts.js デフォルト)
|
||||||
|
```js
|
||||||
|
locale: 'ja-JP',
|
||||||
|
textToSpeak: 'テストです。聞こえますか'
|
||||||
|
```
|
||||||
|
→ 「エ」だけ発話(最初の「テ」の母音のみ)。
|
||||||
|
|
||||||
|
#### ✅ `locale: ''` + ASCII: `'hello'`
|
||||||
|
→ 「ハロー」と正常発話。英語は問題なし。
|
||||||
|
|
||||||
|
#### ❌ `locale: ''` + 日本語: `'テストです。聞こえますか'`
|
||||||
|
→ 「エ」のみ。デバイス言語が英語設定ならこの動作になるが、日本語設定確認済みのため別原因。
|
||||||
|
|
||||||
|
#### ❌ `locale: 'ja-JP'` + 日本語: `'テストです。これは日本語のテストです'`
|
||||||
|
→ 「えんえ」のような音のみ(断片的な音)。
|
||||||
|
|
||||||
|
#### ❌ `locale: 'ja-JP'` + ひらがな: `'あいうえお'`
|
||||||
|
→ 無音(LEDは点滅 = 通知は届いている)。
|
||||||
|
|
||||||
|
#### 🔍 `locale: 'ja-JP'` + 混在: `'あいうえおThis is Testあいうえお'`
|
||||||
|
→ 「ディスイズテスタ」のみ発話。
|
||||||
|
**重要: 日本語部分は無音、ASCII部分のみ日本語アクセントで読まれる。**
|
||||||
|
→ Amazon側で日本語Unicodeを除去している証拠。
|
||||||
|
|
||||||
|
#### ❌ `locale: 'ja-JP'` + Unicodeエスケープ: `'\u3053\u308c\u306f\u30c6\u30b9\u30c8\u3067\u3059'`
|
||||||
|
→ 無音。ファイルエンコード問題ではない(Unicodeエスケープ = `これはテストです` と同一)。
|
||||||
|
**→ 文字コードの問題ではないことが確定。**
|
||||||
|
|
||||||
|
#### ❌ `type: 'AlexaAnnouncement'` + locale:`'ja-JP'` + content[].speak構造
|
||||||
|
```json
|
||||||
|
"type": "AlexaAnnouncement",
|
||||||
|
"operationPayload": {
|
||||||
|
"content": [{ "locale": "ja-JP", "speak": { "type": "text", "value": "日本語のテストです" } }],
|
||||||
|
"target": { "devices": [...] }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
→ 「えんえせんと」("AlexaAnnouncement" を日本語発音で読んだもの)。
|
||||||
|
コンテンツではなくノード型名が読まれた → このノードタイプは別用途。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 確定した事実
|
||||||
|
|
||||||
|
| 事実 | 根拠 |
|
||||||
|
|------|------|
|
||||||
|
| 通知自体は届いている | LEDが点滅する |
|
||||||
|
| 英語ASCIIは正常発話 | "hello" → 「ハロー」、"This is Test" → 「ディスイズテスタ」 |
|
||||||
|
| 日本語Unicodeのみ除去される | 混在テキストで確認。Unicodeエスケープでも同じ |
|
||||||
|
| デバイス言語設定は日本語 | Alexaアプリで確認済み |
|
||||||
|
| サーバーIPは日本(大阪) | ipinfo.io で確認: Xserver Inc., JP |
|
||||||
|
| 文字コードは問題なし | Unicodeエスケープテストで確定 |
|
||||||
|
| `Alexa.SpeakSsml` 系は全て失敗 | 英語含め無音 |
|
||||||
|
| `AlexaAnnouncement` は別用途 | ノード型名が読まれた |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 仮説(現在)
|
||||||
|
|
||||||
|
Amazon の `/api/behaviors/preview` エンドポイントが、
|
||||||
|
何らかの理由で `textToSpeak` 内の日本語Unicodeを除去している。
|
||||||
|
|
||||||
|
考えられる原因:
|
||||||
|
1. **セッション/Cookie が古くなりJapanese TTS権限が変わった**(Cookie の再生成で解消する可能性)
|
||||||
|
2. **Amazonが API の挙動を変更した**(非公開APIのためいつでも変更しうる)
|
||||||
|
3. **別のAPIエンドポイントが必要**(未探索のルートがある可能性)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 試行ログ続き(2026-03-03 午後)
|
||||||
|
|
||||||
|
#### Cookie 新規取得(auth4.js 再実行)
|
||||||
|
→ 変化なし。Cookie は原因ではなかった。
|
||||||
|
|
||||||
|
#### ❌ `AlexaAnnouncement` ノードタイプ
|
||||||
|
→ 「えんえせんと」("AlexaAnnouncement" を日本語で読んだ)。コンテンツではなくノード名が読まれた。別用途のノード。
|
||||||
|
|
||||||
|
#### ✅ **解決!** `sequenceJson` の non-ASCII を `\uXXXX` エスケープに変換
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
var rawSequenceJson = JSON.stringify(sequenceObj).replace(
|
||||||
|
/[\u0080-\uffff]/g,
|
||||||
|
function(c) { return '\\u' + ('0000' + c.charCodeAt(0).toString(16)).slice(-4); }
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
→ 「これは日本語のテストです」が完璧に発話された!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 解決済み(2026-03-03)
|
||||||
|
|
||||||
|
**根本原因**: `sequenceJson` 内の日本語文字を raw UTF-8 のまま Amazon に送ると、Amazon 側のパーサーがそれをフィルタリングして無視する。
|
||||||
|
|
||||||
|
**解決策**: `JSON.stringify(sequenceObj)` 後に non-ASCII 文字(`\u0080` 以上)を `\uXXXX` 形式のJSONエスケープシーケンスに変換してから `sequenceJson` として送る。
|
||||||
|
|
||||||
|
**修正箇所**: `alexa-api/server.js` と `alexa-api/test_tts.js`
|
||||||
|
|
||||||
|
**確定したパラメータ**:
|
||||||
|
- `type: 'Alexa.Speak'`
|
||||||
|
- `locale: 'ja-JP'`
|
||||||
|
- `textToSpeak: <日本語テキスト>`
|
||||||
|
- `sequenceJson` は non-ASCII を `\uXXXX` エスケープして送る
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 参考
|
||||||
|
|
||||||
|
- 実装記録: `docs/alexa-api/10_Alexa TTS API 実装記録 (2026-03-02).md`
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# 引き継ぎ - `u/admin/alexa_speak` API反映後にUIドロップダウンが変わらない件
|
||||||
|
|
||||||
|
> **作成日**: 2026-03-04
|
||||||
|
> **対象**: `windmill.keinafarm.net` / workspace `admins`
|
||||||
|
> **対象スクリプト**: `u/admin/alexa_speak`
|
||||||
|
> **目的**: 別端末から同じ事象に遭遇しても、原因切り分けと復旧をすぐ実施できるようにする
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 事象の概要
|
||||||
|
|
||||||
|
`u/admin/alexa_speak` を Windmill API(`create-script`)で更新した直後、
|
||||||
|
|
||||||
|
- Scriptタブ上のコードは更新済み
|
||||||
|
- `schema` 上も `device` が `dynselect-device`
|
||||||
|
- しかし Inputフォームは `Device` がテキスト入力のまま(ドロップダウンにならない)
|
||||||
|
|
||||||
|
という状態になった。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 当日の時系列(要点)
|
||||||
|
|
||||||
|
1. 既存スクリプトを取得し、ローカル `scripts/alexa_speak.ts`(Dynamic Select実装あり)をAPIで反映
|
||||||
|
2. サーバー再取得で `content` 一致を確認(更新自体は成功)
|
||||||
|
3. UIを開くと `Device` が入力欄のままで、ドロップダウン化されていない
|
||||||
|
4. `schema.device` を `format: dynselect-device`, `originalType: DynSelect_device` に更新して再反映
|
||||||
|
5. それでも UI は直ちには変わらず
|
||||||
|
6. Windmill UIで `Edit` に入り、`Deploy` を1回実施
|
||||||
|
7. 直後にドロップダウン表示へ反映
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 確認できた事実
|
||||||
|
|
||||||
|
- API反映は成功している(hash更新)
|
||||||
|
- 中間: `a6010687183a199d`
|
||||||
|
- 最終: `318d78f45a084e32`
|
||||||
|
- 最終状態では以下がAPIで確認済み
|
||||||
|
- `schema.properties.device.format = "dynselect-device"`
|
||||||
|
- `schema.properties.device.originalType = "DynSelect_device"`
|
||||||
|
- UI反映は API反映だけでは即時にならず、`Edit -> Deploy` 後に反映された
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 想定される原因
|
||||||
|
|
||||||
|
Windmill CE 側で、API経由更新時にフォームUIメタ情報(入力ウィジェット解決)の再計算または再適用が即時反映されないケースがある。
|
||||||
|
|
||||||
|
実務上は「API更新後にUIで1回Deploy」が回避策として有効。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 再現時の標準対応手順(Runbook)
|
||||||
|
|
||||||
|
### 5.1 APIでスクリプト更新
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/akira/develop/windmill_workflow
|
||||||
|
./wm-api.sh get-script u/admin/alexa_speak > /tmp/remote_alexa_speak.json
|
||||||
|
# parent_hash を含む payload を作成して create-script
|
||||||
|
./wm-api.sh create-script /tmp/alexa_speak_push.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 APIで反映確認
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./wm-api.sh get-script u/admin/alexa_speak
|
||||||
|
```
|
||||||
|
|
||||||
|
確認ポイント:
|
||||||
|
|
||||||
|
- `hash` が更新されている
|
||||||
|
- `content` が想定コードになっている
|
||||||
|
- `schema.properties.device.format` が `dynselect-device`
|
||||||
|
- `schema.properties.device.originalType` が `DynSelect_device`
|
||||||
|
|
||||||
|
### 5.3 UI反映されない場合
|
||||||
|
|
||||||
|
1. `u/admin/alexa_speak` を最新リビジョンで開く
|
||||||
|
2. ハードリロード(`Ctrl + Shift + R`)
|
||||||
|
3. 変化がなければ `Edit -> Deploy` を1回実施
|
||||||
|
4. Inputフォームの `Device` がドロップダウン化されたことを確認
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 補足(今回の最終状態)
|
||||||
|
|
||||||
|
- スクリプト: `u/admin/alexa_speak`
|
||||||
|
- 期待UI:
|
||||||
|
- `Device`: ドロップダウン(dynselect)
|
||||||
|
- `Text`: テキスト入力
|
||||||
|
- 前提:
|
||||||
|
- `alexa_api` コンテナが稼働
|
||||||
|
- `http://alexa_api:3500/devices` が取得可能
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 引き継ぎメモ
|
||||||
|
|
||||||
|
- 「API反映成功」と「UIフォーム反映成功」は同時とは限らない
|
||||||
|
- 引き継ぎ時は、必ず以下をセットで確認する
|
||||||
|
1. APIレスポンスの `hash` と `schema`
|
||||||
|
2. UI表示(必要なら `Edit -> Deploy`)
|
||||||
|
|
||||||
24
docs/archive/alexa-tts/README.md
Normal file
24
docs/archive/alexa-tts/README.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Alexa TTS API 関連アーカイブ
|
||||||
|
|
||||||
|
最終統合ドキュメントは以下:
|
||||||
|
|
||||||
|
- `docs/30_マスタードキュメント_Alexa_TTS_API編.md`
|
||||||
|
|
||||||
|
途中経過・検証メモ(本アーカイブ内コピー):
|
||||||
|
|
||||||
|
- `docs/archive/alexa-tts/10_Alexa TTS API 実装記録 (2026-03-02).md`
|
||||||
|
- `docs/archive/alexa-tts/11_色々やってダメだった.txt`
|
||||||
|
- `docs/archive/alexa-tts/12_ローカルで試したこと.md`
|
||||||
|
- `docs/archive/alexa-tts/21_引き継ぎ_alexa_speak_API反映後にUIドロップダウンが変わらない件.md`
|
||||||
|
|
||||||
|
原本の参照先:
|
||||||
|
|
||||||
|
- `docs/alexa-api/10_Alexa TTS API 実装記録 (2026-03-02).md`
|
||||||
|
- `docs/alexa-api/11_色々やってダメだった.txt`
|
||||||
|
- `docs/alexa-api/12_ローカルで試したこと.md`
|
||||||
|
- `docs/flow-manage/11_引き継ぎ_alexa_speak_API反映後にUIドロップダウンが変わらない件.md`
|
||||||
|
|
||||||
|
補足:
|
||||||
|
|
||||||
|
- `docs/flow-manage/10_マスタードキュメント_Windmillフロー管理_API一本化編.md` は
|
||||||
|
Alexa TTS 単体ではなく、Windmill API運用全体の親ドキュメントとして維持する。
|
||||||
362
docs/flow-manage/10_マスタードキュメント_Windmillフロー管理_API一本化編.md
Normal file
362
docs/flow-manage/10_マスタードキュメント_Windmillフロー管理_API一本化編.md
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
# マスタードキュメント - Windmillフロー管理 API一本化編
|
||||||
|
|
||||||
|
> **最終更新**: 2026-03-03
|
||||||
|
> **対象**: `windmill.keinafarm.net` / workspace `admins`
|
||||||
|
> **目的**: ローカルGitとサーバーGitの衝突を避けつつ、Windmill APIを唯一の運用経路に統一する
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 目次
|
||||||
|
|
||||||
|
1. [この文書の役割](#1-この文書の役割)
|
||||||
|
2. [運用方針(結論)](#2-運用方針結論)
|
||||||
|
3. [現状の課題と解決方針](#3-現状の課題と解決方針)
|
||||||
|
4. [管理対象と正本の定義](#4-管理対象と正本の定義)
|
||||||
|
5. [同期・反映の仕様](#5-同期反映の仕様)
|
||||||
|
6. [競合時の動作仕様](#6-競合時の動作仕様)
|
||||||
|
7. [実装計画](#7-実装計画)
|
||||||
|
8. [標準運用手順(Runbook)](#8-標準運用手順runbook)
|
||||||
|
9. [セキュリティ・監査方針](#9-セキュリティ監査方針)
|
||||||
|
10. [障害前提の復旧設計(必須)](#10-障害前提の復旧設計必須)
|
||||||
|
11. [Windmill依存を薄くする方針(必須)](#11-windmill依存を薄くする方針必須)
|
||||||
|
12. [受け入れ条件](#12-受け入れ条件)
|
||||||
|
13. [既知の注意点](#13-既知の注意点)
|
||||||
|
14. [更新履歴](#14-更新履歴)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. この文書の役割
|
||||||
|
|
||||||
|
この文書は、次回セッション開始時にこれだけ読めば作業を継続できることを目的とした、**運用仕様 + 実装計画の単一ソース**である。
|
||||||
|
|
||||||
|
- 暗黙知を残さない
|
||||||
|
- 方針・手順・失敗時の扱いを固定化する
|
||||||
|
- API経由運用に必要な実装タスクを明文化する
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 運用方針(結論)
|
||||||
|
|
||||||
|
### 採用する方式: API一本化(Server First)
|
||||||
|
|
||||||
|
1. ローカルリポジトリは、サーバー側Gitをリモートにしない
|
||||||
|
2. Windmillの実体変更は **Windmill REST API 経由のみ**
|
||||||
|
3. 作業開始時にAPIでサーバー状態を取り込み、サーバーが新しければローカルへ同期
|
||||||
|
4. **運用単位は「workflow package(flow + schedules)」を基本**とする
|
||||||
|
5. ローカル変更はAPIでサーバーへ反映
|
||||||
|
6. サーバー側の Git Sync Workflow は定期記録用途として継続
|
||||||
|
7. ローカルGitコミットは手動(またはAI)で実施し、監査/履歴用途として扱う
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 現状の課題と解決方針
|
||||||
|
|
||||||
|
### 課題
|
||||||
|
|
||||||
|
- Windmill側の自動Git記録WFと、ローカルGit運用が同一系統を触ると競合が発生する
|
||||||
|
- `wmill` CLIは制約があり使いにくく、運用が不安定
|
||||||
|
- サーバーとローカルのどちらが最新か判定が曖昧
|
||||||
|
|
||||||
|
### 解決
|
||||||
|
|
||||||
|
- 更新経路をWindmill APIに統一し、競合面を縮小する
|
||||||
|
- 「正本=Windmillサーバー」の原則を固定する
|
||||||
|
- `hash` / `parent_hash` を使った衝突検知を標準化する
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 管理対象と正本の定義
|
||||||
|
|
||||||
|
### 正本
|
||||||
|
|
||||||
|
- **Windmillサーバー上のオブジェクト**
|
||||||
|
- scripts (`/w/{workspace}/scripts/*`)
|
||||||
|
- flows (`/w/{workspace}/flows/*`)
|
||||||
|
- schedules (`/w/{workspace}/schedules/*`)
|
||||||
|
|
||||||
|
### ローカルの位置づけ
|
||||||
|
|
||||||
|
- ローカルファイルは「編集用ワークツリー + 監査ログ」
|
||||||
|
- ローカルGitはサーバー同期の必須経路ではない
|
||||||
|
|
||||||
|
### 管理単位(重要)
|
||||||
|
|
||||||
|
- 単体オブジェクト運用(scriptだけ、flowだけ)は補助用途
|
||||||
|
- 標準運用は **workflow package 単位** とする
|
||||||
|
- `flow` 本体
|
||||||
|
- その `flow path` に紐づく `schedules`(`is_flow=true` かつ `script_path == flow.path`)
|
||||||
|
|
||||||
|
### 既存の主要オブジェクト(例)
|
||||||
|
|
||||||
|
- script: `u/admin/alexa_speak`
|
||||||
|
- flows: `f/app_custom/system_heartbeat`, `f/shiraou/shiraou_notification` など
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 同期・反映の仕様
|
||||||
|
|
||||||
|
## 5.1 Pull(サーバー -> ローカル)
|
||||||
|
|
||||||
|
### 目的
|
||||||
|
|
||||||
|
- 作業前にサーバー最新状態をローカルへ取り込む
|
||||||
|
|
||||||
|
### 判定ルール
|
||||||
|
|
||||||
|
- サーバー `updated_at` / `hash` とローカル管理メタ情報を比較
|
||||||
|
- サーバーが新しければローカルを更新
|
||||||
|
|
||||||
|
### 対象API(オブジェクト単体)
|
||||||
|
|
||||||
|
- `GET /api/w/{workspace}/scripts/get/p/{path}`
|
||||||
|
- `GET /api/w/{workspace}/flows/get/{path}`
|
||||||
|
- `GET /api/w/{workspace}/schedules/get/{path}`
|
||||||
|
|
||||||
|
### workflow package Pull(標準)
|
||||||
|
|
||||||
|
1. `GET /flows/get/{flow_path}` で flow 本体取得
|
||||||
|
2. `GET /schedules/list` で schedule 一覧取得
|
||||||
|
3. `script_path == flow_path` の schedule 群を抽出
|
||||||
|
4. ローカルへ一括保存(flow + schedules)
|
||||||
|
|
||||||
|
## 5.2 Push(ローカル -> サーバー)
|
||||||
|
|
||||||
|
### 目的
|
||||||
|
|
||||||
|
- ローカル変更をサーバーへ安全反映
|
||||||
|
|
||||||
|
### スクリプト反映API(標準)
|
||||||
|
|
||||||
|
- `POST /api/w/{workspace}/scripts/create`
|
||||||
|
|
||||||
|
必須パラメータ(最小):
|
||||||
|
|
||||||
|
- `path`
|
||||||
|
- `parent_hash`(直前取得したサーバーhash)
|
||||||
|
- `summary`
|
||||||
|
- `description`
|
||||||
|
- `content`
|
||||||
|
- `schema`
|
||||||
|
- `language`(TypeScriptは `bun`)
|
||||||
|
- `kind`(通常 `script`)
|
||||||
|
- `lock`(既存値継承)
|
||||||
|
|
||||||
|
### フロー反映API
|
||||||
|
|
||||||
|
- CE制約により `PUT` 更新が不可/不安定な場合があるため、原則:
|
||||||
|
1. `DELETE /flows/delete/{path}`
|
||||||
|
2. `POST /flows/create`
|
||||||
|
|
||||||
|
### schedule反映API(workflow package の一部)
|
||||||
|
|
||||||
|
- workflow package Push時は、対象 flow に紐づく schedule も同時同期する
|
||||||
|
- 原則:
|
||||||
|
1. サーバー現行 schedule(`script_path == flow_path`)一覧取得
|
||||||
|
2. ローカル定義との差分計算(追加・更新・削除)
|
||||||
|
3. `DELETE /schedules/delete/{path}` と `POST /schedules/create` で収束
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 競合時の動作仕様
|
||||||
|
|
||||||
|
### スクリプトPush競合
|
||||||
|
|
||||||
|
- `parent_hash` 不一致時は更新拒否(期待動作)
|
||||||
|
- 対応:
|
||||||
|
1. 最新をPull
|
||||||
|
2. 差分を再適用
|
||||||
|
3. 再Push
|
||||||
|
|
||||||
|
### フローPush競合
|
||||||
|
|
||||||
|
- 削除再作成前に最新を必ずPullしてローカル保存
|
||||||
|
- 競合が疑われる場合は自動実行せず手動確認にフォールバック
|
||||||
|
- **Preflight必須**: Push直前に `remote_index` の既知hashとサーバー現在hashを比較し、不一致ならPush中断(fail closed)
|
||||||
|
- Push後は `post-verify`(再取得して期待JSON一致確認)を必須化
|
||||||
|
|
||||||
|
### schedulePush競合
|
||||||
|
|
||||||
|
- `schedule.path` 重複や同名上書きに注意
|
||||||
|
- workflow package Push時は、対象 flow の schedule 群をまとめて同期し、中途半端な状態を残さない
|
||||||
|
- 失敗時は flow と schedules の両方を再取得して整合を確認
|
||||||
|
- schedule 同期も Preflight/`post-verify` の対象に含める
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 実装計画
|
||||||
|
|
||||||
|
## Phase 0: 文書固定(完了)
|
||||||
|
|
||||||
|
- 本ドキュメント作成
|
||||||
|
|
||||||
|
## Phase 1: API運用コマンド整備(完了)
|
||||||
|
|
||||||
|
`wm-api.sh` へ追加:
|
||||||
|
|
||||||
|
1. `pull-script <path> <outfile>`
|
||||||
|
2. `push-script <path> <infile>`
|
||||||
|
3. `pull-flow <path> <outfile>`
|
||||||
|
4. `push-flow <json-file>`
|
||||||
|
5. `pull-all`(scripts/flowsの一覧取得 + 一括保存)
|
||||||
|
6. `status-remote`(ローカルとサーバーのhash比較)
|
||||||
|
|
||||||
|
## Phase 2: workflow package 対応(次タスク)
|
||||||
|
|
||||||
|
`wm-api.sh` へ追加:
|
||||||
|
|
||||||
|
1. `pull-workflow <flow_path>`(flow + schedules 一括取得)
|
||||||
|
2. `push-workflow <workflow-dir>`(flow反映 + schedules差分同期)
|
||||||
|
3. `status-workflow [flow_path]`(workflow単位の差分表示)
|
||||||
|
4. `pull-all-workflows`(flow全件を workflow package で取得)
|
||||||
|
|
||||||
|
## Phase 3: メタ情報管理
|
||||||
|
|
||||||
|
- `state/remote_index.json` を拡張し、`scripts/flows/schedules` を保持
|
||||||
|
- `state/workflow_index.json` を導入し、`workflow -> flow_hash + schedule_hashes` を保持
|
||||||
|
|
||||||
|
## Phase 4: 標準化
|
||||||
|
|
||||||
|
- `docs/flow-manage` に操作例を固定
|
||||||
|
- 「作業開始時は必ずpull」を運用ルール化
|
||||||
|
|
||||||
|
## Phase 5: 半自動化(任意)
|
||||||
|
|
||||||
|
- AI/スクリプトで
|
||||||
|
- 変更検知
|
||||||
|
- Pull提案
|
||||||
|
- Push時の競合自動リカバリ
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 標準運用手順(Runbook)
|
||||||
|
|
||||||
|
### 8.1 日常の更新(推奨)
|
||||||
|
|
||||||
|
1. `pull-all` でサーバー最新を取得
|
||||||
|
2. ローカル編集
|
||||||
|
3. workflow修正時は `push-workflow` で反映(flow + schedules)
|
||||||
|
4. script単体修正時のみ `push-script` を使う
|
||||||
|
5. 必要に応じてローカルGitにコミット
|
||||||
|
|
||||||
|
### 8.2 サーバーで変更されたものを取り込む
|
||||||
|
|
||||||
|
1. `status-remote` 実行
|
||||||
|
2. workflow単位の変更は `status-workflow` / `pull-workflow` で取得
|
||||||
|
3. 単体変更は `pull-*` で取得
|
||||||
|
4. ローカル履歴としてコミット(任意)
|
||||||
|
|
||||||
|
### 8.3 `alexa_speak` 更新例(現在の具体例)
|
||||||
|
|
||||||
|
1. `pull-script u/admin/alexa_speak scripts/alexa_speak.ts`
|
||||||
|
2. `scripts/alexa_speak.ts` を編集
|
||||||
|
3. `push-script u/admin/alexa_speak scripts/alexa_speak.ts`
|
||||||
|
4. Windmill UIで動作確認(`device` がドロップダウン表示)
|
||||||
|
5. API反映後にUIが変わらない場合は `Edit -> Deploy` を1回実行して再確認
|
||||||
|
|
||||||
|
関連引き継ぎ文書:
|
||||||
|
|
||||||
|
- `docs/flow-manage/11_引き継ぎ_alexa_speak_API反映後にUIドロップダウンが変わらない件.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. セキュリティ・監査方針
|
||||||
|
|
||||||
|
- APIトークンは環境変数またはローカル限定設定で管理
|
||||||
|
- リポジトリへ平文トークンをコミットしない
|
||||||
|
- 反映時刻・対象path・実行者をログ化
|
||||||
|
- サーバーGit Syncは監査証跡として維持
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 障害前提の復旧設計(必須)
|
||||||
|
|
||||||
|
サーバー内部障害、API非冪等、通信断は防止不能とみなし、**復旧可能性を担保する**。
|
||||||
|
|
||||||
|
### 10.1 復旧の基本原則
|
||||||
|
|
||||||
|
1. Push前に対象オブジェクトをバックアップ保存(必須)
|
||||||
|
2. Push後に `post-verify`(再取得して期待値比較)を必須化
|
||||||
|
3. Push直前に `preflight hash check` を必須化し、不一致時はPush停止(fail closed)
|
||||||
|
4. 失敗時は「再実行」ではなく「現状確認 -> 復旧」の順で実施
|
||||||
|
|
||||||
|
### 10.2 標準復旧手順
|
||||||
|
|
||||||
|
1. `get` で現状確認(存在、content/value、version/hash)
|
||||||
|
2. 期待状態との差分を判定
|
||||||
|
3. 必要ならバックアップから復元
|
||||||
|
4. 復元後に `post-verify`
|
||||||
|
5. 復旧ログを記録(日時、path、操作者、原因、処置)
|
||||||
|
|
||||||
|
### 10.3 フロー欠落時の緊急復旧
|
||||||
|
|
||||||
|
`delete -> create` の途中失敗でフローが消失した場合:
|
||||||
|
|
||||||
|
1. 直前バックアップJSONで即時 `flows/create`
|
||||||
|
2. 依存スケジュールの有効性を確認
|
||||||
|
3. 関連ジョブの手動実行で動作確認
|
||||||
|
|
||||||
|
### 10.4 delete -> create 運用ガード(必須)
|
||||||
|
|
||||||
|
1. `push-flow` / `push-workflow` 前に対象flowのバックアップJSONを必ず保存
|
||||||
|
2. Preflightで hash 不一致なら自動Pushしない(手動レビューへフォールバック)
|
||||||
|
3. Push後に flow と schedules を再取得し、ローカル期待値と一致確認
|
||||||
|
4. 不一致時は即時にバックアップから復元し、復旧ログを残す
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Windmill依存を薄くする方針(必須)
|
||||||
|
|
||||||
|
Windmill API依存は避けられないため、依存点を最小化する。
|
||||||
|
|
||||||
|
### 11.1 使用APIの固定
|
||||||
|
|
||||||
|
- `scripts`: `list/get/create`
|
||||||
|
- `flows`: `list/get/create/delete`
|
||||||
|
- `schedules`: `list/get/create/delete`(workflow package 同期で常用)
|
||||||
|
|
||||||
|
上記以外のAPIは原則使わない。
|
||||||
|
|
||||||
|
### 11.2 判定ロジックの優先順位
|
||||||
|
|
||||||
|
1. 実体JSON比較(content/valueの比較)を主判定
|
||||||
|
2. `hash` / `version_id` / `edited_at` は補助判定
|
||||||
|
3. 補助フィールド欠落時も動作継続できる実装にする
|
||||||
|
|
||||||
|
### 11.3 仕様変更検知
|
||||||
|
|
||||||
|
- 定期的に `smoke test` を実行してレスポンス形を検証
|
||||||
|
- 期待フィールド欠落時はPush停止(fail closed)
|
||||||
|
- 仕様差分を本ドキュメントの更新履歴に反映
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 受け入れ条件
|
||||||
|
|
||||||
|
以下を満たせば「このプロジェクトはサーバーのワークフローを管理するためのもの」と言える状態:
|
||||||
|
|
||||||
|
1. ローカルから workflow package(flow + schedules)の Pull/Push がAPIで完結
|
||||||
|
2. サーバー更新が workflow 単位でローカル検知できる
|
||||||
|
3. 競合時の復旧手順が flow/schedule 両方で定義済み
|
||||||
|
4. 運用手順がこの文書だけで再現可能
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 既知の注意点
|
||||||
|
|
||||||
|
1. `wm-api.sh` は `bash` 前提。WindowsのCRLF混入で shebang 実行失敗し得るため、`.gitattributes` で `*.sh text eol=lf` を固定し、実行は `bash wm-api.sh ...` を標準とする
|
||||||
|
2. フロー更新は環境により `PUT` できないため削除再作成を標準とする
|
||||||
|
3. 削除再作成は `version_id/hash/edited_at` を更新するため、Preflight hash check がないと競合上書きを見落とす可能性がある
|
||||||
|
4. 1つのflowに複数scheduleが紐づくことがあるため、`script_path` ベースで束ねて管理する
|
||||||
|
5. API応答仕様はWindmillバージョン差で微差が出るため、初回導入時は `get` のレスポンス形を確認する
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. 更新履歴
|
||||||
|
|
||||||
|
| 日付 | 変更内容 |
|
||||||
|
|------|----------|
|
||||||
|
| 2026-03-03 | 初版作成(API一本化方針、同期仕様、実装計画、Runbookを定義) |
|
||||||
|
| 2026-03-03 | 障害前提の復旧設計、Windmill依存を薄くする方針を必須要件として追記 |
|
||||||
|
| 2026-03-03 | 運用単位を workflow package(flow + schedules)へ変更し、実装計画とRunbookを更新 |
|
||||||
|
| 2026-03-03 | `delete -> create` と hash 管理の運用ガード(preflight / fail closed / post-verify)およびCRLF対策を追記 |
|
||||||
|
| 2026-03-04 | `u/admin/alexa_speak` のAPI反映後にUIドロップダウンが即時反映されない事象と運用回避策(`Edit -> Deploy`)を追記 |
|
||||||
@@ -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キーは定期的にローテーションすること(変更時は統合システム側の環境変数も同時に更新)
|
||||||
|
|||||||
24
flows/git_sync.flow.json
Normal file
24
flows/git_sync.flow.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"path": "u/antigravity/git_sync",
|
||||||
|
"summary": "Git Sync Workflow",
|
||||||
|
"description": "Automatically sync Windmill workflows to Git repository (sync branch)",
|
||||||
|
"value": {
|
||||||
|
"modules": [
|
||||||
|
{
|
||||||
|
"id": "a",
|
||||||
|
"value": {
|
||||||
|
"lock": "",
|
||||||
|
"type": "rawscript",
|
||||||
|
"content": "#!/bin/bash\nset -e\nexport PATH=/usr/bin:/usr/local/bin:/usr/sbin:/sbin:/bin:$PATH\n\nGREEN=\"\\033[0;32m\"\nYELLOW=\"\\033[1;33m\"\nRED=\"\\033[0;31m\"\nNC=\"\\033[0m\"\n\necho -e \"${GREEN}=== Windmill Workflow Git Sync ===${NC}\"\n\nREPO_ROOT=\"/workspace\"\nWMILL_DIR=\"${REPO_ROOT}/workflows\"\n\nif ! command -v wmill &> /dev/null; then\n echo -e \"${YELLOW}Installing windmill-cli...${NC}\"\n npm install -g windmill-cli\n export PATH=$(npm prefix -g)/bin:$PATH\nfi\n\ngit config --global --add safe.directory \"$REPO_ROOT\"\ngit config --global user.email \"bot@keinafarm.net\"\ngit config --global user.name \"Windmill Bot\"\n\n# sync ブランチを使用\nCURRENT_BRANCH=$(git -C \"$REPO_ROOT\" rev-parse --abbrev-ref HEAD)\nif [ \"$CURRENT_BRANCH\" != \"sync\" ]; then\n echo -e \"${YELLOW}Switching to sync branch...${NC}\"\n git -C \"$REPO_ROOT\" fetch origin sync\n git -C \"$REPO_ROOT\" checkout sync\nfi\n\necho -e \"${YELLOW}Pulling from origin/sync...${NC}\"\ngit -C \"$REPO_ROOT\" pull --rebase origin sync || {\n echo -e \"${RED}Failed to pull from remote. Continuing...${NC}\"\n}\n\necho -e \"${YELLOW}Pulling from Windmill...${NC}\"\ncd \"$WMILL_DIR\"\nwmill sync pull --config-dir /workspace/wmill_config --skip-variables --skip-secrets --skip-resources --yes || exit 1\n\ncd \"$REPO_ROOT\"\nif [[ -n $(git status --porcelain) ]]; then\n echo -e \"${YELLOW}Changes detected, committing to Git...${NC}\"\n git add -A\n TIMESTAMP=$(date \"+%Y-%m-%d %H:%M:%S\")\n git commit -m \"Auto-sync: ${TIMESTAMP}\"\n echo -e \"${YELLOW}Pushing to Gitea (sync branch)...${NC}\"\n git push origin sync || {\n echo -e \"${RED}Failed to push.${NC}\"\n exit 1\n }\n echo -e \"${GREEN}Changes pushed to Gitea (sync branch)${NC}\"\nelse\n echo -e \"${GREEN}No changes detected${NC}\"\nfi\n\necho -e \"${GREEN}=== Sync Complete ===${NC}\"\n",
|
||||||
|
"language": "bash",
|
||||||
|
"input_transforms": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {},
|
||||||
|
"required": []
|
||||||
|
}
|
||||||
|
}
|
||||||
48
flows/hourly_chime.flow.json
Normal file
48
flows/hourly_chime.flow.json
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"path": "u/akiracraftwork/hourly_chime",
|
||||||
|
"summary": "鳩時計機能",
|
||||||
|
"description": "毎正時にAlexaで時刻を読み上げる。失敗時はLINEで通知。",
|
||||||
|
"value": {
|
||||||
|
"modules": [
|
||||||
|
{
|
||||||
|
"id": "a",
|
||||||
|
"value": {
|
||||||
|
"lock": "{\n \"dependencies\": {}\n}\n//bun.lock\n<empty>",
|
||||||
|
"type": "rawscript",
|
||||||
|
"content": "export async function main(\n device: string = \"オフィスの右エコー\",\n prefix: string = \"現在時刻は\",\n suffix: string = \"です\"\n) {\n const now = new Date();\n const hhmm = new Intl.DateTimeFormat(\"ja-JP\", {\n timeZone: \"Asia/Tokyo\",\n hour: \"2-digit\",\n minute: \"2-digit\",\n hour12: false,\n }).format(now); // 例: 09:30\n\n const [h, m] = hhmm.split(\":\");\n const text = `${prefix}${Number(h)}時${Number(m)}分${suffix}`;\n\n const res = await fetch(\"http://alexa_api:3500/speak\", {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ device, text }),\n });\n\n if (!res.ok) {\n const body = await res.text();\n throw new Error(`alexa-api error ${res.status}: ${body}`);\n }\n\n return { ok: true, device, text };\n}\n",
|
||||||
|
"language": "bun",
|
||||||
|
"input_transforms": {
|
||||||
|
"device": {
|
||||||
|
"type": "static",
|
||||||
|
"value": "オフィスの右エコー"
|
||||||
|
},
|
||||||
|
"prefix": {
|
||||||
|
"type": "static",
|
||||||
|
"value": "現在時刻は"
|
||||||
|
},
|
||||||
|
"suffix": {
|
||||||
|
"type": "static",
|
||||||
|
"value": "です"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"failure_module": {
|
||||||
|
"id": "failure",
|
||||||
|
"value": {
|
||||||
|
"type": "rawscript",
|
||||||
|
"content": "import * as wmill from \"windmill-client\";\n\nexport async function main() {\n const token = await wmill.getVariable(\"u/admin/LINE_CHANNEL_ACCESS_TOKEN\");\n const to = await wmill.getVariable(\"u/admin/LINE_TO\");\n\n const message = [\n \"\\u26a0\\ufe0f \\u9ce9\\u6642\\u8a08\\u30a8\\u30e9\\u30fc\",\n \"\",\n \"Alexa TTS API \\u304c\\u5931\\u6557\\u3057\\u307e\\u3057\\u305f\\u3002\",\n \"Cookie\\u306e\\u671f\\u9650\\u5207\\u308c\\u306e\\u53ef\\u80fd\\u6027\\u304c\\u3042\\u308a\\u307e\\u3059\\u3002\",\n \"\",\n \"\\u5bfe\\u51e6\\u624b\\u9806:\",\n \"1. \\u30ed\\u30fc\\u30ab\\u30ebPC\\u3067GUI\\u3092\\u8d77\\u52d5\",\n \"cd /home/akira/develop/windmill_workflow/alexa-api\",\n \"npm run auth:web\",\n \"\",\n \"2. \\u30d6\\u30e9\\u30a6\\u30b6\\u3067 http://127.0.0.1:3678 \\u3092\\u958b\\u304f\",\n \"Amazon \\u306e\\u30e1\\u30fc\\u30eb\\u30a2\\u30c9\\u30ec\\u30b9\\u3068\\u30d1\\u30b9\\u30ef\\u30fc\\u30c9\\u3092\\u5165\\u529b\",\n \"\",\n \"3. \\u540c\\u3058GUI\\u306e\\u300c\\u30b5\\u30fc\\u30d0\\u30fc\\u3078\\u53cd\\u6620\\u3059\\u308b\\u300d\\u3092\\u5b9f\\u884c\",\n \"SSH \\u63a5\\u7d9a\\u5148\\u306f keinafarm \\u3067OK\",\n \"\\u521d\\u56de\\u3060\\u3051 /usr/local/bin/alexa-cookie-deploy.sh \\u3068 sudoers \\u8a2d\\u5b9a\\u304c\\u5fc5\\u8981\"\n ].join(\"\\n\");\n\n const res = await fetch(\"https://api.line.me/v2/bot/message/push\", {\n method: \"POST\",\n headers: {\n \"Authorization\": `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify({\n to: to,\n messages: [{ type: \"text\", text: message }],\n }),\n });\n\n if (!res.ok) {\n const body = await res.text();\n throw new Error(`LINE API error ${res.status}: ${body}`);\n }\n\n return { notified: true };\n}\n",
|
||||||
|
"language": "bun",
|
||||||
|
"input_transforms": {}
|
||||||
|
},
|
||||||
|
"summary": "エラー時LINE通知"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"schema": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"properties": {},
|
||||||
|
"required": [],
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
}
|
||||||
24
flows/konnnichiha.flow.json
Normal file
24
flows/konnnichiha.flow.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"path": "f/dev/konnnichiha",
|
||||||
|
"summary": "Print greeting",
|
||||||
|
"description": "",
|
||||||
|
"value": {
|
||||||
|
"modules": [
|
||||||
|
{
|
||||||
|
"id": "a",
|
||||||
|
"value": {
|
||||||
|
"lock": "# py: 3.12\n",
|
||||||
|
"type": "rawscript",
|
||||||
|
"content": "def main():\n print('こんにちは、世界')",
|
||||||
|
"language": "python3",
|
||||||
|
"input_transforms": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {},
|
||||||
|
"required": []
|
||||||
|
}
|
||||||
|
}
|
||||||
29
flows/mail_filter.flow.json
Normal file
29
flows/mail_filter.flow.json
Normal file
File diff suppressed because one or more lines are too long
@@ -6,14 +6,14 @@
|
|||||||
"modules": [
|
"modules": [
|
||||||
{
|
{
|
||||||
"id": "a",
|
"id": "a",
|
||||||
"summary": "変更確認・LINE通知",
|
|
||||||
"value": {
|
"value": {
|
||||||
|
"lock": "# py: 3.12\nanyio==4.12.1\ncertifi==2026.1.4\nh11==0.16.0\nhttpcore==1.0.9\nhttpx==0.28.1\nidna==3.11\ntyping-extensions==4.15.0\nwmill==1.640.0",
|
||||||
"type": "rawscript",
|
"type": "rawscript",
|
||||||
"language": "python3",
|
|
||||||
"content": "import urllib.request\nimport urllib.parse\nimport json\nimport ssl\nfrom datetime import datetime, timezone, timedelta\nimport wmill\n\nJST = timezone(timedelta(hours=9))\n\n\ndef main():\n # シークレット取得\n api_key = wmill.get_variable(\"u/admin/NOTIFICATION_API_KEY\")\n line_token = wmill.get_variable(\"u/admin/LINE_CHANNEL_ACCESS_TOKEN\")\n line_to = wmill.get_variable(\"u/admin/LINE_TO\")\n\n # 前回実行時刻を取得(初回は現在時刻 - 10分)\n try:\n last_checked = wmill.get_variable(\"u/admin/SHIRAOU_LAST_CHECKED_AT\")\n if not last_checked:\n last_checked = None\n except Exception:\n last_checked = None\n\n if last_checked:\n since = last_checked\n else:\n since = (datetime.now(JST) - timedelta(minutes=10)).isoformat()\n\n print(f\"[通知] 変更確認: since={since}\")\n\n # API呼び出し\n ssl_ctx = ssl.create_default_context()\n ssl_ctx.check_hostname = False\n ssl_ctx.verify_mode = ssl.CERT_NONE\n\n params = urllib.parse.urlencode({\"since\": since})\n url = f\"https://shiraou.keinafarm.net/reservations/api/changes/?{params}\"\n\n req = urllib.request.Request(url, headers={\"X-API-Key\": api_key})\n with urllib.request.urlopen(req, context=ssl_ctx, timeout=30) as resp:\n data = json.loads(resp.read().decode(\"utf-8\"))\n\n checked_at = data[\"checked_at\"]\n reservations = data.get(\"reservations\", [])\n usages = data.get(\"usages\", [])\n\n print(f\"[通知] checked_at={checked_at}, 予約={len(reservations)}件, 実績={len(usages)}件\")\n\n # 変更があればLINE通知(エラー時は状態を更新しない)\n if reservations or usages:\n message = _format_message(reservations, usages)\n _send_line(line_token, line_to, message)\n print(\"[通知] LINE送信完了\")\n else:\n print(\"[通知] 変更なし、通知スキップ\")\n\n # 正常完了時のみ状態更新\n wmill.set_variable(\"u/admin/SHIRAOU_LAST_CHECKED_AT\", checked_at)\n print(f\"[通知] last_checked_at更新: {checked_at}\")\n\n return {\n \"since\": since,\n \"checked_at\": checked_at,\n \"reservations_count\": len(reservations),\n \"usages_count\": len(usages),\n \"notified\": bool(reservations or usages),\n }\n\n\ndef _format_message(reservations, usages):\n lines = [\"\\U0001f4cb 営農システム 変更通知\\n\"]\n\n OP_R = {\n \"create\": (\"\\U0001f7e2\", \"予約作成\"),\n \"update\": (\"\\U0001f535\", \"予約変更\"),\n \"cancel\": (\"\\U0001f534\", \"予約キャンセル\"),\n }\n OP_U = {\n \"create\": (\"\\U0001f7e2\", \"実績登録\"),\n \"update\": (\"\\U0001f535\", \"実績修正\"),\n \"delete\": (\"\\U0001f534\", \"実績削除\"),\n }\n\n for r in reservations:\n start = r[\"start_at\"][:16].replace(\"T\", \" \")\n end = r[\"end_at\"][:16].replace(\"T\", \" \")\n icon, label = OP_R.get(r[\"operation\"], (\"\\u26aa\", r[\"operation\"]))\n lines += [\n f\"{icon} {label}\",\n f\" 機械: {r['machine_name']}\",\n f\" 利用者: {r['user_name']}\",\n f\" 日時: {start} \\uff5e {end}\",\n ]\n if r.get(\"reason\"):\n lines.append(f\" 理由: {r['reason']}\")\n lines.append(\"\")\n\n for u in usages:\n start = u[\"start_at\"][:16].replace(\"T\", \" \")\n icon, label = OP_U.get(u[\"operation\"], (\"\\u26aa\", u[\"operation\"]))\n lines += [\n f\"{icon} {label}\",\n f\" 機械: {u['machine_name']}\",\n f\" 利用者: {u['user_name']}\",\n f\" 利用量: {u['amount']}{u['unit']}\",\n f\" 日: {start[:10]}\",\n ]\n if u.get(\"reason\"):\n lines.append(f\" 理由: {u['reason']}\")\n lines.append(\"\")\n\n return \"\\n\".join(lines).strip()\n\n\ndef _send_line(token, to, message):\n payload = json.dumps({\n \"to\": to,\n \"messages\": [{\"type\": \"text\", \"text\": message}],\n }).encode(\"utf-8\")\n\n req = urllib.request.Request(\n \"https://api.line.me/v2/bot/message/push\",\n data=payload,\n headers={\n \"Authorization\": f\"Bearer {token}\",\n \"Content-Type\": \"application/json\",\n },\n method=\"POST\",\n )\n with urllib.request.urlopen(req, timeout=30) as resp:\n return resp.read().decode(\"utf-8\")\n",
|
"content": "import urllib.request\nimport urllib.parse\nimport json\nimport ssl\nfrom datetime import datetime, timezone, timedelta\nimport wmill\n\nJST = timezone(timedelta(hours=9))\n\n\ndef main():\n # シークレット取得\n api_key = wmill.get_variable(\"u/admin/NOTIFICATION_API_KEY\")\n line_token = wmill.get_variable(\"u/admin/LINE_CHANNEL_ACCESS_TOKEN\")\n line_to = wmill.get_variable(\"u/admin/LINE_TO\")\n\n # 前回実行時刻を取得(初回は現在時刻 - 10分)\n try:\n last_checked = wmill.get_variable(\"u/admin/SHIRAOU_LAST_CHECKED_AT\")\n if not last_checked:\n last_checked = None\n except Exception:\n last_checked = None\n\n if last_checked:\n since = last_checked\n else:\n since = (datetime.now(JST) - timedelta(minutes=10)).isoformat()\n\n print(f\"[通知] 変更確認: since={since}\")\n\n # API呼び出し\n ssl_ctx = ssl.create_default_context()\n ssl_ctx.check_hostname = False\n ssl_ctx.verify_mode = ssl.CERT_NONE\n\n params = urllib.parse.urlencode({\"since\": since})\n url = f\"https://shiraou.keinafarm.net/reservations/api/changes/?{params}\"\n\n req = urllib.request.Request(url, headers={\"X-API-Key\": api_key})\n with urllib.request.urlopen(req, context=ssl_ctx, timeout=30) as resp:\n data = json.loads(resp.read().decode(\"utf-8\"))\n\n checked_at = data[\"checked_at\"]\n reservations = data.get(\"reservations\", [])\n usages = data.get(\"usages\", [])\n\n print(f\"[通知] checked_at={checked_at}, 予約={len(reservations)}件, 実績={len(usages)}件\")\n\n # 変更があればLINE通知(エラー時は状態を更新しない)\n if reservations or usages:\n message = _format_message(reservations, usages)\n _send_line(line_token, line_to, message)\n print(\"[通知] LINE送信完了\")\n else:\n print(\"[通知] 変更なし、通知スキップ\")\n\n # 正常完了時のみ状態更新\n wmill.set_variable(\"u/admin/SHIRAOU_LAST_CHECKED_AT\", checked_at)\n print(f\"[通知] last_checked_at更新: {checked_at}\")\n\n return {\n \"since\": since,\n \"checked_at\": checked_at,\n \"reservations_count\": len(reservations),\n \"usages_count\": len(usages),\n \"notified\": bool(reservations or usages),\n }\n\n\ndef _format_message(reservations, usages):\n lines = [\"\\U0001f4cb 営農システム 変更通知\\n\"]\n\n OP_R = {\n \"create\": (\"\\U0001f7e2\", \"予約作成\"),\n \"update\": (\"\\U0001f535\", \"予約変更\"),\n \"cancel\": (\"\\U0001f534\", \"予約キャンセル\"),\n }\n OP_U = {\n \"create\": (\"\\U0001f7e2\", \"実績登録\"),\n \"update\": (\"\\U0001f535\", \"実績修正\"),\n \"delete\": (\"\\U0001f534\", \"実績削除\"),\n }\n\n for r in reservations:\n start = r[\"start_at\"][:16].replace(\"T\", \" \")\n end = r[\"end_at\"][:16].replace(\"T\", \" \")\n icon, label = OP_R.get(r[\"operation\"], (\"\\u26aa\", r[\"operation\"]))\n lines += [\n f\"{icon} {label}\",\n f\" 機械: {r['machine_name']}\",\n f\" 利用者: {r['user_name']}\",\n f\" 日時: {start} \\uff5e {end}\",\n ]\n if r.get(\"reason\"):\n lines.append(f\" 理由: {r['reason']}\")\n lines.append(\"\")\n\n for u in usages:\n start = u[\"start_at\"][:16].replace(\"T\", \" \")\n icon, label = OP_U.get(u[\"operation\"], (\"\\u26aa\", u[\"operation\"]))\n lines += [\n f\"{icon} {label}\",\n f\" 機械: {u['machine_name']}\",\n f\" 利用者: {u['user_name']}\",\n f\" 利用量: {u['amount']}{u['unit']}\",\n f\" 日: {start[:10]}\",\n ]\n if u.get(\"reason\"):\n lines.append(f\" 理由: {u['reason']}\")\n lines.append(\"\")\n\n return \"\\n\".join(lines).strip()\n\n\ndef _send_line(token, to, message):\n payload = json.dumps({\n \"to\": to,\n \"messages\": [{\"type\": \"text\", \"text\": message}],\n }).encode(\"utf-8\")\n\n req = urllib.request.Request(\n \"https://api.line.me/v2/bot/message/push\",\n data=payload,\n headers={\n \"Authorization\": f\"Bearer {token}\",\n \"Content-Type\": \"application/json\",\n },\n method=\"POST\",\n )\n with urllib.request.urlopen(req, timeout=30) as resp:\n return resp.read().decode(\"utf-8\")\n",
|
||||||
"input_transforms": {},
|
"language": "python3",
|
||||||
"lock": ""
|
"input_transforms": {}
|
||||||
}
|
},
|
||||||
|
"summary": "変更確認・LINE通知"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,70 +6,70 @@
|
|||||||
"modules": [
|
"modules": [
|
||||||
{
|
{
|
||||||
"id": "a",
|
"id": "a",
|
||||||
"summary": "Step1: 診断データ生成",
|
|
||||||
"value": {
|
"value": {
|
||||||
|
"lock": "# py: 3.12\n",
|
||||||
"type": "rawscript",
|
"type": "rawscript",
|
||||||
"language": "python3",
|
|
||||||
"content": "import uuid\nfrom datetime import datetime, timezone\n\ndef main():\n \"\"\"診断データを生成する\"\"\"\n now = datetime.now(timezone.utc)\n run_id = str(uuid.uuid4())\n check_value = 2 + 2\n \n result = {\n \"timestamp\": now.isoformat(),\n \"run_id\": run_id,\n \"check\": check_value,\n \"python_version\": __import__('sys').version\n }\n print(f\"[Step1] 診断データ生成完了\")\n print(f\" run_id: {run_id}\")\n print(f\" timestamp: {now.isoformat()}\")\n print(f\" check: {check_value}\")\n return result\n",
|
"content": "import uuid\nfrom datetime import datetime, timezone\n\ndef main():\n \"\"\"診断データを生成する\"\"\"\n now = datetime.now(timezone.utc)\n run_id = str(uuid.uuid4())\n check_value = 2 + 2\n \n result = {\n \"timestamp\": now.isoformat(),\n \"run_id\": run_id,\n \"check\": check_value,\n \"python_version\": __import__('sys').version\n }\n print(f\"[Step1] 診断データ生成完了\")\n print(f\" run_id: {run_id}\")\n print(f\" timestamp: {now.isoformat()}\")\n print(f\" check: {check_value}\")\n return result\n",
|
||||||
"input_transforms": {},
|
"language": "python3",
|
||||||
"lock": ""
|
"input_transforms": {}
|
||||||
}
|
},
|
||||||
|
"summary": "Step1: 診断データ生成"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "b",
|
"id": "b",
|
||||||
"summary": "Step2: データ検証",
|
|
||||||
"value": {
|
"value": {
|
||||||
|
"lock": "# py: 3.12\n",
|
||||||
"type": "rawscript",
|
"type": "rawscript",
|
||||||
"language": "python3",
|
|
||||||
"content": "from datetime import datetime, timezone\n\ndef main(step1_result: dict):\n \"\"\"Step1の結果を検証する\"\"\"\n errors = []\n \n # 計算チェック\n if step1_result.get(\"check\") != 4:\n errors.append(f\"計算エラー: expected 4, got {step1_result.get('check')}\")\n \n # run_idの存在チェック\n if not step1_result.get(\"run_id\"):\n errors.append(\"run_idが存在しない\")\n \n # timestampの存在チェック\n if not step1_result.get(\"timestamp\"):\n errors.append(\"timestampが存在しない\")\n \n if errors:\n error_msg = \"; \".join(errors)\n print(f\"[Step2] 検証失敗: {error_msg}\")\n raise Exception(f\"検証失敗: {error_msg}\")\n \n print(f\"[Step2] データ検証OK\")\n print(f\" 計算チェック: 2+2={step1_result['check']} ✓\")\n print(f\" run_id: {step1_result['run_id']} ✓\")\n print(f\" timestamp: {step1_result['timestamp']} ✓\")\n \n return {\n \"verification\": \"PASS\",\n \"step1_data\": step1_result\n }\n",
|
"content": "from datetime import datetime, timezone\n\ndef main(step1_result: dict):\n \"\"\"Step1の結果を検証する\"\"\"\n errors = []\n \n # 計算チェック\n if step1_result.get(\"check\") != 4:\n errors.append(f\"計算エラー: expected 4, got {step1_result.get('check')}\")\n \n # run_idの存在チェック\n if not step1_result.get(\"run_id\"):\n errors.append(\"run_idが存在しない\")\n \n # timestampの存在チェック\n if not step1_result.get(\"timestamp\"):\n errors.append(\"timestampが存在しない\")\n \n if errors:\n error_msg = \"; \".join(errors)\n print(f\"[Step2] 検証失敗: {error_msg}\")\n raise Exception(f\"検証失敗: {error_msg}\")\n \n print(f\"[Step2] データ検証OK\")\n print(f\" 計算チェック: 2+2={step1_result['check']} ✓\")\n print(f\" run_id: {step1_result['run_id']} ✓\")\n print(f\" timestamp: {step1_result['timestamp']} ✓\")\n \n return {\n \"verification\": \"PASS\",\n \"step1_data\": step1_result\n }\n",
|
||||||
|
"language": "python3",
|
||||||
"input_transforms": {
|
"input_transforms": {
|
||||||
"step1_result": {
|
"step1_result": {
|
||||||
"type": "javascript",
|
"expr": "results.a",
|
||||||
"expr": "results.a"
|
"type": "javascript"
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
"lock": ""
|
},
|
||||||
}
|
"summary": "Step2: データ検証"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "c",
|
"id": "c",
|
||||||
"summary": "Step3: HTTPヘルスチェック",
|
|
||||||
"value": {
|
"value": {
|
||||||
|
"lock": "# py: 3.12\n",
|
||||||
"type": "rawscript",
|
"type": "rawscript",
|
||||||
"language": "python3",
|
|
||||||
"content": "import urllib.request\nimport ssl\n\ndef main(verification_result: dict):\n \"\"\"Windmillサーバー自身へのHTTPチェック\"\"\"\n url = \"https://windmill.keinafarm.net/api/version\"\n \n # SSL検証をスキップ(自己署名証明書対応)\n ctx = ssl.create_default_context()\n ctx.check_hostname = False\n ctx.verify_mode = ssl.CERT_NONE\n \n try:\n req = urllib.request.Request(url)\n with urllib.request.urlopen(req, context=ctx, timeout=10) as response:\n status_code = response.status\n body = response.read().decode('utf-8')\n except Exception as e:\n print(f\"[Step3] HTTPチェック失敗: {e}\")\n raise Exception(f\"HTTPヘルスチェック失敗: {e}\")\n \n print(f\"[Step3] HTTPヘルスチェックOK\")\n print(f\" URL: {url}\")\n print(f\" Status: {status_code}\")\n print(f\" Version: {body}\")\n \n return {\n \"http_check\": \"PASS\",\n \"status_code\": status_code,\n \"server_version\": body\n }\n",
|
"content": "import urllib.request\nimport ssl\n\ndef main(verification_result: dict):\n \"\"\"Windmillサーバー自身へのHTTPチェック\"\"\"\n url = \"https://windmill.keinafarm.net/api/version\"\n \n # SSL検証をスキップ(自己署名証明書対応)\n ctx = ssl.create_default_context()\n ctx.check_hostname = False\n ctx.verify_mode = ssl.CERT_NONE\n \n try:\n req = urllib.request.Request(url)\n with urllib.request.urlopen(req, context=ctx, timeout=10) as response:\n status_code = response.status\n body = response.read().decode('utf-8')\n except Exception as e:\n print(f\"[Step3] HTTPチェック失敗: {e}\")\n raise Exception(f\"HTTPヘルスチェック失敗: {e}\")\n \n print(f\"[Step3] HTTPヘルスチェックOK\")\n print(f\" URL: {url}\")\n print(f\" Status: {status_code}\")\n print(f\" Version: {body}\")\n \n return {\n \"http_check\": \"PASS\",\n \"status_code\": status_code,\n \"server_version\": body\n }\n",
|
||||||
|
"language": "python3",
|
||||||
"input_transforms": {
|
"input_transforms": {
|
||||||
"verification_result": {
|
"verification_result": {
|
||||||
"type": "javascript",
|
"expr": "results.b",
|
||||||
"expr": "results.b"
|
"type": "javascript"
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
"lock": ""
|
},
|
||||||
}
|
"summary": "Step3: HTTPヘルスチェック"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "d",
|
"id": "d",
|
||||||
"summary": "Step4: 年度判定 & 最終レポート",
|
|
||||||
"value": {
|
"value": {
|
||||||
|
"lock": "# py: 3.12\n",
|
||||||
"type": "rawscript",
|
"type": "rawscript",
|
||||||
"language": "python3",
|
|
||||||
"content": "from datetime import datetime, timezone\n\ndef main(step1_data: dict, verification: dict, http_check: dict):\n \"\"\"年度判定と最終診断レポートを生成\"\"\"\n now = datetime.now(timezone.utc)\n \n # 日本の年度判定(4月始まり)\n fiscal_year = now.year if now.month >= 4 else now.year - 1\n \n report = {\n \"status\": \"ALL OK\",\n \"fiscal_year\": fiscal_year,\n \"diagnostics\": {\n \"data_generation\": \"PASS\",\n \"data_verification\": verification.get(\"verification\", \"UNKNOWN\"),\n \"http_health\": http_check.get(\"http_check\", \"UNKNOWN\"),\n \"server_version\": http_check.get(\"server_version\", \"UNKNOWN\")\n },\n \"run_id\": step1_data.get(\"run_id\"),\n \"started_at\": step1_data.get(\"timestamp\"),\n \"completed_at\": now.isoformat()\n }\n \n print(\"\")\n print(\"========================================\")\n print(\" Windmill Heartbeat - 診断レポート\")\n print(\"========================================\")\n print(f\" Status: {report['status']}\")\n print(f\" 年度: {fiscal_year}年度\")\n print(f\" Run ID: {report['run_id']}\")\n print(f\" Server: {report['diagnostics']['server_version']}\")\n print(f\" 開始: {report['started_at']}\")\n print(f\" 完了: {report['completed_at']}\")\n print(\" ────────────────────────────────────\")\n print(f\" データ生成: PASS ✓\")\n print(f\" データ検証: {report['diagnostics']['data_verification']} ✓\")\n print(f\" HTTP確認: {report['diagnostics']['http_health']} ✓\")\n print(\"========================================\")\n print(\"\")\n \n return report\n",
|
"content": "from datetime import datetime, timezone\n\ndef main(step1_data: dict, verification: dict, http_check: dict):\n \"\"\"年度判定と最終診断レポートを生成\"\"\"\n now = datetime.now(timezone.utc)\n \n # 日本の年度判定(4月始まり)\n fiscal_year = now.year if now.month >= 4 else now.year - 1\n \n report = {\n \"status\": \"ALL OK\",\n \"fiscal_year\": fiscal_year,\n \"diagnostics\": {\n \"data_generation\": \"PASS\",\n \"data_verification\": verification.get(\"verification\", \"UNKNOWN\"),\n \"http_health\": http_check.get(\"http_check\", \"UNKNOWN\"),\n \"server_version\": http_check.get(\"server_version\", \"UNKNOWN\")\n },\n \"run_id\": step1_data.get(\"run_id\"),\n \"started_at\": step1_data.get(\"timestamp\"),\n \"completed_at\": now.isoformat()\n }\n \n print(\"\")\n print(\"========================================\")\n print(\" Windmill Heartbeat - 診断レポート\")\n print(\"========================================\")\n print(f\" Status: {report['status']}\")\n print(f\" 年度: {fiscal_year}年度\")\n print(f\" Run ID: {report['run_id']}\")\n print(f\" Server: {report['diagnostics']['server_version']}\")\n print(f\" 開始: {report['started_at']}\")\n print(f\" 完了: {report['completed_at']}\")\n print(\" ────────────────────────────────────\")\n print(f\" データ生成: PASS ✓\")\n print(f\" データ検証: {report['diagnostics']['data_verification']} ✓\")\n print(f\" HTTP確認: {report['diagnostics']['http_health']} ✓\")\n print(\"========================================\")\n print(\"\")\n \n return report\n",
|
||||||
|
"language": "python3",
|
||||||
"input_transforms": {
|
"input_transforms": {
|
||||||
|
"http_check": {
|
||||||
|
"expr": "results.c",
|
||||||
|
"type": "javascript"
|
||||||
|
},
|
||||||
"step1_data": {
|
"step1_data": {
|
||||||
"type": "javascript",
|
"expr": "results.a",
|
||||||
"expr": "results.a"
|
"type": "javascript"
|
||||||
},
|
},
|
||||||
"verification": {
|
"verification": {
|
||||||
"type": "javascript",
|
"expr": "results.b",
|
||||||
"expr": "results.b"
|
"type": "javascript"
|
||||||
},
|
|
||||||
"http_check": {
|
|
||||||
"type": "javascript",
|
|
||||||
"expr": "results.c"
|
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
"lock": ""
|
},
|
||||||
}
|
"summary": "Step4: 年度判定 & 最終レポート"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
24
flows/textout.flow.json
Normal file
24
flows/textout.flow.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"path": "f/dev/textout",
|
||||||
|
"summary": "Display current time on startup",
|
||||||
|
"description": "",
|
||||||
|
"value": {
|
||||||
|
"modules": [
|
||||||
|
{
|
||||||
|
"id": "a",
|
||||||
|
"value": {
|
||||||
|
"lock": "# py: 3.12\n",
|
||||||
|
"type": "rawscript",
|
||||||
|
"content": "def main():\n from datetime import datetime\n print(datetime.now().strftime('%H:%M:%S'))",
|
||||||
|
"language": "python3",
|
||||||
|
"input_transforms": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {},
|
||||||
|
"required": []
|
||||||
|
}
|
||||||
|
}
|
||||||
27
flows/weather_sync.flow.json
Normal file
27
flows/weather_sync.flow.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"path": "f/weather/weather_sync",
|
||||||
|
"summary": "Weather Sync - 気象データ日次同期",
|
||||||
|
"description": "Open-Meteo から昨日の気象データを取得し、Keinasystem DB に保存する。毎朝6時実行。",
|
||||||
|
"value": {
|
||||||
|
"modules": [
|
||||||
|
{
|
||||||
|
"id": "a",
|
||||||
|
"value": {
|
||||||
|
"lock": "# py: 3.12\nanyio==4.12.1\ncertifi==2026.2.25\ncharset-normalizer==3.4.4\nh11==0.16.0\nhttpcore==1.0.9\nhttpx==0.28.1\nidna==3.11\nrequests==2.32.5\ntyping-extensions==4.15.0\nurllib3==2.6.3\nwmill==1.646.0",
|
||||||
|
"type": "rawscript",
|
||||||
|
"content": "import wmill\nimport requests\nimport datetime\n\nLATITUDE = 33.213\nLONGITUDE = 133.133\nTIMEZONE = \"Asia/Tokyo\"\n\nOPEN_METEO_URL = \"https://archive-api.open-meteo.com/v1/archive\"\nDAILY_VARS = [\n \"temperature_2m_mean\",\n \"temperature_2m_max\",\n \"temperature_2m_min\",\n \"sunshine_duration\",\n \"precipitation_sum\",\n \"wind_speed_10m_max\",\n \"surface_pressure_min\",\n]\n\n\ndef main():\n api_key = wmill.get_variable(\"u/admin/KEINASYSTEM_API_KEY\")\n base_url = wmill.get_variable(\"u/admin/KEINASYSTEM_API_URL\").rstrip(\"/\")\n sync_url = f\"{base_url}/api/weather/sync/\"\n\n yesterday = (datetime.date.today() - datetime.timedelta(days=1)).isoformat()\n print(f\"Fetching weather data for {yesterday} ...\")\n\n params = {\n \"latitude\": LATITUDE,\n \"longitude\": LONGITUDE,\n \"start_date\": yesterday,\n \"end_date\": yesterday,\n \"daily\": DAILY_VARS,\n \"timezone\": TIMEZONE,\n }\n resp = requests.get(OPEN_METEO_URL, params=params, timeout=30)\n if resp.status_code != 200:\n raise Exception(f\"Open-Meteo API error: {resp.status_code} {resp.text[:300]}\")\n\n daily = resp.json().get(\"daily\", {})\n dates = daily.get(\"time\", [])\n if not dates:\n print(\"No data returned from Open-Meteo.\")\n return {\"status\": \"no_data\"}\n\n sunshine_raw = daily.get(\"sunshine_duration\", [])\n records = []\n for i, d in enumerate(dates):\n sun_sec = sunshine_raw[i]\n records.append({\n \"date\": d,\n \"temp_mean\": daily[\"temperature_2m_mean\"][i],\n \"temp_max\": daily[\"temperature_2m_max\"][i],\n \"temp_min\": daily[\"temperature_2m_min\"][i],\n \"sunshine_h\": round(sun_sec / 3600, 2) if sun_sec is not None else None,\n \"precip_mm\": daily[\"precipitation_sum\"][i],\n \"wind_max\": daily[\"wind_speed_10m_max\"][i],\n \"pressure_min\": daily[\"surface_pressure_min\"][i],\n })\n\n headers = {\n \"X-API-Key\": api_key,\n \"Content-Type\": \"application/json\",\n }\n post_resp = requests.post(sync_url, json=records, headers=headers, timeout=30)\n if post_resp.status_code not in (200, 201):\n raise Exception(f\"Keinasystem sync error: {post_resp.status_code} {post_resp.text[:300]}\")\n\n result = post_resp.json()\n print(f\"Sync complete: {result}\")\n return result\n",
|
||||||
|
"language": "python3",
|
||||||
|
"input_transforms": {}
|
||||||
|
},
|
||||||
|
"summary": "気象データ取得・同期"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"schema": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "object",
|
||||||
|
"order": [],
|
||||||
|
"properties": {},
|
||||||
|
"required": []
|
||||||
|
}
|
||||||
|
}
|
||||||
20
scripts/alexa_speak.ts
Normal file
20
scripts/alexa_speak.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
export async function main(
|
||||||
|
device: string,
|
||||||
|
text: string,
|
||||||
|
): Promise<{ ok: boolean; device: string; text: string }> {
|
||||||
|
const ALEXA_API_URL = "http://alexa_api:3500";
|
||||||
|
|
||||||
|
const res = await fetch(`${ALEXA_API_URL}/speak`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ device, text }), // ← SSMLなし、素のテキスト
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(`alexa-api error ${res.status}: ${JSON.stringify(body)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await res.json();
|
||||||
|
}
|
||||||
|
|
||||||
106
state/flows.list.json
Normal file
106
state/flows.list.json
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"workspace_id": "admins",
|
||||||
|
"path": "u/akiracraftwork/hourly_chime",
|
||||||
|
"summary": "鳩時計機能",
|
||||||
|
"description": "",
|
||||||
|
"edited_by": "akiracraftwork@gmail.com",
|
||||||
|
"edited_at": "2026-03-03T05:37:39.969305Z",
|
||||||
|
"archived": false,
|
||||||
|
"extra_perms": {},
|
||||||
|
"starred": false,
|
||||||
|
"has_draft": false,
|
||||||
|
"ws_error_handler_muted": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"workspace_id": "admins",
|
||||||
|
"path": "f/dev/textout",
|
||||||
|
"summary": "Display current time on startup",
|
||||||
|
"description": "",
|
||||||
|
"edited_by": "akiracraftwork@gmail.com",
|
||||||
|
"edited_at": "2026-03-02T05:05:05.215985Z",
|
||||||
|
"archived": false,
|
||||||
|
"extra_perms": {},
|
||||||
|
"starred": false,
|
||||||
|
"has_draft": false,
|
||||||
|
"ws_error_handler_muted": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"workspace_id": "admins",
|
||||||
|
"path": "f/dev/konnnichiha",
|
||||||
|
"summary": "Print greeting",
|
||||||
|
"description": "",
|
||||||
|
"edited_by": "akiracraftwork@gmail.com",
|
||||||
|
"edited_at": "2026-03-02T04:53:56.968574Z",
|
||||||
|
"archived": false,
|
||||||
|
"extra_perms": {},
|
||||||
|
"starred": false,
|
||||||
|
"has_draft": false,
|
||||||
|
"ws_error_handler_muted": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"workspace_id": "admins",
|
||||||
|
"path": "u/antigravity/git_sync",
|
||||||
|
"summary": "Git Sync Workflow",
|
||||||
|
"description": "Automatically sync Windmill workflows to Git repository (sync branch)",
|
||||||
|
"edited_by": "akiracraftwork@gmail.com",
|
||||||
|
"edited_at": "2026-03-01T17:28:14.331046Z",
|
||||||
|
"archived": false,
|
||||||
|
"extra_perms": {},
|
||||||
|
"starred": false,
|
||||||
|
"has_draft": false,
|
||||||
|
"ws_error_handler_muted": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"workspace_id": "admins",
|
||||||
|
"path": "f/weather/weather_sync",
|
||||||
|
"summary": "Weather Sync - 気象データ日次同期",
|
||||||
|
"description": "Open-Meteo から昨日の気象データを取得し、Keinasystem DB に保存する。毎朝6時実行。",
|
||||||
|
"edited_by": "akiracraftwork@gmail.com",
|
||||||
|
"edited_at": "2026-02-28T04:31:27.835748Z",
|
||||||
|
"archived": false,
|
||||||
|
"extra_perms": {},
|
||||||
|
"starred": false,
|
||||||
|
"has_draft": false,
|
||||||
|
"ws_error_handler_muted": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"workspace_id": "admins",
|
||||||
|
"path": "f/mail/mail_filter",
|
||||||
|
"summary": "メールフィルタリング",
|
||||||
|
"description": "IMAPで新着メールを受信し、送信者ルール確認→LLM判定→LINE通知を行う。Keinasystemと連携。Gmail→Hotmail→Xserverの順で段階的に有効化する。",
|
||||||
|
"edited_by": "akiracraftwork@gmail.com",
|
||||||
|
"edited_at": "2026-02-24T06:41:54.748865Z",
|
||||||
|
"archived": false,
|
||||||
|
"extra_perms": {},
|
||||||
|
"starred": false,
|
||||||
|
"has_draft": false,
|
||||||
|
"ws_error_handler_muted": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"workspace_id": "admins",
|
||||||
|
"path": "f/shiraou/shiraou_notification",
|
||||||
|
"summary": "白皇集落営農 変更通知",
|
||||||
|
"description": "shiraou.keinafarm.net の予約・実績変更をポーリングし、変更があればLINEで管理者に通知する。5分毎に実行。",
|
||||||
|
"edited_by": "akiracraftwork@gmail.com",
|
||||||
|
"edited_at": "2026-02-21T06:33:11.078673Z",
|
||||||
|
"archived": false,
|
||||||
|
"extra_perms": {},
|
||||||
|
"starred": false,
|
||||||
|
"has_draft": false,
|
||||||
|
"ws_error_handler_muted": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"workspace_id": "admins",
|
||||||
|
"path": "f/app_custom/system_heartbeat",
|
||||||
|
"summary": "Windmill Heartbeat - システム自己診断",
|
||||||
|
"description": "Windmillの動作確認用ワークフロー。UUID生成、時刻取得、計算チェック、HTTPヘルスチェック、年度判定を行い、全ステップの正常性を検証する。",
|
||||||
|
"edited_by": "akiracraftwork@gmail.com",
|
||||||
|
"edited_at": "2026-02-21T03:43:55.495111Z",
|
||||||
|
"archived": false,
|
||||||
|
"extra_perms": {},
|
||||||
|
"starred": false,
|
||||||
|
"has_draft": false,
|
||||||
|
"ws_error_handler_muted": false
|
||||||
|
}
|
||||||
|
]
|
||||||
78
state/remote_index.current.json
Normal file
78
state/remote_index.current.json
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
{
|
||||||
|
"synced_at": "2026-03-03T07:09:55Z",
|
||||||
|
"workspace": "admins",
|
||||||
|
"scripts": {
|
||||||
|
"u/admin/alexa_speak": {
|
||||||
|
"hash": "3783872112d1a24c",
|
||||||
|
"updated_at": "2026-03-03T02:57:13.068287Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flows": {
|
||||||
|
"u/akiracraftwork/hourly_chime": {
|
||||||
|
"updated_at": "2026-03-03T05:37:39.969305Z"
|
||||||
|
},
|
||||||
|
"f/dev/textout": {
|
||||||
|
"updated_at": "2026-03-02T05:05:05.215985Z"
|
||||||
|
},
|
||||||
|
"f/dev/konnnichiha": {
|
||||||
|
"updated_at": "2026-03-02T04:53:56.968574Z"
|
||||||
|
},
|
||||||
|
"u/antigravity/git_sync": {
|
||||||
|
"updated_at": "2026-03-01T17:28:14.331046Z"
|
||||||
|
},
|
||||||
|
"f/weather/weather_sync": {
|
||||||
|
"updated_at": "2026-02-28T04:31:27.835748Z"
|
||||||
|
},
|
||||||
|
"f/mail/mail_filter": {
|
||||||
|
"updated_at": "2026-02-24T06:41:54.748865Z"
|
||||||
|
},
|
||||||
|
"f/shiraou/shiraou_notification": {
|
||||||
|
"updated_at": "2026-02-21T06:33:11.078673Z"
|
||||||
|
},
|
||||||
|
"f/app_custom/system_heartbeat": {
|
||||||
|
"updated_at": "2026-02-21T03:43:55.495111Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"schedules": {
|
||||||
|
"u/akiracraftwork/hourly_chime": {
|
||||||
|
"schedule": "0 0 * * * *",
|
||||||
|
"timezone": "Asia/Tokyo",
|
||||||
|
"enabled": true,
|
||||||
|
"script_path": "u/akiracraftwork/hourly_chime",
|
||||||
|
"is_flow": true,
|
||||||
|
"updated_at": "2026-03-03T04:44:03.309346Z"
|
||||||
|
},
|
||||||
|
"f/weather/weather_sync": {
|
||||||
|
"schedule": "0 0 6 * * *",
|
||||||
|
"timezone": "Asia/Tokyo",
|
||||||
|
"enabled": true,
|
||||||
|
"script_path": "f/weather/weather_sync",
|
||||||
|
"is_flow": true,
|
||||||
|
"updated_at": "2026-02-28T04:31:41.375049Z"
|
||||||
|
},
|
||||||
|
"f/mail/mail_filter_schedule": {
|
||||||
|
"schedule": "0 */10 * * * *",
|
||||||
|
"timezone": "Asia/Tokyo",
|
||||||
|
"enabled": true,
|
||||||
|
"script_path": "f/mail/mail_filter",
|
||||||
|
"is_flow": true,
|
||||||
|
"updated_at": "2026-02-24T06:42:06.977249Z"
|
||||||
|
},
|
||||||
|
"f/shiraou/shiraou_notification_every_5min": {
|
||||||
|
"schedule": "0 */5 * * * *",
|
||||||
|
"timezone": "Asia/Tokyo",
|
||||||
|
"enabled": true,
|
||||||
|
"script_path": "f/shiraou/shiraou_notification",
|
||||||
|
"is_flow": true,
|
||||||
|
"updated_at": "2026-02-21T06:18:34.967961Z"
|
||||||
|
},
|
||||||
|
"u/antigravity/git_sync": {
|
||||||
|
"schedule": "0 */30 * * * *",
|
||||||
|
"timezone": "Asia/Tokyo",
|
||||||
|
"enabled": true,
|
||||||
|
"script_path": "u/antigravity/git_sync",
|
||||||
|
"is_flow": true,
|
||||||
|
"updated_at": "2026-02-19T06:38:19.867037Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
78
state/remote_index.json
Normal file
78
state/remote_index.json
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
{
|
||||||
|
"synced_at": "2026-03-03T07:12:56Z",
|
||||||
|
"workspace": "admins",
|
||||||
|
"scripts": {
|
||||||
|
"u/admin/alexa_speak": {
|
||||||
|
"hash": "3783872112d1a24c",
|
||||||
|
"updated_at": "2026-03-03T02:57:13.068287Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flows": {
|
||||||
|
"u/akiracraftwork/hourly_chime": {
|
||||||
|
"updated_at": "2026-03-03T05:37:39.969305Z"
|
||||||
|
},
|
||||||
|
"f/dev/textout": {
|
||||||
|
"updated_at": "2026-03-02T05:05:05.215985Z"
|
||||||
|
},
|
||||||
|
"f/dev/konnnichiha": {
|
||||||
|
"updated_at": "2026-03-02T04:53:56.968574Z"
|
||||||
|
},
|
||||||
|
"u/antigravity/git_sync": {
|
||||||
|
"updated_at": "2026-03-01T17:28:14.331046Z"
|
||||||
|
},
|
||||||
|
"f/weather/weather_sync": {
|
||||||
|
"updated_at": "2026-02-28T04:31:27.835748Z"
|
||||||
|
},
|
||||||
|
"f/mail/mail_filter": {
|
||||||
|
"updated_at": "2026-02-24T06:41:54.748865Z"
|
||||||
|
},
|
||||||
|
"f/shiraou/shiraou_notification": {
|
||||||
|
"updated_at": "2026-02-21T06:33:11.078673Z"
|
||||||
|
},
|
||||||
|
"f/app_custom/system_heartbeat": {
|
||||||
|
"updated_at": "2026-02-21T03:43:55.495111Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"schedules": {
|
||||||
|
"u/akiracraftwork/hourly_chime": {
|
||||||
|
"schedule": "0 0 * * * *",
|
||||||
|
"timezone": "Asia/Tokyo",
|
||||||
|
"enabled": true,
|
||||||
|
"script_path": "u/akiracraftwork/hourly_chime",
|
||||||
|
"is_flow": true,
|
||||||
|
"updated_at": "2026-03-03T04:44:03.309346Z"
|
||||||
|
},
|
||||||
|
"f/weather/weather_sync": {
|
||||||
|
"schedule": "0 0 6 * * *",
|
||||||
|
"timezone": "Asia/Tokyo",
|
||||||
|
"enabled": true,
|
||||||
|
"script_path": "f/weather/weather_sync",
|
||||||
|
"is_flow": true,
|
||||||
|
"updated_at": "2026-02-28T04:31:41.375049Z"
|
||||||
|
},
|
||||||
|
"f/mail/mail_filter_schedule": {
|
||||||
|
"schedule": "0 */10 * * * *",
|
||||||
|
"timezone": "Asia/Tokyo",
|
||||||
|
"enabled": true,
|
||||||
|
"script_path": "f/mail/mail_filter",
|
||||||
|
"is_flow": true,
|
||||||
|
"updated_at": "2026-02-24T06:42:06.977249Z"
|
||||||
|
},
|
||||||
|
"f/shiraou/shiraou_notification_every_5min": {
|
||||||
|
"schedule": "0 */5 * * * *",
|
||||||
|
"timezone": "Asia/Tokyo",
|
||||||
|
"enabled": true,
|
||||||
|
"script_path": "f/shiraou/shiraou_notification",
|
||||||
|
"is_flow": true,
|
||||||
|
"updated_at": "2026-02-21T06:18:34.967961Z"
|
||||||
|
},
|
||||||
|
"u/antigravity/git_sync": {
|
||||||
|
"schedule": "0 */30 * * * *",
|
||||||
|
"timezone": "Asia/Tokyo",
|
||||||
|
"enabled": true,
|
||||||
|
"script_path": "u/antigravity/git_sync",
|
||||||
|
"is_flow": true,
|
||||||
|
"updated_at": "2026-02-19T06:38:19.867037Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
67
state/schedules.list.json
Normal file
67
state/schedules.list.json
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"workspace_id": "admins",
|
||||||
|
"path": "u/akiracraftwork/hourly_chime",
|
||||||
|
"edited_by": "akiracraftwork@gmail.com",
|
||||||
|
"edited_at": "2026-03-03T04:44:03.309346Z",
|
||||||
|
"schedule": "0 0 * * * *",
|
||||||
|
"timezone": "Asia/Tokyo",
|
||||||
|
"enabled": true,
|
||||||
|
"script_path": "u/akiracraftwork/hourly_chime",
|
||||||
|
"is_flow": true,
|
||||||
|
"summary": null,
|
||||||
|
"extra_perms": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"workspace_id": "admins",
|
||||||
|
"path": "f/weather/weather_sync",
|
||||||
|
"edited_by": "akiracraftwork@gmail.com",
|
||||||
|
"edited_at": "2026-02-28T04:31:41.375049Z",
|
||||||
|
"schedule": "0 0 6 * * *",
|
||||||
|
"timezone": "Asia/Tokyo",
|
||||||
|
"enabled": true,
|
||||||
|
"script_path": "f/weather/weather_sync",
|
||||||
|
"is_flow": true,
|
||||||
|
"summary": null,
|
||||||
|
"extra_perms": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"workspace_id": "admins",
|
||||||
|
"path": "f/mail/mail_filter_schedule",
|
||||||
|
"edited_by": "akiracraftwork@gmail.com",
|
||||||
|
"edited_at": "2026-02-24T06:42:06.977249Z",
|
||||||
|
"schedule": "0 */10 * * * *",
|
||||||
|
"timezone": "Asia/Tokyo",
|
||||||
|
"enabled": true,
|
||||||
|
"script_path": "f/mail/mail_filter",
|
||||||
|
"is_flow": true,
|
||||||
|
"summary": null,
|
||||||
|
"extra_perms": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"workspace_id": "admins",
|
||||||
|
"path": "f/shiraou/shiraou_notification_every_5min",
|
||||||
|
"edited_by": "akiracraftwork@gmail.com",
|
||||||
|
"edited_at": "2026-02-21T06:18:34.967961Z",
|
||||||
|
"schedule": "0 */5 * * * *",
|
||||||
|
"timezone": "Asia/Tokyo",
|
||||||
|
"enabled": true,
|
||||||
|
"script_path": "f/shiraou/shiraou_notification",
|
||||||
|
"is_flow": true,
|
||||||
|
"summary": null,
|
||||||
|
"extra_perms": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"workspace_id": "admins",
|
||||||
|
"path": "u/antigravity/git_sync",
|
||||||
|
"edited_by": "akiracraftwork@gmail.com",
|
||||||
|
"edited_at": "2026-02-19T06:38:19.867037Z",
|
||||||
|
"schedule": "0 */30 * * * *",
|
||||||
|
"timezone": "Asia/Tokyo",
|
||||||
|
"enabled": true,
|
||||||
|
"script_path": "u/antigravity/git_sync",
|
||||||
|
"is_flow": true,
|
||||||
|
"summary": null,
|
||||||
|
"extra_perms": {}
|
||||||
|
}
|
||||||
|
]
|
||||||
18
state/scripts.list.json
Normal file
18
state/scripts.list.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"hash": "3783872112d1a24c",
|
||||||
|
"path": "u/admin/alexa_speak",
|
||||||
|
"summary": "Echo デバイスに TTS で読み上げ",
|
||||||
|
"created_at": "2026-03-03T02:57:13.068287Z",
|
||||||
|
"archived": false,
|
||||||
|
"extra_perms": {},
|
||||||
|
"language": "bun",
|
||||||
|
"starred": false,
|
||||||
|
"tag": null,
|
||||||
|
"description": "指定した Echo デバイスにテキストを読み上げさせる",
|
||||||
|
"has_draft": false,
|
||||||
|
"has_deploy_errors": false,
|
||||||
|
"ws_error_handler_muted": false,
|
||||||
|
"kind": "script"
|
||||||
|
}
|
||||||
|
]
|
||||||
83
test_imap.py
Normal file
83
test_imap.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
"""
|
||||||
|
IMAP接続診断スクリプト
|
||||||
|
使い方: python test_imap.py
|
||||||
|
"""
|
||||||
|
import imaplib
|
||||||
|
import ssl
|
||||||
|
import getpass
|
||||||
|
|
||||||
|
HOST = "outlook.office365.com"
|
||||||
|
PORT = 993
|
||||||
|
USER = "akiracraftworl@infoseek.jp"
|
||||||
|
|
||||||
|
print(f"IMAP診断: {HOST}:{PORT}")
|
||||||
|
print(f"ユーザー: {USER}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# --- Step 1: SSL接続テスト(認証なし)---
|
||||||
|
print("[1] SSL接続テスト...")
|
||||||
|
try:
|
||||||
|
ssl_ctx = ssl.create_default_context()
|
||||||
|
mail = imaplib.IMAP4_SSL(HOST, PORT, ssl_context=ssl_ctx)
|
||||||
|
print(" ✓ SSL接続成功")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ❌ SSL接続失敗: {e}")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
# --- Step 2: サーバーの認証方式を確認 ---
|
||||||
|
print("[2] サーバー対応認証方式を確認...")
|
||||||
|
try:
|
||||||
|
typ, caps_data = mail.capability()
|
||||||
|
caps = caps_data[0].decode() if caps_data and caps_data[0] else ""
|
||||||
|
print(f" CAPABILITY: {caps}")
|
||||||
|
|
||||||
|
if "AUTH=PLAIN" in caps or "AUTH=LOGIN" in caps:
|
||||||
|
print(" ✓ 基本認証(パスワード)が使えます")
|
||||||
|
basic_auth_supported = True
|
||||||
|
else:
|
||||||
|
basic_auth_supported = False
|
||||||
|
|
||||||
|
if "AUTH=XOAUTH2" in caps or "AUTH=OAUTHBEARER" in caps:
|
||||||
|
print(" ⚠ モダン認証(OAuth2)が必要な可能性があります")
|
||||||
|
|
||||||
|
if not basic_auth_supported:
|
||||||
|
print(" ❌ 基本認証は対応していません → OAuth2が必要です")
|
||||||
|
mail.logout()
|
||||||
|
exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" CAPABILITY取得エラー: {e}")
|
||||||
|
|
||||||
|
# --- Step 3: ログインテスト ---
|
||||||
|
print("[3] ログインテスト...")
|
||||||
|
password = getpass.getpass(" パスワードを入力: ")
|
||||||
|
|
||||||
|
try:
|
||||||
|
mail.login(USER, password)
|
||||||
|
print(" ✓ ログイン成功!")
|
||||||
|
|
||||||
|
mail.select("INBOX")
|
||||||
|
_, data = mail.uid("SEARCH", None, "ALL")
|
||||||
|
uids = data[0].split() if data[0] else []
|
||||||
|
print(f" ✓ INBOX: {len(uids)}件")
|
||||||
|
mail.logout()
|
||||||
|
print()
|
||||||
|
print("✅ 成功!Windmillに登録できます。")
|
||||||
|
|
||||||
|
except imaplib.IMAP4.error as e:
|
||||||
|
err = str(e)
|
||||||
|
print(f" ❌ ログイン失敗: {err}")
|
||||||
|
print()
|
||||||
|
if "disabled" in err.lower() or "imap" in err.lower():
|
||||||
|
print(" 原因: IMAPが無効化されています")
|
||||||
|
print(" 対処: https://outlook.live.com → 設定 → メールの同期 → IMAPを有効化")
|
||||||
|
elif "AUTHENTICATE" in err or "XOAUTH" in err:
|
||||||
|
print(" 原因: モダン認証(OAuth2)が必要です")
|
||||||
|
else:
|
||||||
|
print(" 原因1: パスワードが間違っている")
|
||||||
|
print(" 原因2: IMAPが無効(Outlook.com設定を再確認)")
|
||||||
|
print(" 原因3: モダン認証が必要")
|
||||||
|
print()
|
||||||
|
print(" 確認してください:")
|
||||||
|
print(" → https://outlook.live.com にこのメアドとパスワードでログインできますか?")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ❌ エラー: {type(e).__name__}: {e}")
|
||||||
@@ -1 +1 @@
|
|||||||
defaultTs: bun
|
defaultTs: bun
|
||||||
|
|||||||
Reference in New Issue
Block a user