-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathCode.gs.template
More file actions
509 lines (432 loc) · 19.1 KB
/
Copy pathCode.gs.template
File metadata and controls
509 lines (432 loc) · 19.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
// --- 系統密碼設定 ---
const ADMIN_PASSWORD = 'NEVERTELLOTHERS'; // 大關主後台密碼
const SECRET_PASSWORD = '87654321'; // 正式有密碼版本的通關密碼
const DUMMY_PASSWORD = '12345678'; // 無密碼版本的防呆自動驗證密碼
// --- Line API 設定 ---
// 請將你的 Channel Access Token 填入下方引號內
const LINE_CHANNEL_ACCESS_TOKEN = '(去 Messenging API 撈 Channel access token)';
const LINE_REGISTER_KEYWORD = '/取得GroupID然後綁定'; // 可自行修改為你測試成功的密語
const LINE_GET_USERID_KEYWORD = '/取得我的UserID'; // 可自行修改為你取得 User ID 的密語
// 【安全防護】請將剛剛在後台複製的 Your user ID 填入下方
const ADMIN_DATA = [
{ name: '百片', userId: '(百片 的 User ID)' },
{ name: '紅椰', userId: '(紅椰 的 User ID)' },
{ name: '文鈞', userId: '(文鈞 的 User ID)' }
];
// 轉播系統使用的 Google Sheets 設定
// TODO: 請建立一個新的 Google 試算表,並將其 ID 填入下方
const LIVE_SPREADSHEET_ID = '(去雲端硬碟抓 Google Sheets 的 Spreadsheet ID)';
const LIVE_SHEET_NAME = 'LiveStatus';
const SUBMIT_RECORD_SHEET_NAME = 'SubmitRecord';
// 取得是否為無密碼模式 (具備明確的初始化防呆)
function getIsPasswordFree() {
const props = PropertiesService.getScriptProperties();
let val = props.getProperty('isPasswordFree');
if (val === null) {
props.setProperty('isPasswordFree', 'false'); // 第一次執行出廠時,強制寫入「有密碼模式」
val = 'false';
}
return val === 'true';
}
// 取得是否啟用 LINE 主動推播 (具備明確的初始化防呆)
function getIsLineNotifyEnabled() {
const props = PropertiesService.getScriptProperties();
let val = props.getProperty('isLineNotifyEnabled');
if (val === null) {
props.setProperty('isLineNotifyEnabled', 'false'); // 第一次執行出廠時,強制寫入「關閉推播」
val = 'false';
}
return val === 'true';
}
// GAS 網頁應用程式的進入點
function doGet(e) {
// 取得系統設定
const isPasswordFree = getIsPasswordFree();
// 1. 後台管理路由 (?p=admin)
if (e && e.parameter && e.parameter.p === 'admin') {
return HtmlService.createHtmlOutputFromFile('backend_setting')
.setTitle('Final Mission 大關主後台')
.addMetaTag('viewport', 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no');
}
// 2. 轉播台路由 (?p=live)
// 透過 URL 參數 p 來決定要載入哪個頁面 (例如網址加上 ?p=live)
if (e && e.parameter && e.parameter.p === 'live') {
const file = isPasswordFree ? 'live_dashboard_wo_passwd' : 'live_dashboard';
return HtmlService.createHtmlOutputFromFile(file)
.setTitle('Final Mission 進度直播')
.addMetaTag('viewport', 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no');
}
// 3. 關主的驗收頁面路由 (?p=check)
if (e && e.parameter && e.parameter.p === 'check') {
const file = isPasswordFree ? 'permutation_check_wo_passwd' : 'permutation_check';
return HtmlService.createHtmlOutputFromFile(file)
.setTitle('Final Mission 驗收系統')
.addMetaTag('viewport', 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no');
}
// 4. 歷史重播資料 API 路由 (?p=getReplayData)
if (e && e.parameter && e.parameter.p === 'getReplayData') {
const replayData = getReplayLogs();
return ContentService.createTextOutput(JSON.stringify(replayData))
.setMimeType(ContentService.MimeType.JSON);
}
// 5. 預設載入歷史重播頁面路由 (無參數或未知參數)
return HtmlService.createHtmlOutputFromFile('replay_check')
.setTitle('Final Mission 歷史重播')
.addMetaTag('viewport', 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no');
}
// 供前端呼叫的密碼驗證函式
function verifyPassword(inputPassword) {
const isPasswordFree = getIsPasswordFree();
return (isPasswordFree && inputPassword === DUMMY_PASSWORD) || (!isPasswordFree && inputPassword === SECRET_PASSWORD);
}
// 供前端呼叫的進階驗證函式:密碼正確才回傳機密資料
function authenticateAndGetData(inputPassword) {
if (verifyPassword(inputPassword)) {
const props = PropertiesService.getScriptProperties();
let activePlayers = PLAYER_DATA;
// 根據晉級名單進行過濾
const advancedTeamsStr = props.getProperty('advancedTeams');
if (advancedTeamsStr) {
try {
const advancedTeams = JSON.parse(advancedTeamsStr); // e.g., [1, 3, 4]
// 只保留 ID 存在於勾選名單中的隊伍
activePlayers = PLAYER_DATA.filter(p => advancedTeams.includes(p.id.toString()) || advancedTeams.includes(p.id));
} catch(e) {}
}
return {
success: true,
data: {
correctPoems: POEM_CORRECT_DATA,
confusePoems: POEM_CONFUSE_DATA,
players: activePlayers,
isLineNotifyEnabled: getIsLineNotifyEnabled() // 將 LINE 推播開關狀態一起傳給前台
}
};
}
return { success: false };
}
// 取得公開的靜態資料 (供無密碼的重播系統使用,因活動已結束,題目可公開)
function getPublicData() {
return {
success: true,
data: {
correctPoems: POEM_CORRECT_DATA,
confusePoems: POEM_CONFUSE_DATA
}
};
}
// --- 後台管理 API ---
// 取得目前系統設定
function getSystemSettings(inputPassword) {
if (inputPassword !== ADMIN_PASSWORD) return { success: false, error: '密碼錯誤' };
const props = PropertiesService.getScriptProperties();
// 若尚未設定過晉級名單,預設全部勾選 (避免一開始什麼都沒有)
let advancedTeams = PLAYER_DATA.map(p => p.id);
if (props.getProperty('advancedTeams')) {
try { advancedTeams = JSON.parse(props.getProperty('advancedTeams')); } catch(e) {}
}
return {
success: true,
data: {
isPasswordFree: getIsPasswordFree(),
isLineNotifyEnabled: getIsLineNotifyEnabled(),
advancedTeams: advancedTeams,
allPlayers: PLAYER_DATA // 傳送原始完整名單,供後台渲染核取方塊使用
}
};
}
// 儲存系統設定
function saveSystemSettings(inputPassword, settings) {
if (inputPassword !== ADMIN_PASSWORD) return { success: false, error: '密碼錯誤' };
try {
const props = PropertiesService.getScriptProperties();
props.setProperty('isPasswordFree', settings.isPasswordFree ? 'true' : 'false');
props.setProperty('isLineNotifyEnabled', settings.isLineNotifyEnabled ? 'true' : 'false');
props.setProperty('advancedTeams', JSON.stringify(settings.advancedTeams || []));
return { success: true };
} catch (e) {
return { success: false, error: e.toString() };
}
}
// 供前端呼叫的轉播狀態同步函式
function syncLiveStatus(payload) {
// payload 預期格式: { teamId: "1", currentSequence: [...], lisLength: 5, totalCount: 10, isAttempt: true/false }
// 使用 LockService 防止多人同時寫入造成資料覆蓋或衝突
const lock = LockService.getScriptLock();
// 最多等待 3 秒,若拿不到鎖則回傳失敗請前端稍後再試
if (!lock.tryLock(3000)) {
return { success: false, error: '系統忙碌中,請稍後重試。' };
}
try {
const ss = SpreadsheetApp.openById(LIVE_SPREADSHEET_ID);
let sheet = ss.getSheetByName(LIVE_SHEET_NAME);
// 若工作表不存在則自動建立
if (!sheet) {
sheet = ss.insertSheet(LIVE_SHEET_NAME);
}
// 檢查工作表是否為空 (沒有標題列),若為空則自動寫入標題列並凍結
if (sheet.getLastRow() === 0) {
sheet.appendRow(['隊號(系統內用)', '隊伍名稱', '最後更新時間', '木條數量', '最長遞增序列長度', '驗收次數', '木條編號序列JSON']);
sheet.setFrozenRows(1); // 凍結第一列標題
}
const data = sheet.getDataRange().getValues();
const teamId = payload.teamId.toString();
const now = new Date();
// 利用現有的 PLAYER_DATA 抓取隊伍名稱,減輕前端傳輸負擔
const teamInfo = PLAYER_DATA.find(p => p.id.toString() === teamId);
const teamName = teamInfo ? `${teamInfo.p1name} ${teamInfo.p2name} (${teamInfo.relationship})` : '未知隊伍';
const sequenceStr = JSON.stringify(payload.currentSequence || []);
// 尋找該隊伍是否已經有紀錄 (i=1 略過標題列)
let rowIndex = -1;
let attemptCount = 0;
for (let i = 1; i < data.length; i++) {
if (data[i][0].toString() === teamId) {
rowIndex = i + 1; // GAS 的 Row 是從 1 開始算
attemptCount = parseInt(data[i][5]) || 0; // 讀取目前的驗收次數 (F欄, index 5)
break;
}
}
let newAttemptCount = attemptCount;
// 只要前端明確傳送 isAttempt = true (按下驗收按鈕),才算一次正式的驗收,次數加一
if (payload.isAttempt) {
newAttemptCount += 1;
}
const liveStatusRowData = [teamName, now, payload.totalCount || 0, payload.lisLength || 0, newAttemptCount, sequenceStr];
if (rowIndex !== -1) {
// 更新現有隊伍資料 (B欄到G欄)
sheet.getRange(rowIndex, 2, 1, 6).setValues([liveStatusRowData]);
} else {
// 若無紀錄則新增一列
sheet.appendRow([teamId, ...liveStatusRowData]);
}
// --- 如果是正式驗收,則在 SubmitRecord 工作表附加一筆紀錄 ---
if (payload.isAttempt) {
const isLineEnabled = getIsLineNotifyEnabled(); // 取得當時大關主推播設定
let submitSheet = ss.getSheetByName(SUBMIT_RECORD_SHEET_NAME);
if (!submitSheet) {
submitSheet = ss.insertSheet(SUBMIT_RECORD_SHEET_NAME);
}
// 檢查 SubmitRecord 工作表是否為空,若為空則自動寫入標題列
if (submitSheet.getLastRow() === 0) {
submitSheet.appendRow(['隊號(系統內用)', '隊伍名稱', '最後更新時間', '木條數量', '最長遞增序列長度', '驗收次數', '木條編號序列JSON', '推播設定狀態']);
submitSheet.setFrozenRows(1);
}
submitSheet.appendRow([teamId, ...liveStatusRowData, isLineEnabled ? '開啟' : '關閉']);
// --- 若有開啟推播則發送 LINE 通知 ---
if (isLineEnabled) {
sendLinePushNotification(teamId, teamName, payload.lisLength, newAttemptCount);
}
}
// 寫入成功後,主動清除讀取快取,讓儀表板能立刻抓到最新進度
CacheService.getScriptCache().remove('LIVE_STATUS_CACHE');
return { success: true, timestamp: now.getTime(), isLineNotifyEnabled: getIsLineNotifyEnabled() };
} catch (e) {
return { success: false, error: e.toString() };
} finally {
lock.releaseLock();
}
}
// 負責發送「驗收結果」的推播訊息
function sendLinePushNotification(teamId, teamName, lisLength, attemptCount) {
const props = PropertiesService.getScriptProperties();
const groupId = props.getProperty('LINE_GROUP_ID');
// 1. 如果尚未綁定群組,則不執行推播
if (!groupId) return;
const isSuccess = lisLength === 40;
// 2. 智慧防連發機制:除了紀錄時間,也記錄上次的「驗收狀態」
const cooldownKey = 'NOTIFY_COOLDOWN_' + teamId;
const lastNotifyData = props.getProperty(cooldownKey);
let lastNotifyTime = 0;
let lastNotifyWasSuccess = null;
if (lastNotifyData) {
const parts = lastNotifyData.split('_');
lastNotifyTime = parseInt(parts[0]);
lastNotifyWasSuccess = parts[1] === 'true';
}
const now = new Date().getTime();
const NOTIFY_COOLDOWN_SECONDS = 5; // 冷卻時間縮短為 5 秒
// 若狀態不變 (例如連兩次失敗) 且在 5 秒內,才判定為前端誤觸;若狀態改變 (如失敗變成功),則直接放行
if (isSuccess === lastNotifyWasSuccess && (now - lastNotifyTime < NOTIFY_COOLDOWN_SECONDS * 1000)) {
return;
}
// 3. 組合推播訊息
const resultText = isSuccess ? '驗收成功!' : '驗收失敗。';
const icon = isSuccess ? '🎉' : '⚠️';
const messageText = `${icon} [第 ${attemptCount} 次驗收] ${icon}\n隊伍「${teamName}」\n最長序列為 ${lisLength} 片,${resultText}`;
const url = 'https://api.line.me/v2/bot/message/push';
const payload = {
'to': groupId,
'messages': [{'type': 'text', 'text': messageText}]
};
const options = {
'method': 'post',
'muteHttpExceptions': true, // 避免 Line API 發生錯誤時導致主程式當機
'headers': {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + LINE_CHANNEL_ACCESS_TOKEN
},
'payload': JSON.stringify(payload)
};
// 發送請求,並在成功後更新冷卻時間戳與狀態
UrlFetchApp.fetch(url, options);
props.setProperty(cooldownKey, now.toString() + '_' + isSuccess);
}
// 供前端儀表板呼叫的讀取 API,具備 CacheService 快取機制防護
function getLiveStatus() {
const cache = CacheService.getScriptCache();
const cacheKey = 'LIVE_STATUS_CACHE';
const cachedData = cache.get(cacheKey);
// 1. 若快取內有資料,直接回傳 (保護 Quota,應付 60 人併發)
if (cachedData) {
return { success: true, data: JSON.parse(cachedData), cached: true };
}
// 2. 若無快取,從 Google Sheets 撈取
try {
const ss = SpreadsheetApp.openById(LIVE_SPREADSHEET_ID);
const sheet = ss.getSheetByName(LIVE_SHEET_NAME);
if (!sheet) {
return { success: true, data: [], cached: false };
}
const data = sheet.getDataRange().getValues();
const result = [];
// 略過第一列標題,從 i = 1 開始
for (let i = 1; i < data.length; i++) {
const row = data[i];
if (!row[0]) continue; // 略過空行
result.push({
challengeOrder: i, // 明確記錄挑戰順位 (也就是 n-1)
teamId: row[0].toString(),
teamName: row[1],
lastUpdated: row[2], // 日期時間
totalCount: row[3],
lisLength: row[4],
attemptCount: row[5],
currentSequence: JSON.parse(row[6] || '[]')
});
}
// 3. 將結果寫入快取,設定存活時間為 5 秒
cache.put(cacheKey, JSON.stringify(result), 5);
return { success: true, data: result, cached: false };
} catch (e) {
return { success: false, error: e.toString() };
}
}
// 供前端重播系統呼叫的讀取 API (回傳所有驗收歷史紀錄)
function getReplayLogs() {
try {
const ss = SpreadsheetApp.openById(LIVE_SPREADSHEET_ID);
const sheet = ss.getSheetByName(SUBMIT_RECORD_SHEET_NAME);
if (!sheet) {
return { success: true, data: [] };
}
const data = sheet.getDataRange().getValues();
const result = [];
// 略過第一列標題,從 i = 1 開始
for (let i = 1; i < data.length; i++) {
const row = data[i];
if (!row[0]) continue; // 略過空行
result.push({
teamId: row[0].toString(),
teamName: row[1],
timestamp: new Date(row[2]).getTime(), // 轉為毫秒時間戳,方便前端計算相對時間與排序
totalCount: row[3],
lisLength: row[4],
attemptCount: row[5],
currentSequence: JSON.parse(row[6] || '[]')
});
}
// 雙重保險:即使試算表已經手動排好,程式端仍確實執行一次依時間戳由早到晚排序
result.sort((a, b) => a.timestamp - b.timestamp);
return { success: true, data: result };
} catch (e) {
return { success: false, error: e.toString() };
}
}
// --- Line Webhook 接收器 (用於自動綁定群組) ---
function doPost(e) {
if (typeof e === 'undefined' || !e.postData || !e.postData.contents) {
return ContentService.createTextOutput("Error: No data");
}
try {
const data = JSON.parse(e.postData.contents);
const events = data.events;
for (let i = 0; i < events.length; i++) {
const event = events[i];
// 確保是群組內的文字訊息
if (event.type === 'message' && event.message.type === 'text' && event.source.type === 'group') {
const userMessage = event.message.text;
const replyToken = event.replyToken;
const groupId = event.source.groupId;
const userId = event.source.userId; // 抓取發言人的 User ID
// 如果收到的訊息等於我們設定的密語
if (userMessage === LINE_REGISTER_KEYWORD) {
// 【安全防護】判斷發言人是哪位大關主
const admin = ADMIN_DATA.find(a => a.userId === userId);
const adminName = admin ? admin.name : '';
// 如果都不符合,回傳錯誤訊息並印出 ID
if (!adminName) {
//replyToLine(replyToken, '❌ 權限驗證失敗!\n系統辨識到你的 User ID 為:\n' + userId + '\n\n請確認是否與 Code.gs 中的大關主 User ID 一致。');
replyToLine(replyToken, '❌ 權限驗證失敗!\n系統辨識到你的 User ID 不是大關主名單一員。');
continue;
}
// 將 Group ID 存入系統環境變數中
const props = PropertiesService.getScriptProperties();
props.setProperty('LINE_GROUP_ID', groupId);
// 回覆成功訊息給群組
const replyText = `✅ ${adminName} 已經綁定群組成功!\n已將此群組設為 Final Mission 進度通報專線。`;
replyToLine(replyToken, replyText);
} else if (userMessage === LINE_GET_USERID_KEYWORD) {
// 如果是要求取得 User ID,則直接回覆給該使用者
const replyText = '✅ 你的 Line User ID 是:\n' + userId;
replyToLine(replyToken, replyText);
continue;
}
}
}
} catch (err) {
// 錯誤防護,避免影響 GAS 執行
console.error(err);
}
// 必須回傳 200 OK 給 Line
return ContentService.createTextOutput("OK");
}
// 負責發送回覆訊息給 Line
function replyToLine(replyToken, text) {
const url = 'https://api.line.me/v2/bot/message/reply';
const payload = {
'replyToken': replyToken,
'messages': [{'type': 'text', 'text': text}]
};
const options = {
'method': 'post',
'headers': {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + LINE_CHANNEL_ACCESS_TOKEN
},
'payload': JSON.stringify(payload)
};
UrlFetchApp.fetch(url, options);
}
// --- 除錯專用:測試授權與 Token 是否正確 ---
function testLineToken() {
const url = 'https://api.line.me/v2/bot/message/push';
const payload = {
'to': ADMIN_DATA[0].userId, // 除錯發送測試訊息,預設發給陣列中第一位大關主
'messages': [{'type': 'text', 'text': '✅ 系統發送測試:Token 與授權皆正常!'}]
};
const options = {
'method': 'post',
'muteHttpExceptions': true, // 即使有錯誤也不會中斷程式,方便印出錯誤訊息
'headers': {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + LINE_CHANNEL_ACCESS_TOKEN
},
'payload': JSON.stringify(payload)
};
const response = UrlFetchApp.fetch(url, options);
console.log('LINE API 回應:', response.getContentText());
}
// 將原本的 JSON 資料直接宣告在後端,避免透過獨立檔案外流
const POEM_CORRECT_DATA = '__POEM_CORRECT_DATA__';
const POEM_CONFUSE_DATA = '__POEM_CONFUSE_DATA__';
const PLAYER_DATA = '__PLAYER_DATA__';