commit c1ce766b63aa0e52886e4dd4956c3b4b01798fb7 Author: hiroki ito Date: Fri Apr 3 20:53:06 2026 +0900 初回配布 Made-with: Cursor diff --git a/.clasp.json.example b/.clasp.json.example new file mode 100644 index 0000000..bc4bb0e --- /dev/null +++ b/.clasp.json.example @@ -0,0 +1,4 @@ +{ + "scriptId": "YOUR_SCRIPT_ID_HERE", + "rootDir": "src" +} diff --git a/.claspignore b/.claspignore new file mode 100644 index 0000000..c0059e8 --- /dev/null +++ b/.claspignore @@ -0,0 +1,3 @@ +**/** +!src/**/*.gs +!appsscript.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b3404d5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.clasp.json +.clasprc.json +node_modules/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..e49f29b --- /dev/null +++ b/README.md @@ -0,0 +1,266 @@ +# 朝活遅刻罰金Bot + +朝活グループの遅刻を記録し、罰金を自動集計する LINE Bot です。 + +LINEグループに Bot を追加して「遅刻」と投稿するだけで、誰が何回遅刻したか、今月いくら払うべきかが自動で管理されます。毎月1日に前月の集計レポートが届き、メンバー同士で精算する運用です。 + +## 仕組み(3パーツ) + +| パーツ | 料理の比喩 | このツールでは | +|---|---|---| +| トリガー | 伝票 | LINE Webhook(コマンド投稿時)+ GAS 時間トリガー(毎月1日) | +| ソース元 | 冷蔵庫 | Google スプレッドシート(遅刻記録ログ) | +| 処理する場所 | キッチン | Google Apps Script(コマンド解析・集計・レポート生成) | + +## 全体の構成 + +``` +LINE グループ + ↓ Webhook(コマンド投稿) +Google Apps Script(doPost で直接受信) + ↓ コマンド解析 → スプレッドシート読み書き + ↓ LINE API で返信 +LINE グループ +``` + +LINE のメッセージを GAS が直接受け取り、処理結果を LINE API で返信します。プロキシサーバーは不要です。 + +## コマンド一覧 + +| コマンド | 動作 | +|---|---| +| `遅刻` | 自分の遅刻を記録 | +| `遅刻 @名前` | 他の人の遅刻を代理で記録 | +| `追加 ○回` | 自分の過去の遅刻をまとめて追加(例: `追加 3回`) | +| `追加 @名前 ○回` | 他の人の過去分を代理で追加 | +| `集計` | 今月の遅刻回数と罰金額を表示 | +| `履歴` | 直近10件の記録を表示 | +| `取消` | 自分の遅刻を1件取り消し | +| `取消 @名前` | 他の人の遅刻を代理で取り消し | +| `ヘルプ` | コマンド一覧を表示 | + +遅刻1回 = 500円。毎月1日に前月の集計が自動で届きます。 + +## 出力例 + +`遅刻` と投稿すると: +``` +⏰ 田中 遅刻3回目(1,500円) +``` + +他の人の分を代理記録すると: +``` +⏰ 田中 遅刻3回目(1,500円)※鈴木が記録 +``` + +`集計` と投稿すると: +``` +📊 2026年4月の遅刻集計 +━━━━━━━━━━━━━━━ +山田: 5回(2,500円) +田中: 3回(1,500円) +鈴木: 1回(500円) +━━━━━━━━━━━━━━━ +合計: 9回(4,500円) +遅刻なし: 佐藤 +``` + +`履歴` と投稿すると: +``` +📋 直近の記録 +━━━━━━━━━━━━━━━ +4/2 13:08 遅刻 田中 +4/2 09:15 遅刻 山田 ※鈴木が記録 +4/1 08:30 追加 山田 ×2回 +3/31 19:00 取消 田中 +``` + +--- + +## セットアップ手順 + +### 準備するもの + +- Google アカウント +- LINE アカウント(スマートフォン) + +--- + +### Part A: LINE の準備 + +#### 手順1: LINE 公式アカウントを作る + +1. [LINE Official Account Manager](https://manager.line.biz/) にアクセス +2. 「アカウントを作成」→ 認証済みアカウントではなく「未認証アカウント」で作成 +3. アカウント名は「朝活Bot」など自由に設定 + +#### 手順2: Messaging API を有効にする + +1. LINE Official Account Manager の「設定」→「Messaging API」を開く +2. 「Messaging API を利用する」をクリック +3. プロバイダーを選択(なければ新規作成) +4. [LINE Developers Console](https://developers.line.biz/console/) が開く + +#### 手順3: チャネルアクセストークンを取得 + +LINE Official Account Manager の Messaging API 画面には「Channel ID」と「Channel secret」が表示されますが、**このBotに必要なのはどちらでもありません**。必要なのは「チャネルアクセストークン(長期)」で、LINE Developers Console で発行します。 + +1. [LINE Developers Console](https://developers.line.biz/console/) を開く +2. プロバイダー → 手順2で作成したチャネルを選択 +3. **「Messaging API設定」タブ**を開く +4. ページ下部の「チャネルアクセストークン(長期)」→ **「発行」をクリック** +5. 表示されたトークンをコピーしてメモしておく + +**このトークンは他の人に見せないでください。** 後で GAS に設定します。 + +--- + +### Part B: GAS の構築 + +#### 手順4: Google スプレッドシートを作る + +1. [Google スプレッドシート](https://sheets.google.com/) で新しいスプレッドシートを作成 +2. 名前は「朝活Bot」など自由に設定 +3. シートは空のままで OK(Bot が初回実行時に自動作成します) + +#### 手順5: Google Apps Script にコードを貼り付ける + +1. スプレッドシートの「拡張機能」→「Apps Script」を開く +2. 左側のファイル一覧で、最初からある `コード.gs` を削除する +3. 以下の6ファイルを1つずつ追加する: + +**ファイルの追加方法**: 左側の「+」→「スクリプト」をクリック → ファイル名を入力(拡張子 `.gs` は不要) + +| ファイル名 | このリポジトリのパス | +|---|---| +| `config` | `src/config.gs` | +| `line` | `src/line.gs` | +| `store` | `src/store.gs` | +| `commands` | `src/commands.gs` | +| `penalty` | `src/penalty.gs` | +| `main` | `src/main.gs` | + +各ファイルの中身をコピー&ペーストしてください。 + +4. `appsscript.json` を編集する: + - 左上の歯車アイコン(プロジェクトの設定)をクリック + - 「『appsscript.json』マニフェスト ファイルをエディタで表示する」にチェック + - 左側に表示された `appsscript.json` を開き、このリポジトリの `appsscript.json` の内容で上書き + +#### 手順6: 初期設定(トークンの登録) + +1. Apps Script エディタでスプレッドシートに戻る +2. スプレッドシートを**リロード**する +3. メニューに「朝活Bot」が表示される(初回は数秒かかる場合があります) +4. 「朝活Bot」→「初期設定」をクリック +5. 手順3でメモした**チャネルアクセストークン(長期)**を入力 → OK + +初回実行時に Google から「承認が必要です」と表示されます。「権限を確認」→ 自分の Google アカウントを選択 → 「詳細」→「(安全でないページに移動)」→「許可」の順に進めてください。 + +#### 手順7: Web App としてデプロイ + +1. Apps Script エディタに戻る +2. 右上の「デプロイ」→「新しいデプロイ」 +3. 種類の選択で歯車アイコン → 「ウェブアプリ」を選択 +4. 設定: + - **説明**: 「朝活Bot」 + - **次のユーザーとして実行**: 「自分」 + - **アクセスできるユーザー**: 「全員」 +5. 「デプロイ」をクリック +6. 表示された **ウェブアプリの URL** をコピー(`https://script.google.com/macros/s/.../exec` の形式) + +--- + +### Part C: LINE と接続 + +#### 手順8: Webhook URL を設定 + +1. [LINE Developers Console](https://developers.line.biz/console/) でチャネルを開く +2. 「Messaging API設定」タブ → 「Webhook URL」に **手順7の GAS デプロイ URL** を貼り付け +3. 「Webhookの利用」をオンにする + +> **Webhook URL の検証について**: 「検証」ボタンを押すとエラーが表示される場合がありますが、実際のメッセージ送受信には影響しません。手順9に進んで実際にメッセージを送って動作確認してください。 + +4. [LINE Official Account Manager](https://manager.line.biz/) の「設定」→「応答設定」を開く +5. **応答メッセージ**: オフ(LINE のデフォルト自動応答を無効にする) + +#### 手順9: Bot のグループ参加を許可する + +Bot をグループに招待するには、事前に LINE Developers Console で参加許可の設定が必要です。この設定はデフォルトで無効になっています。 + +1. [LINE Developers Console](https://developers.line.biz/console/) でチャネルを開く +2. 「Messaging API設定」タブを開く +3. **「グループトーク・複数人トークへの参加を許可する」を有効にする** + +#### 手順10: Bot をグループに招待 + +1. LINE アプリで朝活グループを開く +2. メンバー追加から、手順1で作った公式アカウントを友だち追加 → グループに招待 +3. Bot がグループに参加すると、自動でウェルカムメッセージが届く +4. 「ヘルプ」と送信してコマンド一覧が表示されれば完了 + +#### 手順11: 月次レポートトリガーを設定(任意) + +毎月1日の朝に自動で前月の集計レポートが届くようにする: + +1. スプレッドシートのメニュー「朝活Bot」→「月次レポートトリガーを設定」 +2. 「毎月1日 7:00〜8:00 にトリガーを設定しました」と表示されれば OK + +--- + +## カスタマイズ + +### 罰金額を変更する + +`src/config.gs` の `PENALTY_AMOUNT` を変更する: + +```javascript +const PENALTY_AMOUNT = 1000; // 1回1,000円に変更 +``` + +変更後は Apps Script エディタで「デプロイ」→「デプロイを管理」→ 鉛筆アイコン → バージョンを「新バージョン」に変更 → 「デプロイ」。 + +### 集計レポートの見た目を変更する + +`src/penalty.gs` の `getSummary()` や `getMonthlyReport()` を編集する。 + +--- + +## 料金について + +- **LINE 公式アカウント**: 無料プラン(コミュニケーションプラン)で利用可能 +- **コマンドへの応答**(Reply Message): 通数にカウントされない(完全無料) +- **月次レポート**(Push Message): 送信先人数 × 1通でカウント。5人グループなら月5通。無料枠は月200通なので十分 +- **Google Apps Script / Google スプレッドシート**: 無料 + +--- + +## セキュリティについて + +GAS の Web App URL にはランダムなスクリプトID(64文字)が含まれており、この URL を知らない限りリクエストを送ることはできません。LINE Webhook の署名検証(`x-line-signature`)は GAS の仕様上実装できませんが、URL 自体が事実上のシークレットとして機能します。 + +**デプロイ URL は他の人に見せないでください。** + +--- + +## 制約・注意事項 + +- **Webhook URL 検証**: LINE Developers Console の「検証」ボタンはエラーを返すことがありますが、実際のメッセージ送受信には影響しません +- **1グループ限定**: 1つの GAS プロジェクト(スプレッドシート)で1つのグループを管理します。複数グループで使う場合は、グループごとにスプレッドシートと GAS プロジェクトを分けてください +- **初回の応答が遅い場合があります**: GAS は使っていない間はスリープ状態になります。しばらく使っていなかった後の最初のコマンドは、応答に数秒かかることがあります + +--- + +## clasp で開発する場合(上級者向け) + +[clasp](https://github.com/google/clasp) を使うとローカルで編集して GAS にプッシュできます。 + +```bash +npm install -g @google/clasp +clasp login +cp .clasp.json.example .clasp.json +# .clasp.json の scriptId を自分の GAS プロジェクトの ID に書き換える +clasp push +``` + +GAS プロジェクトのスクリプト ID は、Apps Script エディタの「プロジェクトの設定」→「スクリプト ID」で確認できます。 diff --git a/appsscript.json b/appsscript.json new file mode 100644 index 0000000..132ad4e --- /dev/null +++ b/appsscript.json @@ -0,0 +1,15 @@ +{ + "timeZone": "Asia/Tokyo", + "dependencies": {}, + "exceptionLogging": "STACKDRIVER", + "runtimeVersion": "V8", + "webapp": { + "executeAs": "USER_DEPLOYING", + "access": "ANYONE_ANONYMOUS" + }, + "oauthScopes": [ + "https://www.googleapis.com/auth/spreadsheets", + "https://www.googleapis.com/auth/script.external_request", + "https://www.googleapis.com/auth/script.scriptapp" + ] +} diff --git a/src/commands.gs b/src/commands.gs new file mode 100644 index 0000000..0ab0cf0 --- /dev/null +++ b/src/commands.gs @@ -0,0 +1,127 @@ +// ============================================================ +// commands.gs — コマンド解析 + ディスパッチ +// ============================================================ + +/** + * テキストメッセージを解析し、コマンドを実行して返信テキストを返す。 + * コマンドに一致しない場合は null を返す(グループ会話には無反応)。 + * + * senderResolver: () => { id, name } | null + * LINE API 呼び出しを含むため、コマンドがマッチした場合のみ呼ばれる。 + */ +function dispatch(text, senderResolver, groupId, mentionees) { + const trimmed = text.trim(); + if (trimmed.includes('\n')) return null; + + const cmd = parseCommand_(trimmed); + if (!cmd) return null; + + if (cmd.type === 'hint') return cmd.message; + if (cmd.type === 'summary') return getSummary(); + if (cmd.type === 'history') return getHistory(); + if (cmd.type === 'help') return getHelp(); + + // --- 以下、書き込み系コマンド --- + + if (cmd.type === 'add') { + const err = validateCount_(cmd.count); + if (err) return err; + } + + const sender = senderResolver(); + if (!sender) return null; + + let target; + if (cmd.targetName) { + const resolved = resolveTarget_(cmd.targetName, groupId, mentionees); + if (resolved.error) return resolved.error; + target = resolved; + } else { + target = sender; + } + + switch (cmd.type) { + case 'late': + return recordLate(target.id, target.name, sender.id, sender.name); + case 'cancel': + return cancelLate(target.id, target.name, sender.id, sender.name); + case 'add': + return addBulk(target.id, target.name, cmd.count, sender.id, sender.name); + default: + return null; + } +} + +// --- コマンドパース(純粋関数 — 副作用なし、バリデーションなし) --- + +function parseCommand_(text) { + if (text === 'ヘルプ') return { type: 'help' }; + if (text === '集計') return { type: 'summary' }; + if (text === '履歴') return { type: 'history' }; + if (text === '遅刻') return { type: 'late', targetName: null }; + if (text === '取消') return { type: 'cancel', targetName: null }; + + let m; + + m = text.match(/^遅刻\s+@(.+)$/); + if (m) return { type: 'late', targetName: m[1].trim() }; + + m = text.match(/^取消\s+@(.+)$/); + if (m) return { type: 'cancel', targetName: m[1].trim() }; + + m = text.match(/^追加\s+@(.+?)\s+(\d+)\s*回$/); + if (m) return { type: 'add', targetName: m[1].trim(), count: parseInt(m[2], 10) }; + + m = text.match(/^追加\s+(\d+)\s*回$/); + if (m) return { type: 'add', targetName: null, count: parseInt(m[1], 10) }; + + // 惜しいコマンド — ヒントを返す + if (/^遅刻\s+[^@]/.test(text)) { + return { type: 'hint', message: '他の人を記録するには「遅刻 @名前」と@を付けてください。' }; + } + if (/^(取り消し|とりけし)$/.test(text)) { + return { type: 'hint', message: '取り消しは「取消」と入力してください。' }; + } + + return null; +} + +/** + * 追加回数のバリデーション。不正ならエラーメッセージ文字列、正常なら null を返す。 + */ +function validateCount_(n) { + if (isNaN(n) || n < 1) { + return '「追加 3回」のように、1以上の数字を指定してください。'; + } + if (n > MAX_BULK_ADD) { + return '一度に追加できるのは最大' + MAX_BULK_ADD + '回までです。'; + } + return null; +} + +/** + * @名前テキストを User ID に解決する。 + * mention メタデータ → テキスト完全一致 の二段構え。 + */ +function resolveTarget_(nameText, groupId, mentionees) { + if (mentionees && mentionees.length > 0) { + for (const m of mentionees) { + if (m.type === 'user' && m.userId) { + const profile = getGroupMemberProfile(groupId, m.userId); + if (profile) { + upsertMember(m.userId, profile.displayName); + return { id: m.userId, name: profile.displayName }; + } + } + } + } + + const matches = findMemberByName(nameText); + if (matches.length === 1) { + return { id: matches[0].userId, name: matches[0].displayName }; + } + if (matches.length > 1) { + return { error: '「' + nameText + '」に該当するメンバーが' + matches.length + '名います。フルネームで指定してください。' }; + } + return { error: '@' + nameText + ' に該当するメンバーが見つかりません。LINEの表示名を確認してください。' }; +} diff --git a/src/config.gs b/src/config.gs new file mode 100644 index 0000000..c71a162 --- /dev/null +++ b/src/config.gs @@ -0,0 +1,65 @@ +// ============================================================ +// config.gs — 定数 + PropertiesService ラッパー +// ============================================================ + +const PENALTY_AMOUNT = 500; +const MAX_BULK_ADD = 31; +const DUPLICATE_GUARD_SECONDS = 5; +const HISTORY_LIMIT = 10; +const SEPARATOR = '━━━━━━━━━━━━━━━'; + +const SHEET_NAMES = { + LOG: '記録ログ', + MEMBERS: 'メンバー' +}; + +const ACTIONS = { + LATE: '遅刻', + ADD: '追加', + CANCEL: '取消' +}; + +// --- PropertiesService ラッパー --- + +function getProps_() { + return PropertiesService.getScriptProperties(); +} + +function getChannelToken() { + return getProps_().getProperty('CHANNEL_ACCESS_TOKEN') || ''; +} + +function setChannelToken(token) { + getProps_().setProperty('CHANNEL_ACCESS_TOKEN', token); +} + +function getGroupId() { + return getProps_().getProperty('GROUP_ID') || ''; +} + +function setGroupId(id) { + getProps_().setProperty('GROUP_ID', id); +} + +// --- フォーマッター --- + +function getCurrentYearMonth() { + const now = new Date(); + const y = now.getFullYear(); + const m = ('0' + (now.getMonth() + 1)).slice(-2); + return y + '-' + m; +} + +function formatYen(amount) { + return amount.toLocaleString('ja-JP') + '円'; +} + +function formatYearMonthLabel(ym) { + const parts = ym.split('-'); + return parts[0] + '年' + parseInt(parts[1], 10) + '月'; +} + +function proxySuffix(recorderId, targetId, recorderName, verb) { + if (recorderId === targetId) return ''; + return ' ※' + recorderName + 'が' + verb; +} diff --git a/src/line.gs b/src/line.gs new file mode 100644 index 0000000..4ec0941 --- /dev/null +++ b/src/line.gs @@ -0,0 +1,65 @@ +// ============================================================ +// line.gs — LINE Messaging API ラッパー +// ============================================================ + +const LINE_API_BASE = 'https://api.line.me/v2/bot'; + +function replyMessage(replyToken, text) { + const url = LINE_API_BASE + '/message/reply'; + const payload = { + replyToken: replyToken, + messages: [{ type: 'text', text: text }] + }; + const res = callLineApi_(url, payload); + if (res && res.getResponseCode() !== 200) { + console.error('replyMessage failed: ' + res.getResponseCode() + ' ' + res.getContentText()); + } +} + +function pushMessage(to, text) { + const url = LINE_API_BASE + '/message/push'; + const payload = { + to: to, + messages: [{ type: 'text', text: text }] + }; + const res = callLineApi_(url, payload); + if (res && res.getResponseCode() !== 200) { + console.error('pushMessage failed: ' + res.getResponseCode() + ' ' + res.getContentText()); + } +} + +function getGroupMemberProfile(groupId, userId) { + const url = LINE_API_BASE + '/group/' + groupId + '/member/' + userId; + const options = { + method: 'get', + headers: { 'Authorization': 'Bearer ' + getChannelToken() }, + muteHttpExceptions: true + }; + try { + const res = UrlFetchApp.fetch(url, options); + if (res.getResponseCode() !== 200) { + console.warn('getGroupMemberProfile failed for ' + userId + ': ' + res.getResponseCode()); + return null; + } + return JSON.parse(res.getContentText()); + } catch (e) { + console.error('getGroupMemberProfile error: ' + e.message); + return null; + } +} + +function callLineApi_(url, payload) { + const options = { + method: 'post', + contentType: 'application/json', + headers: { 'Authorization': 'Bearer ' + getChannelToken() }, + payload: JSON.stringify(payload), + muteHttpExceptions: true + }; + try { + return UrlFetchApp.fetch(url, options); + } catch (e) { + console.error('LINE API call failed: ' + e.message); + return null; + } +} diff --git a/src/main.gs b/src/main.gs new file mode 100644 index 0000000..a8c3042 --- /dev/null +++ b/src/main.gs @@ -0,0 +1,165 @@ +// ============================================================ +// main.gs — エントリポイント(Webhook / メニュー / 月次トリガー) +// ============================================================ + +function doPost(e) { + try { + const body = JSON.parse(e.postData.contents); + const events = body.events; + if (!events || events.length === 0) return; + + for (const event of events) { + processEvent_(event); + } + } catch (err) { + console.error('doPost error: ' + err.message + '\n' + err.stack); + } +} + +// --- イベント処理 --- + +function processEvent_(event) { + try { + const cache = CacheService.getScriptCache(); + const eventId = event.webhookEventId; + if (eventId) { + if (cache.get(eventId)) return; + cache.put(eventId, '1', 21600); + } + + if (event.source && event.source.type === 'group' && !getGroupId()) { + setGroupId(event.source.groupId); + } + + switch (event.type) { + case 'join': + handleJoin_(event); + break; + case 'message': + if (event.message && event.message.type === 'text') { + handleTextMessage_(event); + } + break; + } + } catch (err) { + console.error('processEvent_ error: ' + err.message + '\n' + err.stack); + if (event.replyToken) { + try { + replyMessage(event.replyToken, '処理中にエラーが発生しました。もう一度お試しください。'); + } catch (replyErr) { + console.error('Error reply failed: ' + replyErr.message); + } + } + } +} + +function handleJoin_(event) { + if (event.source && event.source.groupId) { + setGroupId(event.source.groupId); + } + const welcome = [ + '朝活Botがグループに参加しました!', + '', + '「遅刻」→ 遅刻を記録', + '「集計」→ 今月の遅刻回数と罰金額', + '「ヘルプ」→ 全コマンド一覧', + '', + 'まずは「ヘルプ」と送ってみてください。' + ].join('\n'); + replyMessage(event.replyToken, welcome); +} + +function handleTextMessage_(event) { + if (!event.source || event.source.type !== 'group') { + replyMessage(event.replyToken, 'このBotはグループチャットで使ってください。'); + return; + } + + const senderId = event.source.userId; + const groupId = event.source.groupId; + if (!senderId) return; + + const mentionees = (event.message.mention && event.message.mention.mentionees) || []; + + const senderResolver = () => { + const profile = getGroupMemberProfile(groupId, senderId); + if (!profile) return null; + upsertMember(senderId, profile.displayName); + return { id: senderId, name: profile.displayName }; + }; + + const result = dispatch(event.message.text, senderResolver, groupId, mentionees); + if (result === null) return; + + replyMessage(event.replyToken, result); +} + +// --- 月次精算レポート --- + +function sendMonthlyReport() { + const gid = getGroupId(); + if (!gid) { + console.error('GROUP_ID が未設定です。Bot をグループに追加してください。'); + return; + } + + const now = new Date(); + let prevMonth = now.getMonth(); + let prevYear = now.getFullYear(); + if (prevMonth === 0) { + prevMonth = 12; + prevYear--; + } + const ym = prevYear + '-' + ('0' + prevMonth).slice(-2); + + pushMessage(gid, getMonthlyReport(ym)); +} + +// --- GAS メニュー --- + +function onOpen() { + SpreadsheetApp.getUi() + .createMenu('朝活Bot') + .addItem('初期設定', 'showSetupDialog') + .addItem('月次レポートトリガーを設定', 'setupMonthlyTrigger') + .addItem('月次レポートトリガーを削除', 'removeMonthlyTrigger') + .addToUi(); +} + +function showSetupDialog() { + const ui = SpreadsheetApp.getUi(); + + const tokenRes = ui.prompt( + '初期設定', + 'LINE Developers Console の「Messaging API設定」タブで発行した\nチャネルアクセストークン(長期)を入力してください:', + ui.ButtonSet.OK_CANCEL + ); + if (tokenRes.getSelectedButton() !== ui.Button.OK) return; + const token = tokenRes.getResponseText().trim(); + if (!token) { + ui.alert('エラー', 'トークンが空です。LINE Developers Console で発行してから再度お試しください。', ui.ButtonSet.OK); + return; + } + setChannelToken(token); + + ui.alert('設定完了', 'トークンを保存しました。\n次は Web App としてデプロイしてください。', ui.ButtonSet.OK); +} + +function setupMonthlyTrigger() { + removeMonthlyTrigger(); + ScriptApp.newTrigger('sendMonthlyReport') + .timeBased() + .onMonthDay(1) + .atHour(7) + .create(); + SpreadsheetApp.getUi().alert('毎月1日 7:00〜8:00 に月次レポートを配信するトリガーを設定しました。'); +} + +function removeMonthlyTrigger() { + const triggers = ScriptApp.getProjectTriggers(); + for (const trigger of triggers) { + if (trigger.getHandlerFunction() === 'sendMonthlyReport') { + ScriptApp.deleteTrigger(trigger); + } + } +} diff --git a/src/penalty.gs b/src/penalty.gs new file mode 100644 index 0000000..e3da129 --- /dev/null +++ b/src/penalty.gs @@ -0,0 +1,227 @@ +// ============================================================ +// penalty.gs — 遅刻記録 / 取消 / 追加 / 集計 / 履歴 +// ============================================================ + +// --- 書き込みコマンド --- + +function recordLate(targetId, targetName, recorderId, recorderName) { + if (isDuplicateWrite_(recorderId, 'late_' + targetId)) { + return '⚠️ 直前に同じ記録済みです。'; + } + const ym = getCurrentYearMonth(); + appendRecord([ + new Date(), ym, targetId, targetName, + ACTIONS.LATE, 1, recorderId, recorderName + ]); + + const total = calcTotal_(ym, targetId); + return '⏰ ' + targetName + ' 遅刻' + total + '回目(' + penaltyYen_(total) + ')' + + proxySuffix(recorderId, targetId, recorderName, '記録'); +} + +function cancelLate(targetId, targetName, recorderId, recorderName) { + const ym = getCurrentYearMonth(); + const total = calcTotal_(ym, targetId); + if (total <= 0) { + return '今月の' + targetName + 'さんの遅刻記録がないため、取り消せません。'; + } + + appendRecord([ + new Date(), ym, targetId, targetName, + ACTIONS.CANCEL, -1, recorderId, recorderName + ]); + + const newTotal = total - 1; + const status = newTotal > 0 + ? '(残り' + newTotal + '回 / ' + penaltyYen_(newTotal) + ')' + : '(今月の遅刻: 0回)'; + return '↩️ ' + targetName + ' の遅刻を1件取り消しました' + status + + proxySuffix(recorderId, targetId, recorderName, '操作'); +} + +function addBulk(targetId, targetName, count, recorderId, recorderName) { + if (isDuplicateWrite_(recorderId, 'add_' + targetId + '_' + count)) { + return '⚠️ 直前に同じ記録済みです。'; + } + const ym = getCurrentYearMonth(); + appendRecord([ + new Date(), ym, targetId, targetName, + ACTIONS.ADD, count, recorderId, recorderName + ]); + + const total = calcTotal_(ym, targetId); + return '📝 ' + targetName + ' の遅刻を' + count + '回追加(累計' + total + '回 / ' + penaltyYen_(total) + ')' + + proxySuffix(recorderId, targetId, recorderName, '記録'); +} + +// --- 参照コマンド --- + +function getSummary() { + const ym = getCurrentYearMonth(); + const agg = aggregate_(ym); + + const lines = ['📊 ' + formatYearMonthLabel(ym) + 'の遅刻集計', SEPARATOR]; + + if (agg.ranked.length === 0 && agg.zeroNames.length === 0) { + lines.push('メンバーの記録がありません。'); + lines.push('「遅刻」と投稿して記録を始めてください。'); + return lines.join('\n'); + } + + for (const entry of agg.ranked) { + lines.push(formatEntryLine_(entry)); + } + lines.push(SEPARATOR); + lines.push('合計: ' + agg.grandTotal + '回(' + penaltyYen_(agg.grandTotal) + ')'); + + if (agg.zeroNames.length > 0) { + lines.push('遅刻なし: ' + agg.zeroNames.join('、')); + } + return lines.join('\n'); +} + +function getHistory() { + const records = getRecentRecords(HISTORY_LIMIT); + if (records.length === 0) { + return '📋 記録がまだありません。'; + } + + const lines = ['📋 直近の記録', SEPARATOR]; + for (const record of records) { + lines.push(formatHistoryLine_(record)); + } + return lines.join('\n'); +} + +function getHelp() { + return [ + '📖 朝活Bot コマンド一覧', + SEPARATOR, + '▶ 遅刻 → 自分の遅刻を記録', + '▶ 遅刻 @名前 → 他の人の遅刻を代理で記録', + '▶ 追加 ○回 → 自分の過去の遅刻をまとめて追加', + ' 例: 追加 3回', + '▶ 追加 @名前 ○回 → 他の人の過去分を代理で追加', + '▶ 集計 → 遅刻回数と罰金額を表示', + '▶ 履歴 → 直近10件の記録を表示', + '▶ 取消 → 自分の遅刻を1回取り消し', + '▶ 取消 @名前 → 他の人の遅刻を代理で取り消し', + '▶ ヘルプ → このメッセージを表示', + '', + '💰 遅刻1回 = ' + penaltyYen_(1), + '📅 毎月1日に前月の集計が届きます' + ].join('\n'); +} + +// --- 月次レポート(Push 用) --- + +function getMonthlyReport(yearMonth) { + const agg = aggregate_(yearMonth); + + const lines = ['📊 ' + formatYearMonthLabel(yearMonth) + ' 最終集計', SEPARATOR]; + + for (const e of agg.all) { + lines.push(e.count > 0 ? formatEntryLine_(e) : e.name + ': 0回'); + } + lines.push(SEPARATOR); + lines.push('合計: ' + agg.grandTotal + '回(' + penaltyYen_(agg.grandTotal) + ')'); + lines.push(''); + lines.push('💰 メンバー同士で精算をお願いします。'); + + const parts = yearMonth.split('-'); + let nextMonth = parseInt(parts[1], 10) + 1; + let nextYear = parseInt(parts[0], 10); + if (nextMonth > 12) { nextMonth = 1; nextYear++; } + lines.push(nextMonth + '月の記録がスタートしました!'); + + return lines.join('\n'); +} + +// --- 内部ヘルパー --- + +function penaltyYen_(count) { + return formatYen(count * PENALTY_AMOUNT); +} + +function formatEntryLine_(entry) { + return entry.name + ': ' + entry.count + '回(' + penaltyYen_(entry.count) + ')'; +} + +function formatHistoryLine_(r) { + const ts = r.timestamp; + const dateStr = (ts.getMonth() + 1) + '/' + ts.getDate() + ' ' + + ('0' + ts.getHours()).slice(-2) + ':' + ('0' + ts.getMinutes()).slice(-2); + + let line = dateStr + ' ' + r.action + ' ' + r.targetName; + if (r.action === ACTIONS.ADD && r.count > 1) { + line += ' ×' + r.count + '回'; + } + if (r.recorderId !== r.targetId) { + line += ' ※' + r.recorderName + 'が記録'; + } + return line; +} + +/** + * 月の記録を集計する。 + * ranked: 遅刻 > 0 を回数降順 / zeroNames: 遅刻 0 の名前リスト + * all: 全員を回数降順(月次レポート用) / grandTotal: 合計 + */ +function aggregate_(yearMonth) { + const records = getMonthRecords(yearMonth); + const members = getMembers(); + + const totals = {}; + for (const member of members) { + totals[member.userId] = { name: member.displayName, count: 0 }; + } + for (const r of records) { + if (!totals[r.targetId]) { + totals[r.targetId] = { name: r.targetName, count: 0 }; + } + totals[r.targetId].count += r.count; + } + + const ranked = []; + const zeroNames = []; + const all = []; + let grandTotal = 0; + + for (const key of Object.keys(totals)) { + const t = totals[key]; + const c = Math.max(0, t.count); + const entry = { name: t.name, count: c }; + all.push(entry); + if (c > 0) { + ranked.push(entry); + grandTotal += c; + } else { + zeroNames.push(t.name); + } + } + + const byCountDesc = (a, b) => b.count - a.count; + ranked.sort(byCountDesc); + all.sort(byCountDesc); + + return { ranked, zeroNames, all, grandTotal }; +} + +function calcTotal_(yearMonth, targetId) { + const records = getMonthRecords(yearMonth); + let total = 0; + for (const r of records) { + if (r.targetId === targetId) { + total += r.count; + } + } + return Math.max(0, total); +} + +function isDuplicateWrite_(userId, actionKey) { + const cache = CacheService.getScriptCache(); + const key = 'dup_' + userId + '_' + actionKey; + if (cache.get(key)) return true; + cache.put(key, '1', DUPLICATE_GUARD_SECONDS); + return false; +} diff --git a/src/store.gs b/src/store.gs new file mode 100644 index 0000000..1835094 --- /dev/null +++ b/src/store.gs @@ -0,0 +1,126 @@ +// ============================================================ +// store.gs — スプレッドシート読み書き(LockService 排他制御) +// ============================================================ + +// --- シート取得(なければ自動作成) --- + +function getRecordSheet_() { + const ss = SpreadsheetApp.getActiveSpreadsheet(); + let sheet = ss.getSheetByName(SHEET_NAMES.LOG); + if (!sheet) { + sheet = ss.insertSheet(SHEET_NAMES.LOG); + sheet.appendRow([ + 'タイムスタンプ', '年月', '対象者ID', '対象者名', + '操作', '回数', '記録者ID', '記録者名' + ]); + sheet.getRange('B:B').setNumberFormat('@'); + } + return sheet; +} + +function getMemberSheet_() { + const ss = SpreadsheetApp.getActiveSpreadsheet(); + let sheet = ss.getSheetByName(SHEET_NAMES.MEMBERS); + if (!sheet) { + sheet = ss.insertSheet(SHEET_NAMES.MEMBERS); + sheet.appendRow(['User ID', '表示名', '登録日']); + } + return sheet; +} + +// --- 行 → オブジェクト変換 --- + +function parseLogRow_(row) { + return { + timestamp: row[0], + yearMonth: normalizeYearMonth_(row[1]), + targetId: String(row[2]), + targetName: row[3], + action: row[4], + count: row[5], + recorderId: String(row[6]), + recorderName: row[7] + }; +} + +function normalizeYearMonth_(value) { + if (value instanceof Date) { + const y = value.getFullYear(); + const m = ('0' + (value.getMonth() + 1)).slice(-2); + return y + '-' + m; + } + return String(value); +} + +function parseMemberRow_(row) { + return { + userId: String(row[0]), + displayName: row[1], + registeredAt: row[2] + }; +} + +// --- 記録ログ --- + +function appendRecord(record) { + const lock = LockService.getScriptLock(); + lock.waitLock(10000); + try { + getRecordSheet_().appendRow(record); + SpreadsheetApp.flush(); + } finally { + lock.releaseLock(); + } +} + +function getMonthRecords(yearMonth) { + const data = getRecordSheet_().getDataRange().getValues(); + return data.slice(1) + .filter(row => normalizeYearMonth_(row[1]) === yearMonth) + .map(parseLogRow_); +} + +function getRecentRecords(limit) { + const sheet = getRecordSheet_(); + const lastRow = sheet.getLastRow(); + if (lastRow <= 1) return []; + + const startRow = Math.max(2, lastRow - limit + 1); + const numRows = lastRow - startRow + 1; + const data = sheet.getRange(startRow, 1, numRows, 8).getValues(); + + return data.reverse().slice(0, limit).map(parseLogRow_); +} + +// --- メンバー --- + +function getMembers() { + const data = getMemberSheet_().getDataRange().getValues(); + return data.slice(1).map(parseMemberRow_); +} + +function findMemberByName(name) { + return getMembers().filter(m => m.displayName === name); +} + +function upsertMember(userId, displayName) { + const lock = LockService.getScriptLock(); + lock.waitLock(10000); + try { + const sheet = getMemberSheet_(); + const data = sheet.getDataRange().getValues(); + for (let i = 1; i < data.length; i++) { + if (String(data[i][0]) === String(userId)) { + if (data[i][1] !== displayName) { + sheet.getRange(i + 1, 2).setValue(displayName); + SpreadsheetApp.flush(); + } + return; + } + } + sheet.appendRow([userId, displayName, new Date()]); + SpreadsheetApp.flush(); + } finally { + lock.releaseLock(); + } +}