初回配布
Made-with: Cursor
This commit is contained in:
commit
c1ce766b63
4
.clasp.json.example
Normal file
4
.clasp.json.example
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"scriptId": "YOUR_SCRIPT_ID_HERE",
|
||||
"rootDir": "src"
|
||||
}
|
||||
3
.claspignore
Normal file
3
.claspignore
Normal file
@ -0,0 +1,3 @@
|
||||
**/**
|
||||
!src/**/*.gs
|
||||
!appsscript.json
|
||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
.clasp.json
|
||||
.clasprc.json
|
||||
node_modules/
|
||||
266
README.md
Normal file
266
README.md
Normal file
@ -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」で確認できます。
|
||||
15
appsscript.json
Normal file
15
appsscript.json
Normal 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
127
src/commands.gs
Normal 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
65
src/config.gs
Normal 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
65
src/line.gs
Normal 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
165
src/main.gs
Normal 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
227
src/penalty.gs
Normal 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
126
src/store.gs
Normal 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();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user