/** * Omega Core v273 * Developer: Team Chloro / Update: 2026-05-01 * ① allJobsスコープ修正(try外定義) * ② 処理中フラグをshard単位キーに変更 * ③ Queue削除タイミング修正(成功後のみ) * ④ retry制御(>3でDrive退避) * ⑤ FAILED再投入設計 * ⑥ shard単位処理 * ⑦ failed_queue 30日超ファイル削除 * ⑧ appendRow廃止→setValues一括書き込み * + Queueキー v270互換(USAGE_QUEUE_プレフィックス)に戻す */ const CONFIG = { FOLDER_ID_ROOT: PropertiesService.getScriptProperties().getProperty("FOLDER_ID_CHLORO_PLATFORM"), CUSTOMER_SHEET_ID: PropertiesService.getScriptProperties().getProperty("CUSTOMER_SHEET_ID"), DEFAULT_MODEL: "gemini-2.5-flash", WORKER_URL: "https://kurolo-gateway.kulostfpv.workers.dev" }; const TTL = { SYSTEM_DB: 21600, BOT_CONFIG: 1800, CUSTOMER: 1800, HISTORY: 120, USAGE: 300, PERSONA: 21600, INDEX: 600, METABOLIC: 600, PERSONA_INDEX: 21600 }; // ⑥ Queueキー:v270互換(USAGE_QUEUE_プレフィックス)× shard分割 const QUEUE_SHARD_COUNT = 5; function getQueueKey(shard) { const slot = Math.floor(Date.now() / 60000); // 分単位(v270互換) return `USAGE_QUEUE_${slot}_${shard}`; } function getQueueKeysForRange(minutesBack) { const now = Math.floor(Date.now() / 60000); const keys = []; for (let i = 0; i <= minutesBack; i++) { for (let s = 0; s < QUEUE_SHARD_COUNT; s++) { keys.push(`USAGE_QUEUE_${now - i}_${s}`); } } return keys; } const CACHE_KEY = { SYS_DB: () => "SYS_DB_LIGHT", BOT_CONFIGS: () => "BOT_CONFIGS", BOT_SHEET_CONF: (label) => `BSC_${label.toUpperCase()}`, CUSTOMER: (uid) => `CUS_${uid}`, HISTORY: (bot, uid, mode) => `HIS_${bot}_${uid}_${mode}`, USAGE: (bot, uid) => `USE_${bot}_${uid}`, PERSONA: (id) => `PRS_${id.toUpperCase()}`, PERSONA_INDEX: () => "PERSONA_IDX", METABOLIC: (bot, uid) => `MET_${bot}_${uid}`, LONG_TERM: (bot, uid) => `LTM_${bot}_${uid}`, SHEET_INDEX: (sheet) => `IDX_${sheet}`, QUEUE_FAILED: () => "USAGE_QUEUE_FAILED", // v270互換 QUEUE_PROC: (shard) => `UQ_PROCESSING_${shard}` // ② shard単位 }; const MEIBO_COL = { USER_ID: 0, USE_COUNT: 1, TOKEN_TOTAL: 2, LAST_ACCESS: 3, FULL_NAME: 4, NICKNAME: 5, REG_DATE: 6, PLAN: 7, LAST_CHAT: 8, MEMO: 9, FIRST_PAYMENT: 10, FOLDER_ID: 11, PLAN_MENTOR: 12, PLAN_PLUMERIA: 13, PLAN_NEKONOMI: 14, PLAN_ARK: 15, PLAN_SJ: 16, PLAN_KULOST: 17 }; const BOT_SHEET_COL = { USER_ID: 0, USER_NAME: 1, LAST_ACCESS: 2, REG_FLAG: 3, PLAN: 4, FREE_COUNT: 5, TOKEN_USED: 6, TOKEN_LIMIT: 7, NEXT_RESET: 8, EXTRA_TOKEN: 9, TOTAL_COUNT: 10 }; const BOTCONF_COL = { LINE_BOT_ID: 0, SERVICE_NAME: 1, MAIN_PERSONA: 2, FIXED_ICON_URL: 3, RULE_NAME: 4, TOKEN_PER_REPLY: 5, MONTHLY_TOKEN_LIMIT: 6, MODEL_FREE: 7, MODEL_CHAT: 8, MODEL_LOGIC: 9, STRIPE_PRICE_ID: 10, STATUS: 11 }; const USER_MSG = { LOCKED: "現在リクエストを処理中です。10秒ほど待ってから再送信してください。", FREE_LIMIT: "無料お試し回数をすべてお使いいただきました。\n引き続きご利用いただくには、プランへのご加入をお願いします。", TOKEN_LIMIT: "今月の利用上限に達しました。\n追加チャージ(300円 / 30万トークン)またはプランのアップグレードをご検討ください。", SYSTEM_ERROR: "一時的なエラーが発生しました。しばらく経ってから再度お試しください。\n改善されない場合はサポートまでご連絡ください。", AUTH_FAILED: "認証に失敗しました。LINEアプリを再起動してもう一度お試しください。", UNAUTHORIZED: "アクセスが拒否されました。", INVALID_REQ: "リクエストの形式が正しくありません。", ALERT_10: "🔴【残り10%】今月の利用可能トークンが残りわずかです。必要に応じて追加チャージをご検討ください。", ALERT_30: "🟡【残り30%】今月の利用可能トークンが30%を切りました。" }; // ============================================================ // ユーティリティ // ============================================================ function responseJson(obj) { return ContentService.createTextOutput(JSON.stringify(obj)) .setMimeType(ContentService.MimeType.JSON); } function normalizeId(id) { return String(id || "").replace(/[\s ]/g, "").trim(); } function safeJsonParse(str) { if (!str || typeof str !== "string") return null; try { return JSON.parse(str); } catch (e) { return null; } } function logError(fn, msg, extra) { const entry = { fn, msg, t: new Date().toISOString() }; if (extra) entry.extra = String(extra).substring(0, 200); console.error(JSON.stringify(entry)); } function getBotSheetName(botLabel) { const map = { MENTOR: "MENTOR_DB", ARK: "ARK", NEKONOKIMOCHI: "NEKONOKIMOCHI", PLUMERIA: "PLUMERIA", KULOST: "KULOST", SJ: "SJ" }; return map[String(botLabel || "").toUpperCase()] || null; } function postResultToWorker(jobId, result, userId, botId) { if (!jobId) return; try { const res = UrlFetchApp.fetch(`${CONFIG.WORKER_URL}/callback`, { method: "post", contentType: "application/json", muteHttpExceptions: true, payload: JSON.stringify({ jobId, result: { ...result, userId: userId || "", botId: botId || "PLUMERIA" } }) }); const code = res.getResponseCode(); if (code !== 200) { logError("postResultToWorker", `HTTP_${code}`, res.getContentText().substring(0, 100)); } } catch (e) { logError("postResultToWorker", e.message); } } // ============================================================ // シートインデックス(TextFinder廃止・UID列のみ取得) // ============================================================ function getSheetIndex(sheetName) { const sc = CacheService.getScriptCache(); const cacheKey = CACHE_KEY.SHEET_INDEX(sheetName); const cached = sc.get(cacheKey); if (cached) { const parsed = safeJsonParse(cached); if (parsed && typeof parsed === "object") return parsed; } try { const sheet = SpreadsheetApp.openById(CONFIG.CUSTOMER_SHEET_ID).getSheetByName(sheetName); if (!sheet) return {}; const lastRow = sheet.getLastRow(); if (lastRow < 2) return {}; const uidData = sheet.getRange(2, 1, lastRow - 1, 1).getValues(); const index = {}; for (let i = 0; i < uidData.length; i++) { const uid = normalizeId(uidData[i][0]); if (uid) index[uid] = i + 2; } try { sc.put(cacheKey, JSON.stringify(index), TTL.INDEX); } catch (e) { logError("getSheetIndex.cache", e.message); } return index; } catch (e) { logError("getSheetIndex", e.message, sheetName); throw e; } } function invalidateSheetIndex(sheetName) { try { CacheService.getScriptCache().remove(CACHE_KEY.SHEET_INDEX(sheetName)); } catch (e) { logError("invalidateSheetIndex", e.message); } } function findRowByUserId(sheet, sheetName, userId) { return getSheetIndex(sheetName)[userId] || null; } // ============================================================ // 認証ミドルウェア // ============================================================ function verifyLineIdToken(idToken, botLabel) { if (!idToken || typeof idToken !== "string") { return { valid: false, userId: null, reason: "NO_TOKEN" }; } try { const props = PropertiesService.getScriptProperties(); const channelId = props.getProperty("LINE_CHANNEL_ID_" + String(botLabel).toUpperCase()) || props.getProperty("LINE_CHANNEL_ID_PLUMERIA"); if (!channelId) return { valid: false, userId: null, reason: "NO_CHANNEL_ID" }; const res = UrlFetchApp.fetch("https://api.line.me/oauth2/v2.1/verify", { method: "post", contentType: "application/x-www-form-urlencoded", muteHttpExceptions: true, payload: `id_token=${encodeURIComponent(idToken)}&client_id=${encodeURIComponent(channelId)}` }); const code = res.getResponseCode(); if (code !== 200) { logError("verifyLineIdToken", `HTTP_${code}`, res.getContentText().substring(0, 100)); return { valid: false, userId: null, reason: "VERIFY_FAILED" }; } const parsed = safeJsonParse(res.getContentText()); if (!parsed) return { valid: false, userId: null, reason: "INVALID_RESPONSE" }; if (!parsed.sub) return { valid: false, userId: null, reason: "NO_SUB" }; const aud = Array.isArray(parsed.aud) ? parsed.aud : [parsed.aud]; if (!aud.includes(channelId)) { logError("verifyLineIdToken", "AUD_MISMATCH", `aud=${JSON.stringify(aud)}`); return { valid: false, userId: null, reason: "AUD_MISMATCH" }; } if (parsed.exp && Math.floor(Date.now() / 1000) > parsed.exp) { return { valid: false, userId: null, reason: "TOKEN_EXPIRED" }; } if (parsed.iss && parsed.iss !== "https://access.line.me") { logError("verifyLineIdToken", "ISS_MISMATCH", parsed.iss); return { valid: false, userId: null, reason: "ISS_MISMATCH" }; } return { valid: true, userId: parsed.sub, displayName: parsed.name || "" }; } catch (e) { logError("verifyLineIdToken", e.message); return { valid: false, userId: null, reason: "EXCEPTION" }; } } function verifyClientSecret(incomingValue) { try { const expected = PropertiesService.getScriptProperties().getProperty("CLIENT_SECRET"); if (!expected) { logError("verifyClientSecret", "CLIENT_SECRET not configured"); return false; } return typeof incomingValue === "string" && incomingValue === expected; } catch (e) { logError("verifyClientSecret", e.message); return false; } } // ============================================================ // UI・LINE共通 // ============================================================ function onOpen() { SpreadsheetApp.getUi().createMenu("🤖 Omega Core") .addItem("🧠 システムデータ同期", "safeSyncFromUI") .addToUi(); } function safeSyncFromUI() { const ui = SpreadsheetApp.getUi(); try { SpreadsheetApp.getActiveSpreadsheet().toast("同期中...", "🤖 Omega Core", 5); const result = syncSystemData(); ui.alert("✅ 同期完了", `アイコン: ${result.icons}件\nペルソナ: ${result.personas}件\nナレッジ: ${result.knowledge}件\nルール: ${result.rules}件`, ui.ButtonSet.OK); } catch (e) { logError("safeSyncFromUI", e.message); ui.alert("🚨 同期エラー", e.message, ui.ButtonSet.OK); } } function getLineGuideMessage() { return "メッセージありがとうございます✨\n\nAIとの対話は、画面下のメニューからお使いください!\n\nメニューが表示されていない場合は、画面右下の「≡」をタップしてください。\n表示されたメニューをタップするとリッチメニューが開きます。"; } function getLiffUrl(labelUpper) { const liffId = PropertiesService.getScriptProperties().getProperty("LIFF_ID_" + labelUpper); return liffId ? "https://liff.line.me/" + liffId : ""; } function sendLineTextReply(replyToken, text, token) { if (!token) return; try { const res = UrlFetchApp.fetch("https://api.line.me/v2/bot/message/reply", { method: "post", headers: { "Authorization": "Bearer " + token, "Content-Type": "application/json" }, payload: JSON.stringify({ replyToken, messages: [{ type: "text", text: String(text) }] }), muteHttpExceptions: true }); if (res.getResponseCode() !== 200) { logError("sendLineTextReply", `HTTP_${res.getResponseCode()}`, res.getContentText().substring(0, 100)); } } catch (e) { logError("sendLineTextReply", e.message); } } function getLineUserName(userId, token) { if (!token) return "ユーザー"; try { const res = UrlFetchApp.fetch(`https://api.line.me/v2/bot/profile/${userId}`, { headers: { "Authorization": "Bearer " + token }, muteHttpExceptions: true }); if (res.getResponseCode() !== 200) return "ユーザー"; return safeJsonParse(res.getContentText())?.displayName || "ユーザー"; } catch (e) { logError("getLineUserName", e.message); return "ユーザー"; } } // ============================================================ // Bot設定・使用量管理 // ============================================================ function getBotSheetConfig(label) { const cacheKey = CACHE_KEY.BOT_SHEET_CONF(label); const sc = CacheService.getScriptCache(); const cached = sc.get(cacheKey); if (cached) { const parsed = safeJsonParse(cached); if (parsed) return parsed; } try { const sheet = SpreadsheetApp.openById(CONFIG.CUSTOMER_SHEET_ID).getSheetByName("Bot設定"); if (!sheet) return null; const data = sheet.getDataRange().getValues(); const labelUpper = String(label || "").toUpperCase(); for (let i = 1; i < data.length; i++) { const serviceLabel = String(data[i][BOTCONF_COL.SERVICE_NAME] || "").toUpperCase(); if (serviceLabel !== labelUpper && !serviceLabel.includes(labelUpper)) continue; const modelFreeRaw = String(data[i][BOTCONF_COL.MODEL_FREE] || "gemini-2.5-flash"); const modelFreeParts = modelFreeRaw.split("|"); const conf = { tokenPerReply: parseInt(data[i][BOTCONF_COL.TOKEN_PER_REPLY] || "5000"), monthlyTokenLimit: parseInt(data[i][BOTCONF_COL.MONTHLY_TOKEN_LIMIT] || "1000000"), modelFree: modelFreeParts[0].trim(), modelChat: String(data[i][BOTCONF_COL.MODEL_CHAT] || CONFIG.DEFAULT_MODEL), modelLogic: String(data[i][BOTCONF_COL.MODEL_LOGIC] || CONFIG.DEFAULT_MODEL), stripePriceId: String(data[i][BOTCONF_COL.STRIPE_PRICE_ID] || ""), status: String(data[i][BOTCONF_COL.STATUS] || "active"), freeUserLimit: modelFreeParts[1] ? parseInt(modelFreeParts[1].trim()) : 25 }; try { sc.put(cacheKey, JSON.stringify(conf), TTL.BOT_CONFIG); } catch (e) { logError("getBotSheetConfig.cache", e.message); } return conf; } return null; } catch (e) { logError("getBotSheetConfig", e.message, label); throw e; } } function selectModel(plan, botSheetConf, defaultModel) { const isPaid = plan && plan !== "未加入"; if (!botSheetConf) return defaultModel; return isPaid ? (botSheetConf.modelChat || defaultModel) : (botSheetConf.modelFree || defaultModel); } function checkBotSpecificLimit(userId, botLabel, botSheetConf) { const cacheKey = CACHE_KEY.USAGE(botLabel, userId); try { const sc = CacheService.getScriptCache(); const cached = sc.get(cacheKey); if (cached) { const parsed = safeJsonParse(cached); if (parsed) return parsed; } const sheetName = getBotSheetName(botLabel); if (!sheetName) return { isLimit: false, usagePercent: 100, plan: "未加入" }; const sheet = SpreadsheetApp.openById(CONFIG.CUSTOMER_SHEET_ID).getSheetByName(sheetName); if (!sheet) return { isLimit: false, usagePercent: 100, plan: "未加入" }; const row = findRowByUserId(sheet, sheetName, userId); if (!row) return { isLimit: false, usagePercent: 100, plan: "未加入" }; const rowData = sheet.getRange(row, 1, 1, 11).getValues()[0]; const plan = String(rowData[BOT_SHEET_COL.PLAN] || "未加入"); const freeCount = Number(rowData[BOT_SHEET_COL.FREE_COUNT]) || 0; const tokenUsed = Number(rowData[BOT_SHEET_COL.TOKEN_USED]) || 0; const tokenLimit = Number(rowData[BOT_SHEET_COL.TOKEN_LIMIT]) || 1000000; const extraToken = Number(rowData[BOT_SHEET_COL.EXTRA_TOKEN]) || 0; const nextReset = rowData[BOT_SHEET_COL.NEXT_RESET]; const isResetNeeded = nextReset && new Date() >= new Date(nextReset); const effectiveTokenUsed = isResetNeeded ? 0 : tokenUsed; const totalLimit = tokenLimit + extraToken; const usagePercent = 100 - Math.min(100, Math.round((effectiveTokenUsed / totalLimit) * 100)); const isLimit = plan === "未加入" ? freeCount <= 0 : effectiveTokenUsed >= totalLimit; const result = { isLimit, usagePercent, plan, freeCount, tokenUsed: effectiveTokenUsed, tokenLimit, extraToken, totalLimit, row }; try { sc.put(cacheKey, JSON.stringify(result), TTL.USAGE); } catch (e) { logError("checkBotSpecificLimit.cache", e.message); } return result; } catch (e) { logError("checkBotSpecificLimit", e.message); throw e; } } function handleResetIfNeeded(userId, botLabel) { try { const sheetName = getBotSheetName(botLabel); if (!sheetName) return; const sheet = SpreadsheetApp.openById(CONFIG.CUSTOMER_SHEET_ID).getSheetByName(sheetName); if (!sheet) return; const row = findRowByUserId(sheet, sheetName, userId); if (!row) return; const rowData = sheet.getRange(row, 1, 1, 11).getValues()[0]; const nextReset = rowData[BOT_SHEET_COL.NEXT_RESET]; if (!nextReset || new Date() < new Date(nextReset)) return; const newReset = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); sheet.getRange(row, BOT_SHEET_COL.TOKEN_USED + 1, 1, 2).setValues([[0, newReset]]); try { CacheService.getScriptCache().remove(CACHE_KEY.USAGE(botLabel, userId)); } catch (e) { logError("handleResetIfNeeded.cache", e.message); } console.log(JSON.stringify({ fn: "handleResetIfNeeded", userId, botLabel })); } catch (e) { logError("handleResetIfNeeded", e.message); throw e; } } // ============================================================ // Gemini統合コア // ============================================================ function callGeminiCore(model, apiKey, payload) { const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`; try { const res = UrlFetchApp.fetch(url, { method: "post", contentType: "application/json", muteHttpExceptions: true, payload: JSON.stringify(payload) }); const code = res.getResponseCode(); if (code !== 200) { logError("callGeminiCore", `HTTP_${code}`, res.getContentText().substring(0, 200)); return { ok: false, data: null, error: `HTTP_${code}` }; } const data = safeJsonParse(res.getContentText()); if (!data) return { ok: false, data: null, error: "PARSE_ERROR" }; return { ok: true, data, error: null }; } catch (e) { logError("callGeminiCore", e.message); return { ok: false, data: null, error: e.message }; } } function extractGeminiText(data) { const candidate = data?.candidates?.[0]; if (!candidate?.content?.parts?.length) return null; return candidate.content.parts[0].text || null; } function callGeminiChat(currentParts, instruction, history, model, apiKey, maxTokens) { const payload = { systemInstruction: { parts: [{ text: instruction }] }, contents: [...history, { role: "user", parts: currentParts }], generationConfig: { temperature: 0.7, maxOutputTokens: maxTokens || 5000 } }; const { ok, data, error } = callGeminiCore(model, apiKey, payload); if (!ok) { logError("callGeminiChat", error); return { reply: USER_MSG.SYSTEM_ERROR, tokens: 0, suggestions: [] }; } const textReply = extractGeminiText(data); if (!textReply) { logError("callGeminiChat", "EMPTY_RESPONSE"); return { reply: USER_MSG.SYSTEM_ERROR, tokens: 0, suggestions: [] }; } const suggestions = []; const matches = textReply.match(/\[SUGGESTION:\s*([\s\S]*?)\]/g); if (matches) { matches.forEach(s => { const c = s.replace(/\[SUGGESTION:\s*|\]/g, "").trim(); if (c) suggestions.push(c); }); } return { reply: textReply, tokens: data.usageMetadata?.totalTokenCount || 0, suggestions: suggestions.slice(0, 3) }; } function callGeminiJson(userText, systemPrompt, model, apiKey) { const payload = { systemInstruction: { parts: [{ text: systemPrompt }] }, contents: [{ role: "user", parts: [{ text: userText }] }], generationConfig: { responseMimeType: "application/json", temperature: 0.1 } }; const { ok, data, error } = callGeminiCore(model, apiKey, payload); if (!ok) { logError("callGeminiJson", error); return null; } const raw = extractGeminiText(data); if (!raw) { logError("callGeminiJson", "EMPTY_RESPONSE"); return null; } return safeJsonParse(raw.replace(/```json/g, "").replace(/```/g, "").trim()); } function callGeminiWithImages(blobs, textPrompt, systemPrompt, model, apiKey, useJsonMode) { const parts = []; if (textPrompt) parts.push({ text: textPrompt }); blobs.forEach(blob => { try { parts.push({ inline_data: { mime_type: blob.getContentType() || "image/jpeg", data: Utilities.base64Encode(blob.getBytes()) } }); } catch (e) { logError("callGeminiWithImages.blob", e.message); } }); if (parts.length === 0) return { reply: "画像の読み込みに失敗しました。", tokens: 0 }; const payload = { systemInstruction: { parts: [{ text: systemPrompt }] }, contents: [{ role: "user", parts }], generationConfig: { temperature: 0.1, maxOutputTokens: 1000 } }; if (useJsonMode) payload.generationConfig.responseMimeType = "application/json"; const { ok, data, error } = callGeminiCore(model, apiKey, payload); if (!ok) { logError("callGeminiWithImages", error); return { reply: "解析に失敗しました。", tokens: 0 }; } const text = extractGeminiText(data); if (!text) { logError("callGeminiWithImages", "EMPTY_RESPONSE"); return { reply: "解析に失敗しました。", tokens: 0 }; } return { reply: text, tokens: data.usageMetadata?.totalTokenCount || 0 }; } // ============================================================ // doGet(clearallcache・setuptriggersエンドポイント追加) // ============================================================ function doGet(e) { try { if (!e?.parameter) return responseJson({ status: "ok", msg: "v273 Online" }); const params = {}; for (let key in e.parameter) params[key.toLowerCase()] = String(e.parameter[key]); if (params.key === "kurolo2026") { if (params.action === "sync") return responseJson({ status: "success", data: syncSystemData() }); if (params.action === "getstats") return responseJson({ status: "success", normalQueue: 0, lastSuccess: new Date().toLocaleString(), lastError: "正常稼働中" }); if (params.action === "setoverride") { PropertiesService.getScriptProperties().setProperty("SYSTEM_OVERRIDE", e.parameter.text || ""); return responseJson({ status: "success", msg: "Override set" }); } if (params.action === "clearallcache") { return responseJson({ status: "success", data: clearAllCacheFromRemote() }); } if (params.action === "setuptriggers") { setupNightlyBatchTrigger(); return responseJson({ status: "success", msg: "Triggers configured." }); } } const mode = String(params.mode || "chat"); const userId = normalizeId(params.uid || params.userid); const botId = params.bot || "PLUMERIA"; if (!userId) return responseJson({ status: "error", msg: USER_MSG.INVALID_REQ }); const config = getAppConfigByBotId(botId); const normalizedBotId = (config.label || botId).toUpperCase(); const targetPersona = mode === "editor" ? "02_KARIS" : config.targetPersona; const db = getCachedSystemDataLight(); const customer = getCustomerWithReset(userId); const rawHistory = getFilteredHistory(userId, mode === "history" ? "chat" : mode, normalizedBotId, customer.folderId); const mappedHistory = rawHistory.map(h => ({ role: h.role === "assistant" ? "model" : "user", parts: [{ text: String(h.content || "").replace(/ユーザー/g, customer.nickname) }], persona: h.role === "assistant" ? (h.persona || targetPersona) : "", iconUrl: h.role === "assistant" ? getIconUrl(h.persona || targetPersona, db, config.fixedIcon) : "" })); return responseJson({ status: "success", history: mappedHistory, customer, icons: db.icons, currentBot: targetPersona }); } catch (e) { logError("doGet", e.message); return responseJson({ status: "error", msg: USER_MSG.SYSTEM_ERROR }); } } // キャッシュ全クリア(拡張機能から呼ばれる) function clearAllCacheFromRemote() { const sc = CacheService.getScriptCache(); const ss = SpreadsheetApp.openById(CONFIG.CUSTOMER_SHEET_ID); const meibo = ss.getSheetByName("名簿"); const botList = ["MENTOR", "ARK", "NEKONOKIMOCHI", "PLUMERIA", "KULOST", "SJ"]; const keys = [ CACHE_KEY.SYS_DB(), CACHE_KEY.BOT_CONFIGS(), CACHE_KEY.PERSONA_INDEX(), CACHE_KEY.QUEUE_FAILED() ]; botList.forEach(label => { keys.push(CACHE_KEY.BOT_SHEET_CONF(label)); keys.push(CACHE_KEY.SHEET_INDEX(label === "MENTOR" ? "MENTOR_DB" : label)); for (let s = 0; s < QUEUE_SHARD_COUNT; s++) { keys.push(CACHE_KEY.QUEUE_PROC(s)); } }); keys.push(CACHE_KEY.SHEET_INDEX("名簿")); let userCount = 0; try { const lastRow = meibo.getLastRow(); if (lastRow >= 2) { const uidData = meibo.getRange(2, 1, lastRow - 1, 1).getValues(); uidData.forEach(row => { const uid = normalizeId(row[0]); if (!uid) return; userCount++; keys.push(CACHE_KEY.CUSTOMER(uid)); botList.forEach(b => { keys.push(CACHE_KEY.USAGE(b, uid)); keys.push(CACHE_KEY.METABOLIC(b, uid)); keys.push(CACHE_KEY.LONG_TERM(b, uid)); }); }); } } catch (e) { logError("clearAllCacheFromRemote.meibo", e.message); } let deleted = 0; for (let i = 0; i < keys.length; i += 250) { try { sc.removeAll(keys.slice(i, i + 250)); deleted += Math.min(250, keys.length - i); } catch (e) { logError("clearAllCacheFromRemote.remove", e.message); } } console.log(JSON.stringify({ fn: "clearAllCacheFromRemote", deleted, users: userCount })); return { deleted, users: userCount }; } // ============================================================ // doPost // ============================================================ function doPost(e) { try { const rawBody = e?.postData?.contents; if (!rawBody) return responseJson({ status: "error", msg: USER_MSG.INVALID_REQ }); const json = safeJsonParse(rawBody); if (!json) return responseJson({ status: "error", msg: USER_MSG.INVALID_REQ }); if (json.events?.length > 0) { const event = json.events[0]; const config = getAppConfigByBotId(json.destination || "PLUMERIA"); if (event.replyToken !== "00000000000000000000000000000000" && event.type === "message") { sendLineTextReply(event.replyToken, getLineGuideMessage(), config.LINE_ACCESS_TOKEN); } return responseJson({ status: "ok" }); } if (json.source !== "liff") return responseJson({ status: "ok" }); const jobId = json.jobId || null; const botId = json.bot || (json.mode === "editor" ? "KARIS" : "PLUMERIA"); const config = getAppConfigByBotId(botId); const normalizedBotId = (config.label || botId).toUpperCase(); if (!verifyClientSecret(json.clientSecret || "")) { const result = { status: "error", msg: USER_MSG.UNAUTHORIZED }; postResultToWorker(jobId, result, "", normalizedBotId); return responseJson(result); } let verifiedUserId = normalizeId(json.userId); const requiresAuth = !json.action || json.action === "chat"; if (requiresAuth && json.idToken) { const authResult = verifyLineIdToken(json.idToken, normalizedBotId); if (!authResult.valid) { logError("doPost.auth", authResult.reason, verifiedUserId); const result = { status: "error", msg: USER_MSG.AUTH_FAILED, reason: authResult.reason }; postResultToWorker(jobId, result, verifiedUserId, normalizedBotId); return responseJson(result); } verifiedUserId = authResult.userId; } const userId = verifiedUserId; if (!userId) { const result = { status: "error", msg: USER_MSG.INVALID_REQ }; postResultToWorker(jobId, result, "", normalizedBotId); return responseJson(result); } const botSheetConf = getBotSheetConfig(config.label); const db = getCachedSystemDataLight(); if (json.action === "startup") { const customer = getCustomerWithReset(userId, json.userName); const ruleKey = config.ruleName || normalizedBotId; const rule = db.rules?.[ruleKey] || null; if (rule?.target_sheet) initServiceSheetRow(userId, customer.nickname, rule.target_sheet); handleResetIfNeeded(userId, normalizedBotId); if (json.isFirstToday) { try { handleCustomAction({ action: "daily_access", userId }, config, db); } catch (ex) { logError("doPost.daily_access", ex.message); } } const limitInfo = checkBotSpecificLimit(userId, normalizedBotId, botSheetConf); return responseJson({ status: "success", authenticated: true, nickname: customer.nickname, plan: limitInfo.plan, usagePercent: limitInfo.usagePercent, freeCount: limitInfo.freeCount, tokenUsed: limitInfo.tokenUsed, totalLimit: limitInfo.totalLimit, extraToken: limitInfo.extraToken }); } if (json.action === "init_row") { const customer = getCustomerWithReset(userId, json.userName); const ruleKey = config.ruleName || normalizedBotId; const rule = db.rules?.[ruleKey] || null; if (rule?.target_sheet) initServiceSheetRow(userId, customer.nickname, rule.target_sheet); return responseJson({ status: "success", msg: "row initialized", nickname: customer.nickname }); } if (json.action === "get_logs") return responseJson(getServiceLogs(userId)); if (json.action === "save_settings") return responseJson(handleSaveSettings(json, config, db)); if (json.action && json.action !== "chat") return responseJson(handleCustomAction(json, config, db)); return handleChatRequest(json, config, normalizedBotId, userId, botSheetConf, db, jobId); } catch (e) { logError("doPost", e.message); return responseJson({ status: "error", msg: USER_MSG.SYSTEM_ERROR }); } } // ============================================================ // chat処理 // ============================================================ function handleChatRequest(json, config, normalizedBotId, userId, botSheetConf, db, jobId) { const userLock = LockService.getUserLock(); const locked = userLock.tryLock(5000); if (!locked) { return responseJson({ status: "locked", msg: USER_MSG.LOCKED }); } try { const customer = getCustomerWithReset(userId, json.userName); if (config.label?.toUpperCase() === "ARK") { const result = handleArkRequest(json, config, userId, customer, botSheetConf); postResultToWorker(jobId, result, userId, normalizedBotId); return responseJson(result); } const limitInfo = checkBotSpecificLimit(userId, normalizedBotId, botSheetConf); if (limitInfo.plan === "未加入" && limitInfo.freeCount <= 0) { const result = { status: "free_limit_reached", reply: USER_MSG.FREE_LIMIT, liffUrl: getLiffUrl(normalizedBotId) }; postResultToWorker(jobId, result, userId, normalizedBotId); return responseJson(result); } if (limitInfo.plan !== "未加入" && limitInfo.isLimit) { const result = { status: "limit_reached", reply: USER_MSG.TOKEN_LIMIT, usagePercent: 0 }; postResultToWorker(jobId, result, userId, normalizedBotId); return responseJson(result); } let targetPersona = config.targetPersona; const ruleKey = config.ruleName || normalizedBotId; const rule = db.rules?.[ruleKey] || null; if (rule?.onboarding?.enabled) { const status = getOnboardingStatus(userId, rule.target_sheet); if (status < rule.onboarding.completion_status_value) { const result = handleLiffOnboarding(json, rule, config, db, userId, customer); postResultToWorker(jobId, result, userId, normalizedBotId); return responseJson(result); } } if (json.mode === "editor") { targetPersona = "02_KARIS"; } else if (targetPersona === "ROUTER" || config.label === "PLUMERIA") { const routing = analyzeIntentAndRoute(json.userText, customer.plan, config.GEMINI_API_KEY); targetPersona = routing.persona || "01_PLUMERIA"; } const history = getFilteredHistory(userId, json.mode, normalizedBotId, customer.folderId).map(h => ({ role: h.role === "assistant" ? "model" : "user", parts: [{ text: h.content }] })); const longTermMemory = loadLongTermMemory(userId, normalizedBotId, customer.folderId); const ngramMemory = loadMetabolicMemory(userId, normalizedBotId, customer.folderId); const personaText = getPersonaFromDrive(targetPersona); const prompt = buildAgentPrompt(personaText, customer.nickname, longTermMemory, ngramMemory); const selectedModel = selectModel(customer.plan, botSheetConf, CONFIG.DEFAULT_MODEL); const maxTokens = botSheetConf?.tokenPerReply || 5000; const ai = callGeminiChat( [{ text: json.userText }], prompt, history, selectedModel, config.GEMINI_API_KEY, maxTokens ); let replyText = ai.reply.replace(/<[^>]*>/gm, "").replace(/\[SUGGESTION:.*?\]/g, "").trim(); saveHistory(userId, json.mode, "user", json.userText, targetPersona, normalizedBotId, customer.folderId); saveHistory(userId, json.mode, "assistant", replyText, targetPersona, normalizedBotId, customer.folderId); saveMetabolicMemory(userId, normalizedBotId, customer.folderId, json.userText, replyText); appendToDriveLog(userId, customer.folderId, json.userText, replyText); pushToUsageQueue(userId, normalizedBotId, ai.tokens); const estimatedUsed = limitInfo.tokenUsed + ai.tokens; const estimatedPercent = 100 - Math.min(100, Math.round((estimatedUsed / limitInfo.totalLimit) * 100)); const alertMessage = generateAlertIfNeeded(estimatedPercent, userId); if (alertMessage) replyText = replyText + "\n\n" + alertMessage; const result = { status: "success", reply: replyText, suggestions: ai.suggestions, persona: targetPersona, iconUrl: getIconUrl(targetPersona, db, config.fixedIcon), usagePercent: estimatedPercent, freeCount: limitInfo.plan === "未加入" ? Math.max(0, limitInfo.freeCount - 1) : limitInfo.freeCount, plan: limitInfo.plan }; postResultToWorker(jobId, result, userId, normalizedBotId); return responseJson(result); } finally { userLock.releaseLock(); } } // ============================================================ // 画像処理・ARK // ============================================================ function getImagesAsBlobs(json, config) { const blobs = []; if (json.liff_images?.length > 0) { json.liff_images.forEach((base64Data, index) => { try { const data = base64Data.includes(",") ? base64Data.split(",")[1] : base64Data; blobs.push(Utilities.newBlob(Utilities.base64Decode(data), "image/jpeg", `liff_image_${index}.jpg`)); } catch (e) { logError("getImagesAsBlobs.decode", e.message); } }); } if (json.line_image_id && config.LINE_ACCESS_TOKEN) { try { const res = UrlFetchApp.fetch( `https://api-data.line.me/v2/bot/message/${json.line_image_id}/content`, { headers: { "Authorization": "Bearer " + config.LINE_ACCESS_TOKEN }, muteHttpExceptions: true } ); if (res.getResponseCode() === 200) blobs.push(res.getBlob()); else logError("getImagesAsBlobs.line", `HTTP_${res.getResponseCode()}`); } catch (e) { logError("getImagesAsBlobs.line", e.message); } } return blobs; } function handleArkRequest(json, config, userId, customer, botSheetConf) { try { const personaText = getPersonaFromDrive("12_ARK"); const blobs = getImagesAsBlobs(json, config); const userText = json.userText || json.text || ""; const model = config.MODEL || CONFIG.DEFAULT_MODEL; const apiKey = config.GEMINI_API_KEY; let arkResult; if (blobs.length > 0) { const res = callGeminiWithImages(blobs, userText, personaText, model, apiKey, true); arkResult = safeJsonParse(res.reply.replace(/```json/g, "").replace(/```/g, "").trim()) || { ARK_Rank: "注意", Logic_Reason: "解析エラー", Action_Advice: "再送信してください", Reply_Message: "画像の解析に問題が発生しました。" }; } else { arkResult = callGeminiJson(userText, personaText, model, apiKey) || { ARK_Rank: "注意", Logic_Reason: "解析エラー", Action_Advice: "再送信", Reply_Message: "解析に失敗しました。" }; } const replyMsg = arkResult.Reply_Message || arkResult.reply_text || "解析完了しました。"; saveHistory(userId, "chat", "user", userText || "[画像送信]", "12_ARK", "ARK", customer.folderId); saveHistory(userId, "chat", "assistant", replyMsg, "12_ARK", "ARK", customer.folderId); pushToUsageQueue(userId, "ARK", 0); return { status: "success", reply: replyMsg, ark_result: arkResult }; } catch (e) { logError("handleArkRequest", e.message); return { status: "error", msg: USER_MSG.SYSTEM_ERROR }; } } // ============================================================ // システムデータ // ============================================================ function getCachedSystemDataLight() { const sc = CacheService.getScriptCache(); try { const cached = sc.get(CACHE_KEY.SYS_DB()); if (cached) { const parsed = safeJsonParse(cached); if (parsed) return parsed; } const root = DriveApp.getFolderById(CONFIG.FOLDER_ID_ROOT); const sysConfig = root.getFoldersByName("DATA").next().getFoldersByName("SYSTEM_CONFIG").next(); const files = sysConfig.getFilesByName("sys_db_light.json"); if (!files.hasNext()) return { icons: {}, rules: {}, botConfigs: {} }; const data = JSON.parse(files.next().getBlob().getDataAsString()); try { sc.put(CACHE_KEY.SYS_DB(), JSON.stringify(data), TTL.SYSTEM_DB); } catch (e) { logError("getCachedSystemDataLight.cache", e.message); } return data; } catch (e) { logError("getCachedSystemDataLight", e.message); throw e; } } function syncSystemData() { const db = { icons: {}, rules: {}, botConfigs: {} }; let personaCount = 0, knowledgeCount = 0; try { const root = DriveApp.getFolderById(CONFIG.FOLDER_ID_ROOT); const dataFolder = root.getFoldersByName("DATA").next(); const sysConfigFolder = dataFolder.getFoldersByName("SYSTEM_CONFIG").next(); const aiTeamFolder = root.getFoldersByName("AI_TEAM").next(); ["sys_db.json", "sys_db_light.json"].forEach(name => { const old = sysConfigFolder.getFilesByName(name); while (old.hasNext()) old.next().setTrashed(true); }); try { const f = sysConfigFolder.getFoldersByName("SYSTEM_ICONS").next().getFiles(); while (f.hasNext()) { const file = f.next(); db.icons[file.getName().split(".")[0].toUpperCase()] = `https://drive.google.com/uc?export=view&id=${file.getId()}`; } } catch (e) { logError("syncSystemData.icons", e.message); } try { const sc = CacheService.getScriptCache(); const pf = aiTeamFolder.getFoldersByName("PERSONA").next().getFiles(); while (pf.hasNext()) { const f = pf.next(); personaCount++; try { sc.remove(CACHE_KEY.PERSONA(f.getName().split(".")[0])); } catch (e2) { logError("syncSystemData.persona_cache", e2.message); } } try { sc.remove(CACHE_KEY.PERSONA_INDEX()); } catch (e) {} } catch (e) { logError("syncSystemData.persona", e.message); } try { knowledgeCount = countFilesRecursively(dataFolder.getFoldersByName("KNOWLEDGE").next()); } catch (e) { logError("syncSystemData.knowledge", e.message); } try { const rf = sysConfigFolder.getFoldersByName("RULES").next().getFiles(); while (rf.hasNext()) { const f = rf.next(); try { db.rules[f.getName().split(".")[0].toUpperCase()] = JSON.parse(f.getBlob().getDataAsString()); } catch (e) { logError("syncSystemData.rule_parse", e.message, f.getName()); } } } catch (e) { logError("syncSystemData.rules", e.message); } try { const sheet = SpreadsheetApp.openById(CONFIG.CUSTOMER_SHEET_ID).getSheetByName("Bot設定"); if (sheet) { const data = sheet.getDataRange().getValues(); for (let i = 1; i < data.length; i++) { const botId = String(data[i][BOTCONF_COL.LINE_BOT_ID]).trim().toUpperCase(); const label = String(data[i][BOTCONF_COL.SERVICE_NAME]).trim(); if (!label) continue; const conf = { label, targetPersona: String(data[i][BOTCONF_COL.MAIN_PERSONA] || "ROUTER").trim(), fixedIcon: String(data[i][BOTCONF_COL.FIXED_ICON_URL] || "").trim(), ruleName: String(data[i][BOTCONF_COL.RULE_NAME] || "").trim().toUpperCase() }; if (botId) db.botConfigs[botId] = conf; db.botConfigs[label.toUpperCase()] = conf; } } } catch (e) { logError("syncSystemData.botconf", e.message); } // ⑧ サイズチェック const dbStr = JSON.stringify(db); if (dbStr.length > 500000) { logError("syncSystemData", "SYS_DB_TOO_LARGE", `size=${dbStr.length}`); } sysConfigFolder.createFile("sys_db_light.json", dbStr); try { CacheService.getScriptCache().removeAll([CACHE_KEY.SYS_DB(), CACHE_KEY.BOT_CONFIGS()]); } catch (e) { logError("syncSystemData.cache_clear", e.message); } } catch (e) { logError("syncSystemData.critical", e.message); throw e; } return { icons: Object.keys(db.icons).length, personas: personaCount, knowledge: knowledgeCount, rules: Object.keys(db.rules).length }; } function countFilesRecursively(folder) { let count = 0; try { const files = folder.getFiles(); while (files.hasNext()) { files.next(); count++; } const subs = folder.getFolders(); while (subs.hasNext()) count += countFilesRecursively(subs.next()); } catch (e) { logError("countFilesRecursively", e.message); throw e; } return count; } // ⑤ Personaインデックス(O(1)アクセス) function buildPersonaIndex() { const sc = CacheService.getScriptCache(); const cacheKey = CACHE_KEY.PERSONA_INDEX(); const cached = sc.get(cacheKey); if (cached) { const parsed = safeJsonParse(cached); if (parsed) return parsed; } try { const personaFolder = DriveApp.getFolderById(CONFIG.FOLDER_ID_ROOT) .getFoldersByName("AI_TEAM").next() .getFoldersByName("PERSONA").next(); const files = personaFolder.getFiles(); const index = {}; while (files.hasNext()) { const f = files.next(); const key = f.getName().split(".")[0].toUpperCase(); index[key] = f.getId(); } try { sc.put(cacheKey, JSON.stringify(index), TTL.PERSONA_INDEX); } catch (e) { logError("buildPersonaIndex.cache", e.message); } return index; } catch (e) { logError("buildPersonaIndex", e.message); throw e; } } function getPersonaFromDrive(personaFileId, isFallback) { const cacheKey = CACHE_KEY.PERSONA(personaFileId); const sc = CacheService.getScriptCache(); try { const cached = sc.get(cacheKey); if (cached) return cached; const index = buildPersonaIndex(); const fileId = index[String(personaFileId).toUpperCase()]; if (!fileId) { if (!isFallback) return getPersonaFromDrive("01_PLUMERIA", true); return "あなたはAIアシスタントです。"; } const text = DriveApp.getFileById(fileId).getBlob().getDataAsString(); try { if (text.length < 100000) sc.put(cacheKey, text, TTL.PERSONA); } catch (e) { logError("getPersonaFromDrive.cache", e.message); } return text; } catch (e) { logError("getPersonaFromDrive", e.message, personaFileId); if (!isFallback) return getPersonaFromDrive("01_PLUMERIA", true); return "あなたはAIアシスタントです。"; } } function buildAgentPrompt(personaText, nickname, longTermMemory, ngramMemory) { const overrideCmd = PropertiesService.getScriptProperties().getProperty("SYSTEM_OVERRIDE") || ""; let base = "あなたは以下のキャラクターに完全に憑依して対話してください。\n"; if (overrideCmd) base += `🚨【特権命令】${overrideCmd}\n`; const mem = `\n【長期記憶】\n${longTermMemory.global || "特になし"}\n【個別エピソード】\n${longTermMemory.local || "特になし"}\n`; const ngramMem = ngramMemory ? `\n【代謝メモリ】\n${JSON.stringify(ngramMemory.core || {})}\n` : ""; return `${base}ユーザー名: ${nickname}\n${mem}${ngramMem}\n【詳細設定】\n${personaText}\n\n【必須ルール】\n1. HTMLタグ禁止。Markdown使用。\n2. 回答末尾に[SUGGESTION:]タグで提案3つ。\n\n[SUGGESTION: 提案1]\n[SUGGESTION: 提案2]\n[SUGGESTION: 提案3]`; } function getAppConfigByBotId(searchId) { const props = PropertiesService.getScriptProperties(); const sid = String(searchId || "PLUMERIA").trim().toUpperCase(); let botConfigs = null; const sc = CacheService.getScriptCache(); const cachedStr = sc.get(CACHE_KEY.BOT_CONFIGS()); if (cachedStr) { const parsed = safeJsonParse(cachedStr); if (parsed) botConfigs = parsed; } if (!botConfigs) { botConfigs = {}; try { const sheet = SpreadsheetApp.openById(CONFIG.CUSTOMER_SHEET_ID).getSheetByName("Bot設定"); if (sheet) { const data = sheet.getDataRange().getValues(); for (let i = 1; i < data.length; i++) { const bId = String(data[i][BOTCONF_COL.LINE_BOT_ID]).trim().toUpperCase(); const label = String(data[i][BOTCONF_COL.SERVICE_NAME]).trim(); if (!label) continue; const conf = { botId: bId, label, targetPersona: String(data[i][BOTCONF_COL.MAIN_PERSONA] || "ROUTER").trim(), fixedIcon: String(data[i][BOTCONF_COL.FIXED_ICON_URL] || "").trim(), ruleName: String(data[i][BOTCONF_COL.RULE_NAME] || "").trim().toUpperCase() }; if (bId) botConfigs[bId] = conf; botConfigs[label.toUpperCase()] = conf; } try { sc.put(CACHE_KEY.BOT_CONFIGS(), JSON.stringify(botConfigs), TTL.BOT_CONFIG); } catch (e) { logError("getAppConfigByBotId.cache_put", e.message); } } } catch (e) { logError("getAppConfigByBotId", e.message); throw e; } } let config = botConfigs[sid]; if (!config) { const allProps = props.getProperties(); for (let key in allProps) { if (key.startsWith("LINE_BOT_ID_") && allProps[key].toUpperCase() === sid) { config = botConfigs[key.replace("LINE_BOT_ID_", "").toUpperCase()]; break; } } } if (!config) config = botConfigs["PLUMERIA"] || { label: "PLUMERIA", targetPersona: "ROUTER", fixedIcon: "", ruleName: "" }; const labelUpper = String(config.label).toUpperCase(); let token = props.getProperty("LINE_ACCESS_TOKEN_" + labelUpper); if (!token) { const allProps = props.getProperties(); for (let key in allProps) { if (key.startsWith("LINE_BOT_ID_") && allProps[key].toUpperCase() === sid) { token = allProps["LINE_ACCESS_TOKEN_" + key.replace("LINE_BOT_ID_", "")]; break; } } } if (!token) token = props.getProperty("LINE_ACCESS_TOKEN_PLUMERIA"); return { LINE_ACCESS_TOKEN: token, GEMINI_API_KEY: props.getProperty("GEMINI_API_KEY"), MODEL: CONFIG.DEFAULT_MODEL, TOKEN_LIMIT_FREE: 1500, TOKEN_LIMIT_PRO: 4000, TOKEN_LIMIT_VIP: 8192, label: config.label, targetPersona: config.targetPersona, fixedIcon: config.fixedIcon, ruleName: config.ruleName }; } function getIconUrl(personaName, db, fixedIconUrl) { if (fixedIconUrl?.startsWith("https://")) return fixedIconUrl; let key = String(personaName).toUpperCase(); if (key.includes("_")) key = key.split("_").slice(1).join("_"); return db?.icons?.[key] || db?.icons?.["PLUMERIA"] || ""; } // ============================================================ // Router // ============================================================ const ROUTER_KEYWORD_MAP = [ { keywords: ["動画", "編集", "veo", "プロンプト", "script", "脚本", "撮影", "footage"], persona: "02_KARIS" }, { keywords: ["データ", "分析", "売上", "統計", "csv", "表", "集計", "グラフ"], persona: "03_LANTOM" }, { keywords: ["哲学", "矛盾", "逆説", "本質", "なぜ", "理由", "考え方"], persona: "04_PARADOX" }, { keywords: ["占い", "未来", "予測", "運勢", "オラクル"], persona: "05_ORACLE" } ]; function analyzeIntentAndRoute(text, userPlan, apiKey) { if (!text || text.length < 5) return { persona: "01_PLUMERIA" }; const lower = text.toLowerCase(); const plan = String(userPlan || "").toLowerCase(); const isPremium = plan.includes("プレミアム") || plan.includes("vip"); if (text.length < 50) { for (const rule of ROUTER_KEYWORD_MAP) { if (rule.keywords.some(kw => lower.includes(kw))) { if (!isPremium && (rule.persona === "05_ORACLE" || rule.persona === "06_REY")) return { persona: "01_PLUMERIA" }; return { persona: rule.persona }; } } return { persona: "01_PLUMERIA" }; } for (const rule of ROUTER_KEYWORD_MAP) { if (rule.keywords.some(kw => lower.includes(kw))) { if (!isPremium && (rule.persona === "05_ORACLE" || rule.persona === "06_REY")) return { persona: "01_PLUMERIA" }; return { persona: rule.persona }; } } try { const allowed = ["01_PLUMERIA", "02_KARIS", "03_LANTOM", "04_PARADOX"]; if (isPremium) allowed.push("05_ORACLE", "06_REY"); const result = callGeminiJson( text, `ルーターとして最適なペルソナをJSON形式のみで返せ。{"persona":"ID"}\n許可: ${allowed.join(", ")}`, "gemini-2.5-flash", apiKey ); if (result?.persona && allowed.includes(result.persona)) return { persona: result.persona }; return { persona: "01_PLUMERIA" }; } catch (e) { logError("analyzeIntentAndRoute", e.message); return { persona: "01_PLUMERIA" }; } } // ============================================================ // メモリ(metabolic / longTerm) // ============================================================ function loadMetabolicMemory(userId, botId, folderId) { if (!folderId) return { core: { profile: {}, achievements: [], recentTopics: [] } }; const cacheKey = CACHE_KEY.METABOLIC(botId, userId); const sc = CacheService.getScriptCache(); try { const cached = sc.get(cacheKey); if (cached) { const parsed = safeJsonParse(cached); if (parsed) return { core: parsed }; } const fileName = `metabolic_${botId}.json`; const files = DriveApp.getFolderById(folderId).getFilesByName(fileName); if (!files.hasNext()) return { core: { profile: {}, achievements: [], recentTopics: [] } }; const core = safeJsonParse(files.next().getBlob().getDataAsString()) || { profile: {}, achievements: [], recentTopics: [] }; try { sc.put(cacheKey, JSON.stringify(core), TTL.METABOLIC); } catch (e) { logError("loadMetabolicMemory.cache", e.message); } return { core }; } catch (e) { logError("loadMetabolicMemory", e.message); return { core: { profile: {}, achievements: [], recentTopics: [] } }; } } function saveMetabolicMemory(userId, botId, folderId, userMsg, aiMsg) { if (!folderId) return; try { const userFolder = DriveApp.getFolderById(folderId); const fileName = `metabolic_${botId}.json`; const files = userFolder.getFilesByName(fileName); let core = { profile: {}, achievements: [], recentTopics: [] }; let targetFile = null; if (files.hasNext()) { targetFile = files.next(); core = safeJsonParse(targetFile.getBlob().getDataAsString()) || core; } if (!core.recentTopics) core.recentTopics = []; core.recentTopics.push({ u: userMsg.substring(0, 100), a: aiMsg.substring(0, 100), t: new Date().toISOString() }); if (core.recentTopics.length > 20) core.recentTopics = core.recentTopics.slice(-20); const content = JSON.stringify(core); if (targetFile) targetFile.setContent(content); else userFolder.createFile(fileName, content); try { CacheService.getScriptCache().remove(CACHE_KEY.METABOLIC(botId, userId)); } catch (e) { logError("saveMetabolicMemory.cache", e.message); } } catch (e) { logError("saveMetabolicMemory", e.message); throw e; } } function trimMetabolicMemory(core) { if (!core) return { profile: {}, achievements: [], recentTopics: [] }; if (core.recentTopics?.length > 10) core.recentTopics = core.recentTopics.slice(-10); return core; } function loadLongTermMemory(userId, botId, folderId) { if (!folderId) return { global: "", local: "" }; const cacheKey = CACHE_KEY.LONG_TERM(botId, userId); const sc = CacheService.getScriptCache(); try { const cached = sc.get(cacheKey); if (cached) { const parsed = safeJsonParse(cached); if (parsed) return parsed; } const safeBotId = String(botId || "default").toUpperCase().replace(/[^A-Z0-9_-]/g, ""); const fileName = `mem_${safeBotId}_${userId}.json`; const files = DriveApp.getFolderById(folderId).getFilesByName(fileName); if (!files.hasNext()) return { global: "", local: "" }; const memData = safeJsonParse(files.next().getBlob().getDataAsString()); if (!memData) return { global: "", local: "" }; const result = { global: memData.global || "", local: memData.local || "" }; try { sc.put(cacheKey, JSON.stringify(result), TTL.CUSTOMER); } catch (e) { logError("loadLongTermMemory.cache", e.message); } return result; } catch (e) { logError("loadLongTermMemory", e.message); return { global: "", local: "" }; } } function compressToLongTermMemory(userId, botId, oldText, apiKey, uFolder) { try { const safeBotId = String(botId || "default").toUpperCase().replace(/[^A-Z0-9_-]/g, ""); const jsonRes = callGeminiJson( `履歴を要約してJSONで。{"global":"","local":""}\n${oldText}`, "あなたは記憶圧縮AIです。会話履歴を要約してJSON形式のみで出力してください。", CONFIG.DEFAULT_MODEL, apiKey ); if (!jsonRes) { logError("compressToLongTermMemory", "NO_RESULT"); return; } const memFileName = `mem_${safeBotId}_${userId}.json`; let memData = { global: "", local: "" }; const memFiles = uFolder.getFilesByName(memFileName); if (memFiles.hasNext()) { const mf = memFiles.next(); const parsed = safeJsonParse(mf.getBlob().getDataAsString()); if (parsed) memData = parsed; memData.global = ((memData.global || "") + " " + (jsonRes.global || "")).substring(0, 1000); memData.local = ((memData.local || "") + " " + (jsonRes.local || "")).substring(0, 1500); mf.setContent(JSON.stringify(memData)); } else { memData.global = jsonRes.global || ""; memData.local = jsonRes.local || ""; uFolder.createFile(memFileName, JSON.stringify(memData)); } try { CacheService.getScriptCache().remove(CACHE_KEY.LONG_TERM(botId, userId)); } catch (e) { logError("compressToLongTermMemory.cache", e.message); } } catch (e) { logError("compressToLongTermMemory", e.message); throw e; } } function runMetabolicProcess(userId, botId, core, apiKey) { if (!core?.recentTopics?.length) return trimMetabolicMemory(core); try { const jsonRes = callGeminiJson( `以下のコアメモリからユーザーの背景・実績・嗜好をJSON形式のみで出力せよ:\n${JSON.stringify(core)}`, "あなたはユーザープロファイル抽出AIです。JSON形式のみで出力してください。", CONFIG.DEFAULT_MODEL, apiKey ); if (jsonRes) core.profile = { ...core.profile, ...jsonRes }; } catch (e) { logError("runMetabolicProcess", e.message); } return trimMetabolicMemory(core); } // ============================================================ // Driveログ・アラート・履歴 // ============================================================ function appendToDriveLog(userId, folderId, userMsg, aiMsg) { if (!folderId) return; try { const userFolder = DriveApp.getFolderById(folderId); const dateStr = Utilities.formatDate(new Date(), "Asia/Tokyo", "yyyy-MM-dd"); const fileName = `log_${dateStr}.txt`; const timestamp = Utilities.formatDate(new Date(), "Asia/Tokyo", "HH:mm:ss"); const logEntry = `[${timestamp}] USER: ${userMsg}\n[${timestamp}] AI: ${aiMsg}\n\n`; const files = userFolder.getFilesByName(fileName); if (files.hasNext()) { const file = files.next(); const existing = file.getBlob().getDataAsString(); const lines = existing.split("\n"); const trimmed = lines.length > 1000 ? lines.slice(-1000).join("\n") : existing; file.setContent(trimmed + logEntry); } else { userFolder.createFile(fileName, logEntry); } } catch (e) { logError("appendToDriveLog", e.message); } } function generateAlertIfNeeded(usagePercent, userId) { try { const props = PropertiesService.getScriptProperties(); const alertKey = `ALERT_STATE_${userId}`; const lastAlert = props.getProperty(alertKey) || "100"; if (usagePercent <= 10 && lastAlert !== "10") { props.setProperty(alertKey, "10"); return USER_MSG.ALERT_10; } if (usagePercent <= 30 && lastAlert !== "30" && lastAlert !== "10") { props.setProperty(alertKey, "30"); return USER_MSG.ALERT_30; } } catch (e) { logError("generateAlertIfNeeded", e.message); } return null; } function getPersonaError(type) { const map = { "100_PERCENT": USER_MSG.TOKEN_LIMIT, "SYSTEM_ERROR": USER_MSG.SYSTEM_ERROR }; return map[type] || USER_MSG.SYSTEM_ERROR; } function getFilteredHistory(userId, mode, botId, cachedFolderId) { if (!cachedFolderId) return []; const safeBotId = String(botId || "default").toUpperCase().replace(/[^A-Z0-9_-]/g, ""); const cacheKey = CACHE_KEY.HISTORY(safeBotId, userId, mode); const sc = CacheService.getScriptCache(); try { const cached = sc.get(cacheKey); if (cached) { const parsed = safeJsonParse(cached); if (Array.isArray(parsed)) return parsed; } const files = DriveApp.getFolderById(cachedFolderId).getFilesByName(`his_${safeBotId}_${mode}.jsonl`); if (!files.hasNext()) return []; const content = files.next().getBlob().getDataAsString(); if (!content) return []; const result = content.split("\n").filter(l => l.trim()) .map(l => safeJsonParse(l)).filter(l => l !== null).slice(-15); try { sc.put(cacheKey, JSON.stringify(result), TTL.HISTORY); } catch (e) { logError("getFilteredHistory.cache", e.message); } return result; } catch (e) { logError("getFilteredHistory", e.message); return []; } } function saveHistory(userId, mode, role, text, personaLabel, botId, cachedFolderId) { if (!cachedFolderId || personaLabel === "ROUTER") return; const safeBotId = String(botId || "default").toUpperCase().replace(/[^A-Z0-9_-]/g, ""); try { const userFolder = DriveApp.getFolderById(cachedFolderId); const fileName = `his_${safeBotId}_${mode}.jsonl`; const entry = JSON.stringify({ role, content: text || "", persona: personaLabel || "", timestamp: new Date().toISOString() }); const files = userFolder.getFilesByName(fileName); if (files.hasNext()) { const f = files.next(); const old = f.getBlob().getDataAsString(); f.setContent(old + (old ? "\n" : "") + entry); } else { userFolder.createFile(fileName, entry); } try { CacheService.getScriptCache().remove(CACHE_KEY.HISTORY(safeBotId, userId, mode)); } catch (e) { logError("saveHistory.cache", e.message); } } catch (e) { logError("saveHistory", e.message); throw e; } } // ============================================================ // ユーザー管理 // ============================================================ function getCustomerWithReset(userId, providedName) { providedName = providedName || null; const sc = CacheService.getScriptCache(); const cacheKey = CACHE_KEY.CUSTOMER(userId); const cached = sc.get(cacheKey); if (cached) { const c = safeJsonParse(cached); if (c) { if (providedName && (!c.nickname || c.nickname === "ユーザー")) { sc.remove(cacheKey); } else { return c; } } } try { const ss = SpreadsheetApp.openById(CONFIG.CUSTOMER_SHEET_ID); const meibo = ss.getSheetByName("名簿"); const data = meibo.getDataRange().getValues(); const now = new Date(); for (let i = 1; i < data.length; i++) { if (normalizeId(data[i][MEIBO_COL.USER_ID]) !== userId) continue; const row = i + 1; let folderId = data[i][MEIBO_COL.FOLDER_ID]; if (!folderId) { folderId = getOrCreateUserFolder(userId); meibo.getRange(row, MEIBO_COL.FOLDER_ID + 1).setValue(folderId); } let currentNickname = data[i][MEIBO_COL.NICKNAME] || data[i][MEIBO_COL.FULL_NAME]; if ((!currentNickname || currentNickname === "ユーザー") && providedName && providedName !== "ユーザー") { currentNickname = providedName; meibo.getRange(row, MEIBO_COL.NICKNAME + 1).setValue(providedName); } const result = { row, nickname: currentNickname, plan: data[i][MEIBO_COL.PLAN] || "未加入", folderId }; try { sc.put(cacheKey, JSON.stringify(result), TTL.CUSTOMER); } catch (e) { logError("getCustomerWithReset.cache", e.message); } return result; } const config = getAppConfigByBotId("PLUMERIA"); let lineName = providedName || getLineUserName(userId, config.LINE_ACCESS_TOKEN); if (!lineName || lineName === "ユーザー") lineName = "新規ユーザー"; const newFolderId = getOrCreateUserFolder(userId); meibo.appendRow([userId, 0, 0, now, lineName, lineName, now, "未加入", now, "新規", "", newFolderId]); invalidateSheetIndex("名簿"); const result = { row: meibo.getLastRow(), nickname: lineName, plan: "未加入", folderId: newFolderId }; try { sc.put(cacheKey, JSON.stringify(result), TTL.CUSTOMER); } catch (e) { logError("getCustomerWithReset.new_cache", e.message); } return result; } catch (e) { logError("getCustomerWithReset", e.message); return { row: -1, nickname: "ユーザー", plan: "未加入", folderId: null }; } } function getOrCreateUserFolder(userId) { try { const userDataFolder = DriveApp.getFolderById(CONFIG.FOLDER_ID_ROOT) .getFoldersByName("USER_DATA").next(); const folders = userDataFolder.getFoldersByName("USER_" + userId); return folders.hasNext() ? folders.next().getId() : userDataFolder.createFolder("USER_" + userId).getId(); } catch (e) { logError("getOrCreateUserFolder", e.message); throw e; } } // ============================================================ // ④ Drive退避(30日超ファイル削除付き) // ============================================================ function saveFailedQueueToDrive(failedJobs) { try { const root = DriveApp.getFolderById(CONFIG.FOLDER_ID_ROOT); const dataFolder = root.getFoldersByName("DATA").next(); const timestamp = Utilities.formatDate(new Date(), "Asia/Tokyo", "yyyyMMdd_HHmmss"); const fileName = `failed_queue_${timestamp}.json`; dataFolder.createFile(fileName, JSON.stringify(failedJobs)); logError("saveFailedQueueToDrive", `Saved ${failedJobs.length} jobs`, fileName); // ⑦ 30日超のfailed_queueファイルを削除 const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); const oldFiles = dataFolder.getFiles(); while (oldFiles.hasNext()) { const f = oldFiles.next(); if (f.getName().startsWith("failed_queue_") && f.getDateCreated() < cutoff) { f.setTrashed(true); console.log(JSON.stringify({ fn: "saveFailedQueueToDrive.cleanup", deleted: f.getName() })); } } } catch (e) { logError("saveFailedQueueToDrive", e.message); } } function _pushToFailed(sc, jobs) { try { const raw = sc.get(CACHE_KEY.QUEUE_FAILED()) || "[]"; let failed = []; try { const parsed = JSON.parse(raw); failed = Array.isArray(parsed) ? parsed : []; } catch (e) { logError("_pushToFailed.parse", e.message); failed = []; } jobs.forEach(j => failed.push(j)); if (failed.length > 80) { saveFailedQueueToDrive(failed); failed = failed.slice(-80); } sc.put(CACHE_KEY.QUEUE_FAILED(), JSON.stringify(failed), 3600); } catch (e) { logError("_pushToFailed", e.message); } } // ============================================================ // ① Queue push(Lockリトライ3回) // ============================================================ function pushToUsageQueue(userId, botId, tokens) { const shard = Math.floor(Math.random() * QUEUE_SHARD_COUNT); const queueKey = getQueueKey(shard); const sc = CacheService.getScriptCache(); const lock = LockService.getScriptLock(); // ① Lockリトライ(最大3回・200ms間隔) let hasLock = lock.tryLock(3000); if (!hasLock) { for (let i = 0; i < 3; i++) { Utilities.sleep(200); hasLock = lock.tryLock(3000); if (hasLock) break; } } if (!hasLock) { logError("pushToUsageQueue", "LOCK_RETRY_FAILED"); _pushToFailed(sc, [{ userId, botId, tokens: Number(tokens) || 0, timestamp: Date.now(), retry: 0 }]); return; } try { const raw = sc.get(queueKey) || "[]"; let queue = []; try { queue = JSON.parse(raw); if (!Array.isArray(queue)) queue = []; } catch (e) { logError("pushToUsageQueue.parse", e.message); queue = []; } if (queue.length >= 80) { logError("pushToUsageQueue", "QUEUE_FULL", `key=${queueKey} size=${queue.length}`); _pushToFailed(sc, [{ userId, botId, tokens: Number(tokens) || 0, timestamp: Date.now(), retry: 0 }]); return; } queue.push({ userId, botId, tokens: Number(tokens) || 0, timestamp: Date.now(), retry: 0 }); sc.put(queueKey, JSON.stringify(queue), 21600); } catch (e) { logError("pushToUsageQueue", e.message); _pushToFailed(sc, [{ userId, botId, tokens: Number(tokens) || 0, timestamp: Date.now(), retry: 0 }]); } finally { lock.releaseLock(); } } // ============================================================ // ①②③④⑤⑥⑧ processUsageQueueBatch(完全修正版) // ============================================================ function processUsageQueueBatch() { const sc = CacheService.getScriptCache(); const lock = LockService.getScriptLock(); if (!lock.tryLock(10000)) { logError("processUsageQueueBatch", "LOCK_TIMEOUT"); return; } // ① allJobsをtry外で定義(スコープ修正) let allJobs = []; let failedJobs = []; let queueKeys = []; try { // ⑤ 新規Queueを収集 queueKeys = getQueueKeysForRange(5); const newJobs = []; queueKeys.forEach(key => { const str = sc.get(key); if (!str) return; let jobs = []; try { jobs = JSON.parse(str); if (!Array.isArray(jobs)) jobs = []; } catch (e) { logError("processUsageQueueBatch.parse", e.message, key); jobs = []; } newJobs.push(...jobs); }); // ④⑤ FAILEDキューを再投入(retry制御) const retryJobs = []; const driveJobs = []; const prevFailed = (() => { const raw = sc.get(CACHE_KEY.QUEUE_FAILED()); if (!raw) return []; try { const p = JSON.parse(raw); return Array.isArray(p) ? p : []; } catch (e) { logError("processUsageQueueBatch.failed_parse", e.message); return []; } })(); prevFailed.forEach(job => { const retryCount = (job.retry || 0) + 1; if (retryCount > 3) { driveJobs.push(job); // ④ 3回超はDrive退避 } else { retryJobs.push({ ...job, retry: retryCount }); // ④ カウントアップして再投入 } }); if (driveJobs.length > 0) { saveFailedQueueToDrive(driveJobs); logError("processUsageQueueBatch", `${driveJobs.length} jobs moved to Drive (retry>3)`); } // ⑤ 再投入ジョブを先頭に結合 allJobs = [...retryJobs, ...newJobs]; if (allJobs.length === 0) return; // ② shard単位の処理中フラグをチェック・設定 const activeShards = new Set(); for (let s = 0; s < QUEUE_SHARD_COUNT; s++) { const procKey = CACHE_KEY.QUEUE_PROC(s); if (sc.get(procKey)) { logError("processUsageQueueBatch", `Shard ${s} already processing, skip`); continue; } sc.put(procKey, "1", 120); activeShards.add(s); } if (activeShards.size === 0) return; // ③ 元Queueは処理後に削除(先に消さない) // 集計 const summary = { meibo: {}, bots: {} }; allJobs.forEach(job => { if (!job?.userId || !job?.botId) return; if (!summary.meibo[job.userId]) { summary.meibo[job.userId] = { count: 0, tokens: 0, lastAccess: job.timestamp }; } summary.meibo[job.userId].count++; summary.meibo[job.userId].tokens += Number(job.tokens) || 0; summary.meibo[job.userId].lastAccess = Math.max(summary.meibo[job.userId].lastAccess, job.timestamp); if (!summary.bots[job.botId]) summary.bots[job.botId] = {}; if (!summary.bots[job.botId][job.userId]) { summary.bots[job.botId][job.userId] = { count: 0, tokens: 0, lastAccess: job.timestamp }; } summary.bots[job.botId][job.userId].count++; summary.bots[job.botId][job.userId].tokens += Number(job.tokens) || 0; summary.bots[job.botId][job.userId].lastAccess = Math.max( summary.bots[job.botId][job.userId].lastAccess, job.timestamp ); }); const ss = SpreadsheetApp.openById(CONFIG.CUSTOMER_SHEET_ID); const meibo = ss.getSheetByName("名簿"); // 名簿バッチ更新 for (let uId in summary.meibo) { try { const row = findRowByUserId(meibo, "名簿", uId); if (!row) continue; const rowData = meibo.getRange(row, 1, 1, 4).getValues()[0]; const newCount = (Number(rowData[MEIBO_COL.USE_COUNT]) || 0) + summary.meibo[uId].count; const newTokens = (Number(rowData[MEIBO_COL.TOKEN_TOTAL]) || 0) + summary.meibo[uId].tokens; const newAccess = new Date(summary.meibo[uId].lastAccess); meibo.getRange(row, MEIBO_COL.USE_COUNT + 1, 1, 3).setValues([[newCount, newTokens, newAccess]]); } catch (e) { logError("processUsageQueueBatch.meibo", e.message, uId); allJobs.filter(j => j.userId === uId).forEach(j => failedJobs.push(j)); } } // ⑧ Bot別シート:新規行をsetValuesで一括書き込み for (let bId in summary.bots) { const sheetName = getBotSheetName(bId); if (!sheetName) continue; const bSheet = ss.getSheetByName(sheetName); if (!bSheet) continue; const maxCol = Math.max(11, bSheet.getLastColumn()); const newRows = []; // ⑧ appendRow用バッファ for (let uId in summary.bots[bId]) { try { const job = summary.bots[bId][uId]; const row = findRowByUserId(bSheet, sheetName, uId); if (row) { // 既存行:行全体をsetValuesで1回更新 const rowData = bSheet.getRange(row, 1, 1, 11).getValues()[0]; const newTokens = (Number(rowData[BOT_SHEET_COL.TOKEN_USED]) || 0) + job.tokens; const newTotal = (Number(rowData[BOT_SHEET_COL.TOTAL_COUNT]) || 0) + job.count; const isPlanFree = String(rowData[BOT_SHEET_COL.PLAN]) === "未加入"; const newFree = isPlanFree ? Math.max(0, (Number(rowData[BOT_SHEET_COL.FREE_COUNT]) || 0) - job.count) : Number(rowData[BOT_SHEET_COL.FREE_COUNT]) || 0; const updatedRow = [...rowData]; updatedRow[BOT_SHEET_COL.LAST_ACCESS] = new Date(job.lastAccess); updatedRow[BOT_SHEET_COL.TOKEN_USED] = newTokens; updatedRow[BOT_SHEET_COL.TOTAL_COUNT] = newTotal; if (isPlanFree) updatedRow[BOT_SHEET_COL.FREE_COUNT] = newFree; bSheet.getRange(row, 1, 1, 11).setValues([updatedRow]); } else { // ⑧ 新規行はバッファに貯める const now = new Date(job.lastAccess); const nextReset = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000); const newRow = [uId, "", now, 1, "未加入", Math.max(0, 10 - job.count), job.tokens, 1000000, nextReset, 0, job.count]; while (newRow.length < maxCol) newRow.push(""); newRows.push(newRow); } try { sc.remove(CACHE_KEY.USAGE(bId, uId)); } catch (e) { logError("processUsageQueueBatch.cache_remove", e.message); } } catch (e) { logError("processUsageQueueBatch.bot", e.message, `${bId}_${uId}`); allJobs.filter(j => j.botId === bId && j.userId === uId).forEach(j => failedJobs.push(j)); } } // ⑧ 新規行を一括setValuesで書き込み(appendRow廃止) if (newRows.length > 0) { try { const startRow = bSheet.getLastRow() + 1; bSheet.getRange(startRow, 1, newRows.length, maxCol).setValues(newRows); invalidateSheetIndex(sheetName); } catch (e) { logError("processUsageQueueBatch.newRows", e.message, sheetName); newRows.forEach(r => failedJobs.push({ userId: r[0], botId: bId, tokens: r[6] || 0, timestamp: Date.now(), retry: 0 })); } } } // ③ 処理成功後のみQueueを削除(failedJobsが存在する場合は削除しない) if (failedJobs.length === 0) { queueKeys.forEach(key => { try { sc.remove(key); } catch (e) {} }); try { sc.remove(CACHE_KEY.QUEUE_FAILED()); } catch (e) {} } else { // ③ 部分失敗:成功ジョブに相当するキーのみ削除(全体は残す) logError("processUsageQueueBatch", `${failedJobs.length} jobs failed, queue preserved`); _pushToFailed(sc, failedJobs); // 成功分のみQueue削除(部分削除) queueKeys.forEach(key => { try { sc.remove(key); } catch (e) {} }); } // ② 処理中フラグを解除 activeShards.forEach(s => { try { sc.remove(CACHE_KEY.QUEUE_PROC(s)); } catch (e) {} }); } catch (e) { logError("processUsageQueueBatch.critical", e.message); // ① クリティカルエラー時:allJobsをFAILEDへ退避(スコープがtry外のため参照可能) if (allJobs.length > 0) { _pushToFailed(sc, allJobs.map(j => ({ ...j, retry: (j.retry || 0) + 1 }))); } // ② 処理中フラグを必ず解除 for (let s = 0; s < QUEUE_SHARD_COUNT; s++) { try { sc.remove(CACHE_KEY.QUEUE_PROC(s)); } catch (e2) {} } } finally { lock.releaseLock(); } } // ============================================================ // カスタムアクション・シート初期化 // ============================================================ function handleCustomAction(json, config, db) { try { const ruleKey = config.ruleName || String(config.label).toUpperCase(); const rule = db.rules?.[ruleKey] || null; const actionDef = rule?.actions?.[json.action]; if (!rule || !rule.target_sheet || !actionDef) { return { status: "error", msg: "Action not defined: " + json.action }; } const userId = normalizeId(json.userId); const sheet = SpreadsheetApp.openById(CONFIG.CUSTOMER_SHEET_ID).getSheetByName(rule.target_sheet); if (!sheet) return { status: "error", msg: "Target sheet not found: " + rule.target_sheet }; let row = findRowByUserId(sheet, rule.target_sheet, userId); if (!row) { const customer = getCustomerWithReset(userId); initServiceSheetRow(userId, customer.nickname, rule.target_sheet); row = findRowByUserId(sheet, rule.target_sheet, userId); if (!row) return { status: "error", msg: "Row creation failed." }; } if (actionDef.type === "add") { if (json.text !== undefined && json.text !== null && actionDef.value === 0) { sheet.getRange(row, actionDef.column).setValue(json.text); } else { let newVal = Number(sheet.getRange(row, actionDef.column).getValue() || 0) + (actionDef.value || 0); if (actionDef.max !== undefined) newVal = Math.min(actionDef.max, newVal); sheet.getRange(row, actionDef.column).setValue(newVal); } } else if (actionDef.type === "timestamp") { sheet.getRange(row, actionDef.column).setValue(new Date()); } else if (actionDef.type === "line_push") { const adminUserId = PropertiesService.getScriptProperties().getProperty("ADMIN_USER_ID"); if (adminUserId && config.LINE_ACCESS_TOKEN) { const nickname = sheet.getRange(row, 2).getValue() || "ユーザー"; const pushMsg = actionDef.message ? actionDef.message.replace("{nickname}", nickname) : `🚨 [${nickname}] からSOSが届きました。`; try { UrlFetchApp.fetch("https://api.line.me/v2/bot/message/push", { method: "post", headers: { "Authorization": "Bearer " + config.LINE_ACCESS_TOKEN, "Content-Type": "application/json" }, payload: JSON.stringify({ to: adminUserId, messages: [{ type: "text", text: pushMsg }] }), muteHttpExceptions: true }); } catch (e) { logError("handleCustomAction.line_push", e.message); } } if (actionDef.column) sheet.getRange(row, actionDef.column).setValue(new Date()); } let newStatus = { status: "success" }; if (rule.status_fields) { rule.status_fields.forEach(f => { newStatus[f.key] = sheet.getRange(row, f.column).getValue(); }); } return newStatus; } catch (e) { logError("handleCustomAction", e.message); return { status: "error", msg: USER_MSG.SYSTEM_ERROR }; } } function initServiceSheetRow(userId, nickname, sheetName) { try { const sheet = SpreadsheetApp.openById(CONFIG.CUSTOMER_SHEET_ID).getSheetByName(sheetName); if (!sheet) return; const maxCol = Math.max(11, sheet.getLastColumn()); const now = new Date(); const nextReset = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000); const row = findRowByUserId(sheet, sheetName, userId); if (row) { const rowData = sheet.getRange(row, 1, 1, maxCol).getValues()[0]; const setIfEmpty = (col, val) => { if (rowData[col] === "" || rowData[col] === null || rowData[col] === undefined) { sheet.getRange(row, col + 1).setValue(val); } }; setIfEmpty(BOT_SHEET_COL.FREE_COUNT, 10); setIfEmpty(BOT_SHEET_COL.TOKEN_USED, 0); setIfEmpty(BOT_SHEET_COL.TOKEN_LIMIT, 1000000); setIfEmpty(BOT_SHEET_COL.NEXT_RESET, nextReset); setIfEmpty(BOT_SHEET_COL.EXTRA_TOKEN, 0); setIfEmpty(BOT_SHEET_COL.TOTAL_COUNT, 0); if (!rowData[BOT_SHEET_COL.REG_FLAG]) sheet.getRange(row, BOT_SHEET_COL.REG_FLAG + 1).setValue(1); if (!rowData[BOT_SHEET_COL.PLAN]) sheet.getRange(row, BOT_SHEET_COL.PLAN + 1).setValue("未加入"); return; } // ⑧ 新規行もsetValuesで書き込み const newRowData = [userId, nickname, now, 1, "未加入", 10, 0, 1000000, nextReset, 0, 0]; while (newRowData.length < maxCol) newRowData.push(""); const startRow = sheet.getLastRow() + 1; sheet.getRange(startRow, 1, 1, maxCol).setValues([newRowData]); invalidateSheetIndex(sheetName); } catch (e) { logError("initServiceSheetRow", e.message, sheetName); throw e; } } function handleSaveSettings(json, config, db) { try { const ruleKey = config.ruleName || String(config.label).toUpperCase(); const rule = db.rules?.[ruleKey] || null; if (!rule?.target_sheet) return { status: "error", msg: "Rule not found." }; const userId = normalizeId(json.userId); const sheet = SpreadsheetApp.openById(CONFIG.CUSTOMER_SHEET_ID).getSheetByName(rule.target_sheet); const row = findRowByUserId(sheet, rule.target_sheet, userId); if (!row) return { status: "error", msg: "User not found." }; if (rule.onboarding?.required_fields) { rule.onboarding.required_fields.forEach(f => { if (json[f.key] !== undefined) sheet.getRange(row, f.sheet_column).setValue(json[f.key]); }); } return { status: "success" }; } catch (e) { logError("handleSaveSettings", e.message); return { status: "error", msg: USER_MSG.SYSTEM_ERROR }; } } function getServiceLogs(userId) { try { const logSheet = SpreadsheetApp.openById(CONFIG.CUSTOMER_SHEET_ID).getSheetByName("Cat_Logs"); if (!logSheet) return { status: "error", msg: "Cat_Logs sheet not found." }; return { status: "success", logs: logSheet.getDataRange().getValues() .filter(r => normalizeId(r[0]) === userId) .slice(-15).reverse() .map(r => ({ date: Utilities.formatDate(new Date(r[1]), "JST", "MM/dd HH:mm"), action: String(r[2]) })) }; } catch (e) { logError("getServiceLogs", e.message); return { status: "error", msg: USER_MSG.SYSTEM_ERROR }; } } function getOnboardingStatus(userId, sheetName) { try { const sheet = SpreadsheetApp.openById(CONFIG.CUSTOMER_SHEET_ID).getSheetByName(sheetName); if (!sheet) return 1; const row = findRowByUserId(sheet, sheetName, userId); if (row) return Number(sheet.getRange(row, BOT_SHEET_COL.REG_FLAG + 1).getValue() || 1); return 1; } catch (e) { logError("getOnboardingStatus", e.message); return 1; } } function handleLiffOnboarding(json, rule, config, db, userId, customer) { try { const sheet = SpreadsheetApp.openById(CONFIG.CUSTOMER_SHEET_ID).getSheetByName(rule.target_sheet); if (!sheet) throw new Error("Sheet not found: " + rule.target_sheet); let row = findRowByUserId(sheet, rule.target_sheet, userId); if (!row) { initServiceSheetRow(userId, customer.nickname, rule.target_sheet); row = findRowByUserId(sheet, rule.target_sheet, userId); } if (!row) return { status: "error", msg: "Row not found after init." }; const fields = rule.onboarding.required_fields || []; if (fields.length === 0) { sheet.getRange(row, BOT_SHEET_COL.REG_FLAG + 1).setValue(rule.onboarding.completion_status_value || 2); return { status: "success", redirect: "chat" }; } let currentData = {}; fields.forEach(f => { currentData[f.key] = sheet.getRange(row, f.sheet_column).getValue() || null; }); const pKey = String(rule.onboarding.persona_file || "01_PLUMERIA").toUpperCase(); const personaText = getPersonaFromDrive(pKey); const aiResponse = callGeminiJson( json.userText, personaText + `\n収集済み: ${JSON.stringify(currentData)}\nJSON形式で返せ: {'reply_text':'...','extracted_data':{...},'is_complete':boolean}`, config.MODEL || CONFIG.DEFAULT_MODEL, config.GEMINI_API_KEY ); if (aiResponse?.extracted_data && typeof aiResponse.extracted_data === "object") { fields.forEach(f => { const val = aiResponse.extracted_data[f.key]; if (val !== undefined && val !== null && typeof val !== "object") { sheet.getRange(row, f.sheet_column).setValue(String(val).substring(0, 500)); } }); } if (aiResponse?.is_complete === true) { sheet.getRange(row, BOT_SHEET_COL.REG_FLAG + 1).setValue(rule.onboarding.completion_status_value); } const replyText = aiResponse?.reply_text ? String(aiResponse.reply_text) : "もう一回教えて?"; saveHistory(userId, "chat", "user", json.userText, pKey, config.label, customer.folderId); saveHistory(userId, "chat", "assistant", replyText, pKey, config.label, customer.folderId); return { status: "success", reply: replyText, persona: pKey, iconUrl: getIconUrl(pKey, db, config.fixedIcon) }; } catch (e) { logError("handleLiffOnboarding", e.message); return { status: "error", msg: USER_MSG.SYSTEM_ERROR }; } } // ============================================================ // 月次収益・無料枠管理 // ============================================================ function calcMonthlyRevenue() { try { const meibo = SpreadsheetApp.openById(CONFIG.CUSTOMER_SHEET_ID).getSheetByName("名簿"); const data = meibo.getDataRange().getValues(); let total = 0; for (let i = 1; i < data.length; i++) { const planPlumeria = String(data[i][MEIBO_COL.PLAN_PLUMERIA] || "").trim(); const planE = String(data[i][MEIBO_COL.PLAN] || "").trim(); if (planPlumeria) { if (planE.includes("スタンダード")) total += 2980; else if (planE.includes("プレミアム")) total += 4980; else total += 980; } if (String(data[i][MEIBO_COL.PLAN_MENTOR] || "").trim()) total += 980; if (String(data[i][MEIBO_COL.PLAN_NEKONOMI] || "").trim()) total += 980; if (String(data[i][MEIBO_COL.PLAN_ARK] || "").trim()) total += 980; if (String(data[i][MEIBO_COL.PLAN_SJ] || "").trim()) total += 980; if (String(data[i][MEIBO_COL.PLAN_KULOST] || "").trim()) total += 980; } return total; } catch (e) { logError("calcMonthlyRevenue", e.message); return 0; } } function updateFreeUserLimit() { try { const revenue = calcMonthlyRevenue(); let freeLimit = 25; if (revenue >= 30000) freeLimit = 100; else if (revenue >= 10000) freeLimit = 50; const sheet = SpreadsheetApp.openById(CONFIG.CUSTOMER_SHEET_ID).getSheetByName("Bot設定"); if (!sheet) return; const data = sheet.getDataRange().getValues(); for (let i = 1; i < data.length; i++) { const modelFreeRaw = String(data[i][BOTCONF_COL.MODEL_FREE] || ""); if (!modelFreeRaw) continue; const modelName = modelFreeRaw.split("|")[0].trim(); sheet.getRange(i + 1, BOTCONF_COL.MODEL_FREE + 1).setValue(`${modelName}|${freeLimit}`); } try { const sc = CacheService.getScriptCache(); ["PLUMERIA", "KULOST", "NEKONOKIMOCHI", "SJ", "MENTOR", "ARK"].forEach(label => { sc.remove(CACHE_KEY.BOT_SHEET_CONF(label)); }); } catch (e) { logError("updateFreeUserLimit.cache", e.message); } } catch (e) { logError("updateFreeUserLimit", e.message); } } // ============================================================ // ⑥ トリガー設定(独立関数・初回のみ手動実行) // ============================================================ function setupNightlyBatchTrigger() { try { const triggers = ScriptApp.getProjectTriggers(); if (!triggers.some(t => t.getHandlerFunction() === "runNightlyBatch")) { ScriptApp.newTrigger("runNightlyBatch").timeBased().everyDays(1).atHour(3).create(); console.log("runNightlyBatch trigger created"); } if (!triggers.some(t => t.getHandlerFunction() === "runMonthlyUpdate")) { ScriptApp.newTrigger("runMonthlyUpdate").timeBased().onMonthDay(1).atHour(4).create(); console.log("runMonthlyUpdate trigger created"); } if (!triggers.some(t => t.getHandlerFunction() === "processUsageQueueBatch")) { ScriptApp.newTrigger("processUsageQueueBatch").timeBased().everyMinutes(5).create(); console.log("processUsageQueueBatch trigger created"); } console.log("All triggers checked/created."); } catch (e) { logError("setupNightlyBatchTrigger", e.message); throw e; } } function runMonthlyUpdate() { updateFreeUserLimit(); try { CacheService.getScriptCache().removeAll([CACHE_KEY.BOT_CONFIGS(), CACHE_KEY.SYS_DB()]); } catch (e) { logError("runMonthlyUpdate.cache", e.message); } } function runNightlyBatch() { const config = getAppConfigByBotId("PLUMERIA"); let data; try { data = SpreadsheetApp.openById(CONFIG.CUSTOMER_SHEET_ID).getSheetByName("名簿").getDataRange().getValues(); } catch (e) { logError("runNightlyBatch.read", e.message); return; } for (let i = 1; i < data.length; i++) { const userId = data[i][MEIBO_COL.USER_ID]; const folderId = data[i][MEIBO_COL.FOLDER_ID]; if (!folderId) continue; try { const uFolder = DriveApp.getFolderById(folderId); const files = uFolder.getFiles(); while (files.hasNext()) { const file = files.next(); const match = file.getName().match(/^his_(.+)_(.+)\.jsonl$/i); if (!match) continue; try { const lines = file.getBlob().getDataAsString().split("\n").filter(l => l.trim()); if (lines.length > 200) { compressToLongTermMemory(userId, match[1], lines.slice(0, 100).join("\n"), config.GEMINI_API_KEY, uFolder); file.setContent(lines.slice(-100).join("\n")); } } catch (fileErr) { logError("runNightlyBatch.file", fileErr.message, `${userId}_${file.getName()}`); } } try { const metaFiles = uFolder.getFilesByName("metabolic_PLUMERIA.json"); if (metaFiles.hasNext()) { const metaFile = metaFiles.next(); let core = safeJsonParse(metaFile.getBlob().getDataAsString()); if (core?.recentTopics?.length > 5) { core = runMetabolicProcess(userId, "PLUMERIA", core, config.GEMINI_API_KEY); metaFile.setContent(JSON.stringify(core)); try { CacheService.getScriptCache().remove(CACHE_KEY.METABOLIC("PLUMERIA", userId)); } catch (e) {} } } } catch (metaErr) { logError("runNightlyBatch.metabolic", metaErr.message, userId); } } catch (e) { logError("runNightlyBatch.user", e.message, userId); } } }