feat: get-voice-mail app-portalキッカー追加、app-portal UIをBootstrap化

get-voice-mailはn8n Webhookへの中継専用アプリ(SCP取得・プリザンター書込はn8n側で実施)。
app-portalはBootstrap 5導入で見た目を刷新(カード/モーダル/トースト化)。
dokploy-webapp SKILLはGitea統合(NodeSrv→ken_nogi)後の設定変更を反映。
This commit is contained in:
Kenichiro NOGI 2026-09-19 11:06:02 +09:00
parent 4b8498ba2d
commit 76700845fe
14 changed files with 452 additions and 47 deletions

View File

@ -0,0 +1,125 @@
---
name: dokploy-webapp
description: NodeSrv配下apps/<app-name>にNode.js WEBアプリを新規作成し、ローカル開発から既存Dokploy環境AWS Lightsail上で稼働中のセルフホストPaaSへのデプロイ・運用までを行う。「新しいアプリを作って」「Dokployにデプロイして」「アプリをNodeSrvに追加して」「動作検証して」等の依頼で使う。
---
# NodeSrv アプリ開発 & Dokployデプロイ
`NodeSrv/apps/<app-name>` を1アプリ1フォルダ1Dokploy Composeとして管理する。アプリ同士は完全に独立し、片方の開発・再デプロイがもう片方に影響しない設計。
## 新規アプリ作成
1. `NodeSrv/apps/_template``NodeSrv/apps/<app-name>` としてコピー
2. `package.json``name``<app-name>` に変更
3. 必要な依存関係を追加、`src/index.js` に実装を追加していく
4. `/health` エンドポイントは削除・変更しないDokployのヘルスチェック対象
## ローカル開発
```
cd NodeSrv/apps/<app-name>
npm install
cp .env.example .env
npm run dev
```
`http://localhost:<PORT>/``/health` で動作確認。
## デプロイ前のDocker動作確認必須
Dokployは本番でDockerfileから直接ビルドするため、pushする前にローカルでも同じ手順でビルド・起動できることを確認する。
```
docker compose -f docker-compose.local.yml up --build
```
エラー無く起動し `/health` が200を返すことを確認してから次に進む。
## Gitソース: Gitea
GitHub直接連携ではなく、Dokploy上に自前ホストしたGitea`https://git73.next-hd.net/mygit-admin/ken_nogi.git`、リモート名`gitea`)を経由する。理由: モノレポ(`ken_nogi`のプライベートリポジトリをDokployのGit Provider連携で直接使う設定がうまくいかず、Gitea自前ホストに切り替えた経緯がある詳細はプロジェクトメモリ参照
**注意2026-09-19判明**: 当初はGitea上に`NodeSrv`という単体リポジトリを作って運用していたが、その後`dev`配下NodeSrv/Pleasanter等を`ken_nogi`という1つのGiteaリポジトリへ統合した。`NodeSrv`単体リポジトリは既に削除済み(`git73.next-hd.net/mygit-admin/NodeSrv`は404。Dokploy側の既存Composeapp-portal/n8n等は統合後もしばらく古い`giteaRepository: "NodeSrv"`のままになっていて、pushしても反映されない状態だった。2026-09-19に全Composeapp-portal/authgw-poc/auth-redirect/portal-sample-batch/n8n/keycloak-monitor/lineworks-user-authを`giteaRepository: "ken_nogi"`・`composePath`の先頭に`NodeSrv/`を付ける形へ`dokploy compose update`で修正済み。**新規Compose作成時は`giteaRepository`に`ken_nogi`、`composePath`は必ず`NodeSrv/apps/<app-name>/docker-compose.yml`(先頭に`NodeSrv/`が要る)を指定すること。**
```
git remote -v # "gitea" が登録済みか確認
git push gitea main
```
**注意**: Windows環境でBashツールからpushすると、Git Credential Manager (GCM) がGUIダイアログを出そうとして `tty` が無い環境では失敗する(`fatal: helper error (-1): User cancelled dialog.`)。その場合は一時的な `GIT_ASKPASS` スクリプトで認証情報(`AUTH_SECRETS.md` の Gitea項目を渡し、push後は必ずスクリプトを削除する。認証情報を `git push` のURLやBashコマンド引数に直接書かない。
## Dokployへの接続
`@dokploy/cli``npm install -g @dokploy/cli`)をインストール済みで使う。認証は `dokploy auth -u https://dokploy45.next-hd.net -t <token>` 済みでセッションが保持されている前提(**トークンは絶対にチャットに貼らせない・貼られたら即Revoke指示**。認証はユーザー自身のターミナルで実行させる)。以後は `dokploy <command>` をそのまま実行してよい(本番操作なので実行前に毎回内容を提示し確認を取る)。
**既存環境の実態**2026-07-25確認、CLI v0.29.4:
- NodeSrv用アプリは `webapps` Project配下に **Compose**Docker Compose、Provider: Gitea、`sourceType: gitea`として追加する。Dokploy標準の「Application」type・「Domains」タブは使わないREADME.md「共通ルール」参照
- `projectId: 3T-drY1Q377JcLeTiNTDK` / `environmentId: Cm0HjMIFyl11UdIcIGRy8``production`環境)
- **CLIのGET系コマンドはほぼ全滅**`compose one`, `docker get-containers*` 等が軒並み `Request failed with status code 400`。POST/PUT系`compose deploy`等は動く。状態確認はCLIに頼らずSSH直接調査下記を使う
- **原因特定済み2026-07-25**: `@dokploy/cli``dist/client.js`の`apiGet`関数がtRPCのGETクエリを組み立てる際、`apiPost`と違い`{json: params}`でラップせず生の`params`を`?input=`にJSON化しているだけの実装バグ。DokployのtRPCサーバーは`{"json": {...}}`形式のラップを要求するため、これがない全てのGETリクエストが400になる
- Dokploy tRPC API自体は正常。`x-api-key`ヘッダー付きで`GET {DOKPLOY_URL}/api/trpc/<endpoint>?input=<encodeURIComponent('{"json":{...}}')>`の形式で直接叩けば200が返ることを確認済み`compose.one`で実証。ポータル等でDokploy API連携が必要な場合は、CLIを介さずこの正しい形式で自前クライアントを実装すること
- CLI自体へのパッチは行わない方針グローバルnpmパッケージの一時修正は`npm update`等で消えるため、恒常的な対応にならない)
## DokployへのデプロイCLI手順
1アプリ = 1 Dokploy "Compose"。`webapps` Project配下に、`apps/<app-name>/docker-compose.yml` をCompose Pathとして指定する。
### 新規Compose作成CLI手順、2026-07-25実地検証済み
`dokploy compose create`はGitHub向けのデフォルト値で作成されるため、直後に`dokploy compose update`でGitea連携用の値へ切り替える2段階が必要。
```
# 1. Compose自体を作成webapps projectのenvironmentIdを指定
dokploy compose create \
--name "<app-name>" \
--environmentId "Cm0HjMIFyl11UdIcIGRy8" \
--composeType "docker-compose" \
--appName "<app-name>" \
--json
# → composeId が返る
# 2. sourceTypeをgiteaへ切替、リポジトリ・パスを指定
dokploy compose update \
--composeId "<上で得たcomposeId>" \
--sourceType "gitea" \
--giteaId "O5-CqLQwVdlzXw3KfmN-8" \
--giteaOwner "mygit-admin" \
--giteaRepository "ken_nogi" \
--giteaBranch "main" \
--composePath "NodeSrv/apps/<app-name>/docker-compose.yml" \
--json
# 3. デプロイ実行
dokploy compose deploy --composeId "<composeId>" --title "<変更内容>" --json
```
`serverId`は省略可(単一サーバー構成のため)。
## デプロイ後の検証
CLIの `compose one` 等GET系コマンドは400エラーで使えないため、以下のいずれかで代替する。
```
# SSH経由でコンテナ状態・ラベルを直接確認
ssh -i NodeSrv/Keys/LightsailDefaultKey-ap-northeast-1.pem ubuntu@dokploy45.next-hd.net \
"sudo docker ps --filter name=<app-name>"
ssh -i NodeSrv/Keys/LightsailDefaultKey-ap-northeast-1.pem ubuntu@dokploy45.next-hd.net \
"sudo docker inspect <container-name> --format '{{json .Config.Labels}}'"
# 外部疎通確認
curl -I https://<app-name>.next-hd.net/health
```
**SSHが自動モード分類器にブロックされる場合**2026-07-26確認、`sudo`付きコマンド等でブロックされることがあるは、上記57行目の自前fetchクライアント方式でDokploy側の状態デプロイ履歴の`status`、compose設定等を確認する。認証情報はCLIの`config.json``C:/Users/k.nogi/AppData/Roaming/npm/node_modules/@dokploy/cli/config.json`)から読み取り、結果は必ずファイルへ書き出してから必要な値だけ抽出する(レスポンスに機密情報が含まれるため画面に出さない)。デプロイ完了確認は`deployment.allByCompose`エンドポイントの`status`(`done`か)を見る。
- 割り当てたドメインへ `curl``/health` が200
- 別アプリを同時にredeployし、対象アプリ以外が無停止であることを確認独立性の担保
## アプリポータルへの登録(任意)
新規アプリを`NodeSrv/apps/app-portal`のダッシュボードに登録したい場合、`.env.example`にPORTAL_*変数を追加する(詳細は`NodeSrv/apps/_template/README.md`「アプリポータルへの登録」章、実装は`NodeSrv/apps/app-portal/src/dokployClient.js`参照)。
## 参考
- 全体の運用方針・認証ゲートウェイの詳細: [../../../NodeSrv/README.md](../../../NodeSrv/README.md)
- Dokployインフラの経緯: プロジェクトメモリ `project_nodesrv-dokploy-lightsail`

View File

@ -1,3 +1,22 @@
const logModalEl = document.getElementById('logModal');
const logModalBody = document.getElementById('logModalBody');
const logModal = logModalEl ? new bootstrap.Modal(logModalEl) : null;
const toastEl = document.getElementById('actionToast');
const toastBody = document.getElementById('actionToastBody');
const toast = toastEl ? new bootstrap.Toast(toastEl) : null;
function showToast(message, isError) {
if (!toast) {
alert(message);
return;
}
toastBody.textContent = message;
toastEl.classList.toggle('text-bg-danger', Boolean(isError));
toastEl.classList.toggle('text-bg-primary', !isError);
toast.show();
}
document.getElementById('cards').addEventListener('click', async (event) => { document.getElementById('cards').addEventListener('click', async (event) => {
const button = event.target.closest('button[data-action]'); const button = event.target.closest('button[data-action]');
if (!button) return; if (!button) return;
@ -6,18 +25,19 @@ document.getElementById('cards').addEventListener('click', async (event) => {
if (action === 'logs') { if (action === 'logs') {
const res = await fetch(`/api/compose/${composeId}/logs`); const res = await fetch(`/api/compose/${composeId}/logs`);
if (!res.ok) { if (!res.ok) {
alert(`ログ取得に失敗しました (status: ${res.status})`); showToast(`ログ取得に失敗しました (status: ${res.status})`, true);
return; return;
} }
const data = await res.json(); const data = await res.json();
alert(data.logs || 'ログなし'); logModalBody.textContent = data.logs || 'ログなし';
logModal.show();
return; return;
} }
const res = await fetch(`/api/compose/${composeId}/${action}`, { method: 'POST' }); const res = await fetch(`/api/compose/${composeId}/${action}`, { method: 'POST' });
if (res.ok) { if (res.ok) {
alert(`${action} を実行しました`); showToast(`${action} を実行しました`, false);
} else { } else {
alert(`${action} に失敗しました (status: ${res.status})`); showToast(`${action} に失敗しました (status: ${res.status})`, true);
} }
}); });

View File

@ -6,55 +6,84 @@ function escapeHtml(value) {
.replace(/"/g, '&quot;'); .replace(/"/g, '&quot;');
} }
const BOOTSTRAP_CSS =
'<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">';
const BOOTSTRAP_JS =
'<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>';
function renderLoginPage(errorMessage) { function renderLoginPage(errorMessage) {
const errorHtml = errorMessage ? `<p class="error">${escapeHtml(errorMessage)}</p>` : ''; const errorHtml = errorMessage
? `<div class="alert alert-danger" role="alert">${escapeHtml(errorMessage)}</div>`
: '';
return `<!DOCTYPE html> return `<!DOCTYPE html>
<html> <html lang="ja">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>App Portal Admin Login</title> <title>App Portal Admin Login</title>
${BOOTSTRAP_CSS}
</head> </head>
<body> <body class="bg-body-tertiary d-flex align-items-center" style="min-height:100vh">
<h1>Admin Login</h1> <main class="container" style="max-width:400px">
${errorHtml} <div class="card shadow-sm">
<form method="POST" action="/admin/login"> <div class="card-body">
<input type="password" name="masterKey" placeholder="Master Key" required> <h1 class="h4 mb-3">Admin Login</h1>
<button type="submit">Login</button> ${errorHtml}
</form> <form method="POST" action="/admin/login">
<div class="mb-3">
<input type="password" class="form-control" name="masterKey" placeholder="Master Key" required>
</div>
<button type="submit" class="btn btn-primary w-100">Login</button>
</form>
</div>
</div>
</main>
</body> </body>
</html>`; </html>`;
} }
function renderAdminPage(emails, errorMessage) { function renderAdminPage(emails, errorMessage) {
const errorHtml = errorMessage ? `<p class="error">${escapeHtml(errorMessage)}</p>` : ''; const errorHtml = errorMessage
? `<div class="alert alert-danger" role="alert">${escapeHtml(errorMessage)}</div>`
: '';
const rows = emails const rows = emails
.map( .map(
(email) => `<li> (email) => `<li class="list-group-item d-flex justify-content-between align-items-center">
${escapeHtml(email)} ${escapeHtml(email)}
<form method="POST" action="/admin/allowlist/remove" style="display:inline"> <form method="POST" action="/admin/allowlist/remove" class="mb-0">
<input type="hidden" name="email" value="${escapeHtml(email)}"> <input type="hidden" name="email" value="${escapeHtml(email)}">
<button type="submit">削除</button> <button type="submit" class="btn btn-outline-danger btn-sm">削除</button>
</form> </form>
</li>` </li>`
) )
.join('\n'); .join('\n');
return `<!DOCTYPE html> return `<!DOCTYPE html>
<html> <html lang="ja">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Allowlist Admin</title> <title>Allowlist Admin</title>
${BOOTSTRAP_CSS}
</head> </head>
<body> <body class="bg-body-tertiary">
<h1>Allowlist Admin</h1> <nav class="navbar navbar-dark bg-dark mb-4">
${errorHtml} <div class="container">
<ul>${rows}</ul> <span class="navbar-brand mb-0 h1">Allowlist Admin</span>
<form method="POST" action="/admin/allowlist/add"> <a class="btn btn-outline-light btn-sm" href="/">ダッシュボード</a>
<input type="email" name="email" placeholder="user@example.com" required> </div>
<button type="submit">追加</button> </nav>
</form> <main class="container pb-5" style="max-width:600px">
<form method="POST" action="/admin/logout"> ${errorHtml}
<button type="submit">ログアウト</button> <ul class="list-group mb-3">${rows}</ul>
</form> <form method="POST" action="/admin/allowlist/add" class="input-group mb-4">
<input type="email" class="form-control" name="email" placeholder="user@example.com" required>
<button type="submit" class="btn btn-primary">追加</button>
</form>
<form method="POST" action="/admin/logout">
<button type="submit" class="btn btn-outline-secondary btn-sm">ログアウト</button>
</form>
</main>
${BOOTSTRAP_JS}
</body> </body>
</html>`; </html>`;
} }

View File

@ -37,30 +37,50 @@ async function buildCards(environmentId) {
return cards; return cards;
} }
const STATE_BADGE = {
running: 'bg-success',
stopped: 'bg-secondary',
unknown: 'bg-warning text-dark',
};
function renderCard(card) { function renderCard(card) {
if (card.error) { if (card.error) {
return `<div class="card card-error" data-compose-id="${escapeHtml(card.composeId)}"> return `<div class="col-12 col-md-6 col-lg-4">
<h3>${escapeHtml(card.name || card.composeId)}</h3> <div class="card border-danger-subtle h-100" data-compose-id="${escapeHtml(card.composeId)}">
<p>状態取得失敗</p> <div class="card-body">
<h3 class="card-title h5">${escapeHtml(card.name || card.composeId)}</h3>
<p class="card-text text-danger mb-0">状態取得失敗</p>
</div>
</div>
</div>`; </div>`;
} }
const { composeId, composeStatus, containerState, meta } = card; const { composeId, composeStatus, containerState, meta } = card;
const badgeClass = STATE_BADGE[containerState] || 'bg-warning text-dark';
const openLink = const openLink =
meta.appType === 'web' && meta.url meta.appType === 'web' && meta.url
? `<a href="${escapeHtml(meta.url)}" target="_blank" rel="noopener">開く</a>` ? `<a class="btn btn-outline-primary btn-sm" href="${escapeHtml(meta.url)}" target="_blank" rel="noopener">開く</a>`
: ''; : '';
const actionButtons = const actionButtons =
meta.appType === 'web' meta.appType === 'web'
? `<button data-action="start" data-compose-id="${escapeHtml(composeId)}">Start</button> ? `<button class="btn btn-success btn-sm" data-action="start" data-compose-id="${escapeHtml(composeId)}">Start</button>
<button data-action="stop" data-compose-id="${escapeHtml(composeId)}">Stop</button> <button class="btn btn-danger btn-sm" data-action="stop" data-compose-id="${escapeHtml(composeId)}">Stop</button>
<button data-action="restart" data-compose-id="${escapeHtml(composeId)}">Restart</button>` <button class="btn btn-warning btn-sm" data-action="restart" data-compose-id="${escapeHtml(composeId)}">Restart</button>`
: `<button data-action="trigger" data-compose-id="${escapeHtml(composeId)}">今すぐ実行</button>`; : `<button class="btn btn-primary btn-sm" data-action="trigger" data-compose-id="${escapeHtml(composeId)}">今すぐ実行</button>`;
return `<div class="card" data-compose-id="${escapeHtml(composeId)}"> return `<div class="col-12 col-md-6 col-lg-4">
<h3>${escapeHtml(meta.label)}</h3> <div class="card h-100" data-compose-id="${escapeHtml(composeId)}">
<p>compose: ${escapeHtml(composeStatus)} / container: ${escapeHtml(containerState)}</p> <div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h3 class="card-title h5 mb-0">${escapeHtml(meta.label)}</h3>
<span class="badge ${badgeClass}">${escapeHtml(containerState)}</span>
</div>
<p class="card-text text-muted small mb-3">compose: ${escapeHtml(composeStatus)}</p>
<div class="mt-auto d-flex flex-wrap gap-2">
${openLink} ${openLink}
${actionButtons} ${actionButtons}
<button data-action="logs" data-compose-id="${escapeHtml(composeId)}">ログ表示</button> <button class="btn btn-outline-secondary btn-sm" data-action="logs" data-compose-id="${escapeHtml(composeId)}">ログ表示</button>
</div>
</div>
</div>
</div>`; </div>`;
} }
@ -68,14 +88,45 @@ async function renderDashboard(environmentId) {
const cards = await buildCards(environmentId); const cards = await buildCards(environmentId);
const cardsHtml = cards.map(renderCard).join('\n'); const cardsHtml = cards.map(renderCard).join('\n');
return `<!DOCTYPE html> return `<!DOCTYPE html>
<html> <html lang="ja">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>App Portal</title> <title>App Portal</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
</head> </head>
<body> <body class="bg-body-tertiary">
<h1>App Portal</h1> <nav class="navbar navbar-dark bg-dark mb-4">
<div id="cards">${cardsHtml}</div> <div class="container">
<span class="navbar-brand mb-0 h1">App Portal</span>
<a class="btn btn-outline-light btn-sm" href="/admin">管理</a>
</div>
</nav>
<main class="container pb-5">
<div id="cards" class="row g-3">${cardsHtml}</div>
</main>
<div class="modal fade" id="logModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">ログ</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<pre id="logModalBody" class="mb-0 small"></pre>
</div>
</div>
</div>
</div>
<div class="toast-container position-fixed bottom-0 end-0 p-3">
<div id="actionToast" class="toast align-items-center text-bg-primary border-0" role="status" aria-live="polite" aria-atomic="true">
<div class="d-flex">
<div class="toast-body" id="actionToastBody"></div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="/portal.js"></script> <script src="/portal.js"></script>
</body> </body>
</html>`; </html>`;

View File

@ -0,0 +1,4 @@
node_modules
npm-debug.log
.env
.git

View File

@ -0,0 +1,14 @@
PORT=3000
NODE_ENV=development
# app-portalキッカー登録用
PORTAL_APP_TYPE=batch
PORTAL_APP_LABEL=ボイスメール取得
PORTAL_APP_URL=http://get-voice-mail:3000
PORTAL_TRIGGER_PATH=/trigger
PORTAL_SECRET=
# n8nワークフロー入口(Webhookード)のURL。/trigger起動時にPOSTする先
N8N_WEBHOOK_URL=https://n8n32.next-hd.net/webhook/get-voice-mail
# n8n Webhookード側のHeader Auth Credential値(X-Api-Keyヘッダーに設定)
N8N_WEBHOOK_SECRET=

View File

@ -0,0 +1,13 @@
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --omit=dev
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY . .
USER node
EXPOSE 3000
CMD ["node", "src/index.js"]

View File

@ -0,0 +1,7 @@
services:
get-voice-mail:
build: .
ports:
- "3002:3000"
env_file:
- .env

View File

@ -0,0 +1,14 @@
services:
get-voice-mail:
build: .
expose:
- 3000
env_file:
- .env
networks:
- dokploy-network
restart: unless-stopped
networks:
dokploy-network:
external: true

View File

@ -0,0 +1,14 @@
{
"name": "get-voice-mail",
"version": "0.1.0",
"private": true,
"type": "commonjs",
"scripts": {
"dev": "node --watch src/index.js",
"start": "node src/index.js",
"test": "node --test"
},
"dependencies": {
"express": "^4.21.0"
}
}

View File

@ -0,0 +1,59 @@
# get-voice-mail
## 仕様
- n8nを利用
- SCPでサーバーから録音データ取得
- プリザンターに音声保存
## サーバー情報
pbx-server-secret.md 参照
## voiceメールのパス
/var/spool/asterisk/monitor
この下に、年フォルダ、月フォルダ、日フォルダが階層化されている
取得ファイルルール ※あとで変わるかもだがいまは固定ルール
- ファイル名の先頭がin-0420002706-で始まるもの
{方向}-{番号A}-{番号B}-{日付}-{時刻}-{uniqueid}.wav
- 方向: in=着信 / out=発信 / generic=内線間 or 方向判定不能時
- 番号A: 着信の場合は着信先(DID/内線番号)、発信の場合は発信元内線
- 番号B: 相手側番号(外部番号 or 内線)
- 日付: YYYYMMDD
- 時刻: HHMMSS
- uniqueid: Asteriskチャンネル固有ID (epoch秒.マイクロ秒)。通話開始時刻ベース、重複なし識別子
例:
- in-0420002706-05057998188-20260918-165846-1789718326.114385.wav
→ 着信、着信先0420002706、相手05057998188、2026-09-18 16:58:46
- generic-0425400505-05017446045-20260918-170343-1789718623.114390.wav
→ 内線間 or 方向不明、番号0425400505⇔05017446045
ファイル名からいつの音声データなのか日付情報も算出
ファイル名の日付はJST
## プリザンター
取得した音声ファイルと内容をプリザンターのテーブルへ書き出し
https://nextoffice.next-hd.co.jp/pleasanter/items/515888/index
- ClassA番号A
- ClassB番号B
- ClassCWAVファイル名 ※すでに同じファイルが登録済みの場合はスキップ
- AttachmentAWAV本体保存
## n8nワークフロー起動方式
入口=Webhookード固定。cron等の自前スケジューラは使わない。
起動元=app-portal`NodeSrv/apps/app-portal`のキッカーbatch型トリガー
実装=`NodeSrv/apps/get-voice-mail`直下のNode.jsアプリ他アプリと同じ1フォルダ1Dokploy Compose
役割=中継のみ。`/trigger`受信→n8n Webhook URLへPOST→202即返却。SCP取得・WAV判定・プリザンター書込は全部n8n側workflow内で実施。
- POST `/trigger`(ヘッダ`X-Portal-Secret`必須、`PORTAL_SECRET`と一致時のみ許可)
- 認証通過後、環境変数`N8N_WEBHOOK_URL`n8n Webhookードの入口URLへPOST、結果を待たず202返却
- `.env.example`にPORTAL_APP_TYPE=batch等のポータル登録変数一式を用意済み。Dokploy Environment画面で`PORTAL_SECRET`・`N8N_WEBHOOK_URL`を設定してから使う
- ポータル管理・トリガー実装パターンの詳細は`NodeSrv/apps/_template/README.md`「アプリポータルへの登録」章、実装例は`NodeSrv/apps/portal-sample-batch`参照

View File

@ -0,0 +1,39 @@
const express = require('express');
const { isTriggerAuthorized } = require('./triggerAuth');
const app = express();
const PORT = process.env.PORT || 3000;
const PORTAL_SECRET = process.env.PORTAL_SECRET;
const N8N_WEBHOOK_URL = process.env.N8N_WEBHOOK_URL;
const N8N_WEBHOOK_SECRET = process.env.N8N_WEBHOOK_SECRET;
app.get('/', (req, res) => {
res.json({ app: 'get-voice-mail', status: 'ok' });
});
app.get('/health', (req, res) => {
res.status(200).json({ status: 'healthy' });
});
app.post('/trigger', (req, res) => {
if (!isTriggerAuthorized(req.headers['x-portal-secret'], PORTAL_SECRET)) {
res.sendStatus(403);
return;
}
if (!N8N_WEBHOOK_URL) {
console.error('get-voice-mail: N8N_WEBHOOK_URL未設定');
res.sendStatus(500);
return;
}
fetch(N8N_WEBHOOK_URL, {
method: 'POST',
headers: N8N_WEBHOOK_SECRET ? { 'X-Api-Key': N8N_WEBHOOK_SECRET } : {},
}).catch((err) => {
console.error('get-voice-mail: n8n webhook起動失敗', err.message);
});
res.sendStatus(202);
});
app.listen(PORT, () => {
console.log(`get-voice-mail listening on port ${PORT}`);
});

View File

@ -0,0 +1,5 @@
function isTriggerAuthorized(headerValue, expectedSecret) {
return Boolean(expectedSecret) && headerValue === expectedSecret;
}
module.exports = { isTriggerAuthorized };

View File

@ -0,0 +1,11 @@
const { test } = require('node:test');
const assert = require('node:assert');
const { isTriggerAuthorized } = require('../src/triggerAuth');
test('isTriggerAuthorized returns true only when the header matches a non-empty expected secret', () => {
assert.strictEqual(isTriggerAuthorized('correct', 'correct'), true);
assert.strictEqual(isTriggerAuthorized('wrong', 'correct'), false);
assert.strictEqual(isTriggerAuthorized(undefined, 'correct'), false);
assert.strictEqual(isTriggerAuthorized('anything', undefined), false);
assert.strictEqual(isTriggerAuthorized('anything', ''), false);
});