初回配布

Made-with: Cursor
This commit is contained in:
hiroki ito 2026-04-03 20:53:06 +09:00
commit c1ce766b63
11 changed files with 1066 additions and 0 deletions

4
.clasp.json.example Normal file
View File

@ -0,0 +1,4 @@
{
"scriptId": "YOUR_SCRIPT_ID_HERE",
"rootDir": "src"
}

3
.claspignore Normal file
View File

@ -0,0 +1,3 @@
**/**
!src/**/*.gs
!appsscript.json

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
.clasp.json
.clasprc.json
node_modules/

266
README.md Normal file
View File

@ -0,0 +1,266 @@
# 朝活遅刻罰金Bot
朝活グループの遅刻を記録し、罰金を自動集計する LINE Bot です。
LINEグループに Bot を追加して「遅刻」と投稿するだけで、誰が何回遅刻したか、今月いくら払うべきかが自動で管理されます。毎月1日に前月の集計レポートが届き、メンバー同士で精算する運用です。
## 仕組み3パーツ
| パーツ | 料理の比喩 | このツールでは |
|---|---|---|
| トリガー | 伝票 | LINE Webhookコマンド投稿時+ GAS 時間トリガー毎月1日 |
| ソース元 | 冷蔵庫 | Google スプレッドシート(遅刻記録ログ) |
| 処理する場所 | キッチン | Google Apps Scriptコマンド解析・集計・レポート生成 |
## 全体の構成
```
LINE グループ
↓ Webhookコマンド投稿
Google Apps ScriptdoPost で直接受信)
↓ コマンド解析 → スプレッドシート読み書き
↓ 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. シートは空のままで OKBot が初回実行時に自動作成します)
#### 手順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 にはランダムなスクリプトID64文字が含まれており、この 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」で確認できます。

15
appsscript.json Normal file
View File

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

127
src/commands.gs Normal file
View File

@ -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の表示名を確認してください。' };
}

65
src/config.gs Normal file
View File

@ -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;
}

65
src/line.gs Normal file
View File

@ -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;
}
}

165
src/main.gs Normal file
View File

@ -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);
}
}
}

227
src/penalty.gs Normal file
View File

@ -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;
}

126
src/store.gs Normal file
View File

@ -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();
}
}