Compare commits

..

30 Commits

Author SHA1 Message Date
akira
4d6adeafdc ドキュメント作成 2026-04-04 09:57:03 +09:00
akira
8c90fe79bc Alexaの認証を延長するもの 2026-04-04 09:54:20 +09:00
akira
555940d8f4 未コミットを一括コミット 2026-04-04 09:15:09 +09:00
Akira
ef7c9d3c21 メールフィルターアップデート 2026-04-04 09:03:05 +09:00
Akira
8ef4cfd81e mail_filter: map To recipients to xserver mailbox codes 2026-03-05 15:02:54 +09:00
Akira
dccca90835 mail_filter: use per-mailbox xserver account codes 2026-03-05 14:25:08 +09:00
Akira
bb958b3554 docs: アーカイブ後にAlexa中間文書を整理削除 2026-03-04 12:31:42 +09:00
Akira
70f842f00e docs: Alexa TTS文書を30番に一本化しアーカイブ索引を追加 2026-03-04 12:28:02 +09:00
Akira
be5fd5a75b Merge branch 'main' of https://gitea.keinafarm.net/akira/windmill_workflow
# Conflicts:
#	docs/flow-manage/10_マスタードキュメント_Windmillフロー管理_API一本化編.md
2026-03-04 12:05:16 +09:00
Akira
0f6a2caa41 引き継ぎ用ドキュメントを作成しました。
今回の件は最初から再現可能な形で記録済みです。

新規作成: 11_引き継ぎ_alexa_speak_API反映後にUIドロップダウンが変わらない件.md
追記更新: 10_マスタードキュメント_Windmillフロー管理_API一本化編.md
記載内容は以下を含みます。

事象概要(API反映済みなのにUIが入力欄のまま)
当日の時系列
確認済み事実(hash・schemaキー)
想定原因
再現時の標準対応手順(API確認 -> UI再読込 -> Edit -> Deploy)
今後の引き継ぎ時チェックポイント
必要ならこのままコミット用メッセージ案も作ります。
2026-03-04 02:00:13 +09:00
Akira
9dec4b3ace ワークフロー全体の情報取得機能追加 2026-03-03 16:13:46 +09:00
Akira
d129777bf1 | 2026-03-03 | 運用単位を workflow package(flow + schedules)へ変更し、実装計画とRunbookを更新 |
| 2026-03-03 | `delete -> create` と hash 管理の運用ガード(preflight / fail closed / post-verify)およびCRLF対策を追記 |
2026-03-03 15:49:07 +09:00
Akira
5a0a668a8a 取り込み二回目 2026-03-03 15:42:12 +09:00
Akira
9c67910f3d サーバーからフローを取得 2026-03-03 15:25:09 +09:00
Akira
0359072c30 pushテスト 2026-03-03 15:04:40 +09:00
Akira
f771e6bcf7 windmillワークフローの管理手順の変更仕様 2026-03-03 14:39:57 +09:00
Akira
d5bb7f24dd Claude Codeによる更新 2026-03-03 13:25:25 +09:00
Akira
4954cc0741 マスタードキュメント 2026-03-03 13:09:11 +09:00
Akira
1be261c95f ドキュメント作成の準備 2026-03-03 12:56:26 +09:00
Akira
07258bb46d ローカルで日本語を発話するようになった 2026-03-03 12:37:26 +09:00
Akira
fe9ee0147c ターゲットを変更 2026-03-03 12:02:55 +09:00
Akira
ee59724093 server.js を3点変更しました:
locale: 'ja-JP' → locale: ''(ローカルPCで成功していた設定)
Content-Length ヘッダーを除去(test_tts.js では送っていなかった、これが差異の一つ)
デバッグログを追加(Amazonへのリクエスト内容とレスポンスをログ出力)
認証問題について

test_tts.js(ローカルで成功)と server.js(サーバーで失敗)を比較した結果、実は使っているCookieは全く同じもの(.env から読み込んでいる)なので、認証情報自体の差は本来ないはずです。

ただし、気になる点が1つあります:

test_tts.js は locale: 'ja-JP' でローカルから成功しているのに、server.js は locale: 'ja-JP' でサーバーから失敗している

これは実は「どこから接続しているか(IPアドレス) で Amazon 側の挙動が変わっている」可能性を示唆します。ただし、先ほど確認したように keinafarm.net は大阪のIPなので、この説明も矛盾します。

デプロイ手順:

bash
# ローカルから scp でサーバーへ転送
scp alexa-api/server.js keinafarm-claude:/home/claude/alexa-api/server.js
# サーバーへSSHしてビルド&再起動
ssh keinafarm-claude 'cd /home/claude/alexa-api && sudo docker compose build && sudo docker compose up -d && sudo docker restart traefik'
デプロイ後、sudo docker logs alexa_api -f でログを確認して、[DEBUG] 行の内容を教えてください。どんな JSON が Amazon に送られているか、Amazon が何を返しているかが見えてきます。
2026-03-03 11:46:02 +09:00
Akira
b2a4012ab5 英語もダメ 2026-03-03 11:09:42 +09:00
Akira
9cbacec4c0 英語もだめ 2026-03-03 11:03:50 +09:00
Akira
dcbf599015 英語も発話しなくなった 2026-03-03 10:54:38 +09:00
Akira
9eb3c41584 日本語が発話されない件の2 2026-03-03 10:47:59 +09:00
Akira
0d9b2758e9 日本語が発話されない件の修正案 2026-03-03 10:05:25 +09:00
Akira
1496f4a5e6 fix: Alexa TTS で locale を空にして日本語発話を修正
locale: 'ja-JP' を指定すると一瞬音が出るだけで発話されない問題を修正。
locale: '' (空文字) を使用するとデバイス設定の言語 (ja-jp) が使われ、
日本語・英語・漢字・ひらがな・カタカナ全て正常に発話される。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 19:29:47 +09:00
Akira
34107f98a2 feat: Alexa TTS API サーバーを追加
- alexa-api/: Echo デバイスに TTS を送る Node.js API サーバー
  - server.js: alexa-remote2 を使わない直接 Alexa API 実装
    - GET /api/language で CSRF トークン取得
    - GET /api/bootstrap でカスタマー ID 取得
    - POST /api/behaviors/preview で TTS 実行
  - Dockerfile + docker-compose.yml: windmill_windmill-internal ネットワーク接続
  - auth4.js: Amazon Japan OpenID フローで Cookie 取得(WORKING)
- scripts/alexa_speak.ts: Windmill から alexa-api を呼び出すスクリプト

Windmill (u/admin/alexa_speak) → alexa_api:3500/speak → Echo デバイス の
パスで日本語 TTS が動作することを確認済み。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 16:34:22 +09:00
Akira
593d13d8a1 ローカルLLMにワークフローを作らせる 2026-03-02 15:23:50 +09:00
63 changed files with 9758 additions and 1159 deletions

View File

@@ -11,7 +11,10 @@
"Bash(printf:*)", "Bash(printf:*)",
"Bash(~/.git-credentials)", "Bash(~/.git-credentials)",
"Bash(git add:*)", "Bash(git add:*)",
"Bash(git commit:*)" "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
View File

@@ -0,0 +1 @@
*.sh text eol=lf

9
.gitignore vendored
View File

@@ -3,6 +3,15 @@
variables/ variables/
resources/ resources/
# Environment / secrets
.env
.env.local
# Python
.venv/
__pycache__/
*.pyc
# wmill CLI # wmill CLI
wmill-lock.yaml wmill-lock.yaml

2
.serena/.gitignore vendored Normal file
View File

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

152
.serena/project.yml Normal file
View 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 readonly.
# 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: {}

View File

@@ -24,7 +24,8 @@
windmill_workflow/ windmill_workflow/
├── flows/ # フロー定義JSON ├── flows/ # フロー定義JSON
│ ├── system_heartbeat.flow.json # Windmill自己診断フロー │ ├── system_heartbeat.flow.json # Windmill自己診断フロー
── shiraou_notification.flow.json # 白皇集落 変更通知フロー ── shiraou_notification.flow.json # 白皇集落 変更通知フロー
│ └── mail_filter.flow.json # メールフィルタリングフロー
├── docs/ ├── docs/
│ └── shiraou/ # 白皇集落営農組合関連ドキュメント │ └── shiraou/ # 白皇集落営農組合関連ドキュメント
│ ├── 19_windmill_通知ワークフロー連携仕様.md # API仕様書 │ ├── 19_windmill_通知ワークフロー連携仕様.md # API仕様書
@@ -43,6 +44,7 @@ windmill_workflow/
|------|------|-------------| |------|------|-------------|
| `f/app_custom/system_heartbeat` | Windmill自己診断 | なし(手動) | | `f/app_custom/system_heartbeat` | Windmill自己診断 | なし(手動) |
| `f/shiraou/shiraou_notification` | 白皇集落営農 変更通知 | 5分毎JST | | `f/shiraou/shiraou_notification` | 白皇集落営農 変更通知 | 5分毎JST |
| `f/mail/mail_filter` | メールフィルタリングIMAP→LLM→LINE | 10分毎JST予定 |
| `u/antigravity/git_sync` | Git同期 | 30分毎 | | `u/antigravity/git_sync` | Git同期 | 30分毎 |
## wm-api.sh コマンド一覧 ## wm-api.sh コマンド一覧
@@ -88,7 +90,34 @@ git push origin main
| `u/admin/LINE_CHANNEL_ACCESS_TOKEN` | ✅ | LINE Messaging APIトークン | | `u/admin/LINE_CHANNEL_ACCESS_TOKEN` | ✅ | LINE Messaging APIトークン |
| `u/admin/LINE_TO` | ✅ | LINE通知先IDユーザーまたはグループ | | `u/admin/LINE_TO` | ✅ | LINE通知先IDユーザーまたはグループ |
| `u/admin/SHIRAOU_LAST_CHECKED_AT` | ❌ | 前回確認時刻(ワークフローが自動更新) | | `u/admin/SHIRAOU_LAST_CHECKED_AT` | ❌ | 前回確認時刻(ワークフローが自動更新) |
| `u/admin/KEINASYSTEM_API_KEY` | ✅ | Keinasystem MAIL_API_KEY.envと同じ値 |
| `u/admin/KEINASYSTEM_API_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) - [白皇集落 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
View File

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

2
alexa-api/.gitignore vendored Normal file
View File

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

14
alexa-api/Dockerfile Normal file
View 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"]

View 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"

View 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
View 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
View 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
View 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
View 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(/&amp;/g, '&');
const match2 = html.match(/name="signIn"[^>]+action="([^"]+)"/);
if (match2) return match2[1].replace(/&amp;/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
View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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
View 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);
});

View 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

File diff suppressed because it is too large Load Diff

18
alexa-api/package.json Normal file
View 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
View 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
View 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);

View 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

View 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 ファイルを確認してください。")

View 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)

View 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} 秒以内に完了しませんでした")

View 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)

View File

@@ -0,0 +1,3 @@
httpx
jsonschema
python-dotenv

View 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()

View 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))

View 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

View 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
View File

@@ -0,0 +1 @@
34292

View File

@@ -21,6 +21,7 @@
--- ---
## 1. 機能概要 ## 1. 機能概要
### 目的 ### 目的

View 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
**ローカル PCWindowsでのみ実行可能**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. 取得した Cookieat-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`** で devDependenciesalexa-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`)を統合。中間資料のアーカイブ索引を追加 |

View 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`

View 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 フローを自前で実装。ローカル PCWindowsでのみ実行する
# ローカル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 へのリダイレクトをたどる
取得した Cookieat-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 が海外IPkeinafarm.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

File diff suppressed because it is too large Load Diff

View 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`

View File

@@ -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`

View 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運用全体の親ドキュメントとして維持する。

View 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 packageflow + 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反映APIworkflow 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 packageflow + 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 packageflow + 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`)を追記 |

24
flows/git_sync.flow.json Normal file
View 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": []
}
}

View 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"
}
}

View 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": []
}
}

File diff suppressed because one or more lines are too long

View File

@@ -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通知"
} }
] ]
}, },

View File

@@ -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
View 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": []
}
}

View 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
View 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
View 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
}
]

View 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
View 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
View 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
View 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
View 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}")

964
wm-api.sh

File diff suppressed because it is too large Load Diff