/** * Omega Core v267 * Developer: Team Chloro / Update: 2026-04-27 * v266 + 列インデックス修正 + Bot設定シート動的取得 + MODEL_FREE切り替え + 未加入制限 + LINE返信統一 */ const CONFIG = { FOLDER_ID_ROOT: PropertiesService.getScriptProperties().getProperty("FOLDER_ID_CHLORO_PLATFORM"), CUSTOMER_SHEET_ID: PropertiesService.getScriptProperties().getProperty("CUSTOMER_SHEET_ID"), PROP_MODEL_KEY: "GEMINI_MODEL_VERSION", DEFAULT_MODEL: "gemini-2.5-flash", WORKER_URL: "https://kurolo-gateway.kulostfpv.workers.dev" }; const MEIBO_COL = { USER_ID: 0, FULL_NAME: 1, NICKNAME: 2, REG_DATE: 3, PLAN: 4, LAST_CHAT: 5, USE_COUNT: 6, TOKEN_TOTAL: 7, MEMO: 8, FIRST_PAYMENT: 9, FOLDER_ID: 10, PLAN_MENTOR: 11, PLAN_PLUMERIA: 12, PLAN_NEKONOMI: 13, PLAN_ARK: 14, PLAN_SJ: 15, PLAN_KULOST: 16 }; 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 FREE_MONTHLY_LIMIT = 10; 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) { try { return JSON.parse(str); } catch(e) { try { const m = str.match(/\{[\s\S]*\}/); return m ? JSON.parse(m[0]) : null; } catch(e2) { console.error("safeJsonParse failed:", String(str).substring(0,200)); return null; } } } function fetchGeminiSafe(url, payload) { const res = UrlFetchApp.fetch(url, { method: "post", contentType: "application/json", muteHttpExceptions: true, payload: JSON.stringify(payload) }); const code = res.getResponseCode(); if (code !== 200) { console.error(`Gemini HTTP ${code}:`, res.getContentText().substring(0,500)); return null; } return safeJsonParse(res.getContentText()); } function postResultToWorker(jobId, result) { try { if (!jobId) return; UrlFetchApp.fetch(`${CONFIG.WORKER_URL}/callback`, { method: "post", contentType: "application/json", muteHttpExceptions: true, payload: JSON.stringify({ jobId, result }) }); } catch(e) { console.error("postResultToWorker error:", e); } } 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) { ui.alert("🚨 同期エラー", e.message, ui.ButtonSet.OK); } } function getLineGuideMessage() { return "メッセージありがとうございます✨\n\nAIとの対話は、画面下のメニューからお使いください!\n\nメニューが表示されていない場合は、画面右下の「≡」をタップしてください。\n表示されたメニューをタップするとリッチメニューが開きます。"; } function getLiffUrl(labelUpper) { const props = PropertiesService.getScriptProperties(); const liffId = props.getProperty("LIFF_ID_" + labelUpper); if (!liffId) return ""; return "https://liff.line.me/" + liffId; } function getFreeUsageKey(userId) { const now = new Date(); const ym = Utilities.formatDate(now, "Asia/Tokyo", "yyyyMM"); return `FREE_COUNT_${userId}_${ym}`; } function checkFreeLimit(userId) { const props = PropertiesService.getScriptProperties(); const key = getFreeUsageKey(userId); const count = parseInt(props.getProperty(key) || "0"); return { count, isLimit: count >= FREE_MONTHLY_LIMIT }; } function incrementFreeCount(userId) { const props = PropertiesService.getScriptProperties(); const key = getFreeUsageKey(userId); const count = parseInt(props.getProperty(key) || "0"); props.setProperty(key, (count + 1).toString()); } function getBotSheetConfig(label) { 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)) { const modelFreeRaw = String(data[i][BOTCONF_COL.MODEL_FREE] || "gemini-2.5-flash"); const modelFreeParts = modelFreeRaw.split("|"); const modelFree = modelFreeParts[0].trim(); const freeUserLimit = modelFreeParts[1] ? parseInt(modelFreeParts[1].trim()) : 25; return { tokenPerReply: parseInt(data[i][BOTCONF_COL.TOKEN_PER_REPLY] || "5000"), monthlyTokenLimit: parseInt(data[i][BOTCONF_COL.MONTHLY_TOKEN_LIMIT] || "1000000"), modelFree: modelFree, 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: freeUserLimit }; } } } catch(e) { console.error("getBotSheetConfig error:", e); } return null; } function selectModel(plan, botSheetConf, defaultModel) { const planStr = String(plan || "").toLowerCase(); const isPaid = planStr !== "未加入" && planStr !== ""; if (!botSheetConf) return defaultModel; if (isPaid) return botSheetConf.modelChat || defaultModel; return botSheetConf.modelFree || defaultModel; } function doGet(e) { try { if (!e || !e.parameter) return responseJson({ status: "ok", msg: "v267 Online" }); const params = {}; for (let key in e.parameter) { params[key.toLowerCase()] = String(e.parameter[key]); } const WEBHOOK_KEY = "kurolo2026"; if (params.key === WEBHOOK_KEY) { 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" }); } } const mode = String(params.mode || "chat"); const userId = normalizeId(params.uid || params.userid); let botId = params.bot || "PLUMERIA"; if (!userId) return responseJson({ status: "error", msg: "UID Missing." }); const config = getAppConfigByBotId(botId); const normalizedBotId = (config.label || botId).toUpperCase(); let targetPersona = (mode === "editor") ? "02_KARIS" : config.targetPersona; let readMode = mode; if (mode === "history") { readMode = "chat"; targetPersona = config.targetPersona; } const db = getCachedSystemDataLight(); const customer = getCustomerWithReset(userId); const rawHistory = getFilteredHistory(userId, readMode, 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(err) { return responseJson({ status: "error", msg: "doGet Error: " + err.message }); } } function doPost(e) { const lock = LockService.getScriptLock(); const lockAcquired = lock.tryLock(3000); if (!lockAcquired) return responseJson({ status: 'locked', msg: '処理中です。' }); try { const json = JSON.parse(e.postData.contents); if (json.events && json.events.length > 0) { const event = json.events[0]; const botId = json.destination || "PLUMERIA"; const config = getAppConfigByBotId(botId); if (event.replyToken === "00000000000000000000000000000000") return responseJson({ status: 'ok' }); if (event.type === "message") { sendLineTextReply(event.replyToken, getLineGuideMessage(), config.LINE_ACCESS_TOKEN); } return responseJson({ status: 'ok' }); } if (json.source === "liff") { const jobId = json.jobId || null; const userId = normalizeId(json.userId); let botId = json.bot || ((json.mode === "editor") ? "KARIS" : "PLUMERIA"); if (!userId) { const result = { status: "error", msg: "UID Missing." }; postResultToWorker(jobId, result); return responseJson(result); } const config = getAppConfigByBotId(botId); const normalizedBotId = (config.label || botId).toUpperCase(); const botSheetConf = getBotSheetConfig(config.label); const db = getCachedSystemDataLight(); if (json.action === "startup") { const customer = getCustomerWithReset(userId, json.userName); const ruleKey = config.ruleName || String(config.label).toUpperCase(); const rule = (ruleKey && db.rules) ? db.rules[ruleKey] : null; if (rule && rule.target_sheet) { initServiceSheetRow(userId, customer.nickname, rule.target_sheet); } if (json.isFirstToday) { try { handleCustomAction({ action: "daily_access", userId }, config, db); } catch(e) {} } return responseJson({ status: "success", nickname: customer.nickname }); } if (json.action === "init_row") { const customer = getCustomerWithReset(userId, json.userName); const ruleKey = config.ruleName || String(config.label).toUpperCase(); const rule = (ruleKey && db.rules) ? db.rules[ruleKey] : null; if (rule && 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)); const customer = getCustomerWithReset(userId, json.userName); if (config.label && config.label.toUpperCase() === "ARK") { const result = handleArkRequest(json, config, userId, customer); postResultToWorker(jobId, result); return responseJson(result); } const tokenStatus = checkTokenLimit(userId, botSheetConf); if (tokenStatus.isLimit) { const result = { status: "limit_reached", reply: getPersonaError(tokenStatus.reason) }; postResultToWorker(jobId, result); return responseJson(result); } const plan = customer.plan || "未加入"; const isPaid = plan !== "未加入" && plan !== ""; if (!isPaid) { const freeStatus = checkFreeLimit(userId); if (freeStatus.isLimit) { const result = { status: "free_limit_reached", reply: `今月の無料利用回数(${FREE_MONTHLY_LIMIT}回)に達しました。\n\n続けてご利用いただくには、プランへのご登録をお願いします。`, liffUrl: getLiffUrl(normalizedBotId) }; postResultToWorker(jobId, result); return responseJson(result); } } let targetPersona = config.targetPersona; const ruleKey = config.ruleName || String(config.label).toUpperCase(); const rule = (ruleKey && db.rules) ? db.rules[ruleKey] : null; if (rule && rule.onboarding && 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); return responseJson(result); } } if (json.mode === "editor") { targetPersona = "02_KARIS"; } else if (targetPersona === "ROUTER" || config.label === "PLUMERIA") { const routing = analyzeIntentAndRoute(json.userText, json.mode, customer.plan, db); 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 ai = callGeminiAPI([{ text: json.userText }], prompt, customer.plan, history, config, selectedModel, botSheetConf); 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); updateCustomerStats(userId, ai.tokens); updateTokenUsage(userId, ai.tokens); if (!isPaid) { incrementFreeCount(userId); } const alertMessage = generateAlertIfNeeded(userId, botSheetConf); if (alertMessage) replyText = replyText + "\n\n" + alertMessage; const result = { status: "success", reply: replyText, suggestions: ai.suggestions, persona: targetPersona, iconUrl: getIconUrl(targetPersona, db, config.fixedIcon) }; postResultToWorker(jobId, result); return responseJson(result); } return responseJson({ status: 'ok' }); } catch(err) { return responseJson({ status: "error", msg: "doPost Error: " + err.message }); } finally { lock.releaseLock(); } } function getImagesAsBlobs(json, config) { const blobs = []; if (json.liff_images && 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) { console.error("Base64 decode error:", e); } }); } 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()); } catch(e) { console.error("LINE image fetch error:", e); } } return blobs; } function callGeminiWithImages(blobs, textPrompt, systemPrompt, config, 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) { console.error("Blob to base64 error:", e); } }); 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 resJson = fetchGeminiSafe(`https://generativelanguage.googleapis.com/v1beta/models/${config.MODEL || CONFIG.DEFAULT_MODEL}:generateContent?key=${config.GEMINI_API_KEY}`, payload); if (!resJson || !resJson.candidates || !resJson.candidates[0]) return { reply: "解析に失敗しました。", tokens: 0 }; return { reply: resJson.candidates[0].content.parts[0].text, tokens: resJson.usageMetadata ? resJson.usageMetadata.totalTokenCount : 0 }; } function handleArkRequest(json, config, userId, customer) { try { const arkSheet = SpreadsheetApp.openById(CONFIG.CUSTOMER_SHEET_ID).getSheetByName("ARK"); if (!arkSheet) return { status: "error", msg: "ARK sheet not found." }; const personaText = getPersonaFromDrive("12_ARK"); const blobs = getImagesAsBlobs(json, config); const userText = json.userText || json.text || ""; let arkResult; if (blobs.length > 0) { const res = callGeminiWithImages(blobs, userText, personaText, config, true); arkResult = safeJsonParse(res.reply.replace(/```json/g,"").replace(/```/g,"").trim()) || { ARK_Rank:"注意", Logic_Reason:"解析エラー", Action_Advice:"再送信してください", Reply_Message:"画像の解析に問題が発生しました。" }; } else { arkResult = callGeminiJsonMode(userText, personaText, config); } const sourceData = blobs.length > 0 ? `[画像${blobs.length}枚] ${userText}` : userText; arkSheet.appendRow([userId, new Date(), blobs.length > 0 ? "image" : "text", sourceData.substring(0,500), arkResult.ARK_Rank||"", arkResult.Logic_Reason||"", arkResult.Action_Advice||"", arkResult.ARK_Rank==="危険"?"要確認":"処理済"]); 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); return { status:"success", reply:replyMsg, ark_result:arkResult }; } catch(e) { return { status:"error", msg:"ARK Error: " + e.message }; } } function getCachedSystemDataLight() { try { const sc = CacheService.getScriptCache(); const cached = sc.get("SYS_DB_LIGHT"); if (cached) return JSON.parse(cached); 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("SYS_DB_LIGHT", JSON.stringify(data), 21600); } catch(e) {} return data; } catch(e) { return { icons:{}, rules:{}, botConfigs:{} }; } } function syncSystemData() { setupNightlyBatchTrigger(); const db = { icons:{}, rules:{}, botConfigs:{} }; let personaCount = 0, knowledgeCount = 0; 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) {} try { const sc = CacheService.getScriptCache(); const pf = aiTeamFolder.getFoldersByName("PERSONA").next().getFiles(); while(pf.hasNext()) { const f = pf.next(); personaCount++; try { sc.remove("PERSONA_" + f.getName().split('.')[0].toUpperCase()); } catch(e2) {} } } catch(e) {} try { knowledgeCount = countFilesRecursively(dataFolder.getFoldersByName("KNOWLEDGE").next()); } catch(e) {} 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) {} } } catch(e) {} 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 labelUpper = label.toUpperCase(); if (!data[i][BOTCONF_COL.MAIN_PERSONA]) { for(let key in db.icons) { if(key.includes(labelUpper)) { sheet.getRange(i+1, BOTCONF_COL.MAIN_PERSONA+1).setValue(key); break; } } } if (!data[i][BOTCONF_COL.RULE_NAME]) { if(db.rules[labelUpper]) sheet.getRange(i+1, BOTCONF_COL.RULE_NAME+1).setValue(labelUpper); } 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[labelUpper] = conf; } } } catch(e) { console.error("Sheet Sync Error:", e); } sysConfigFolder.createFile("sys_db_light.json", JSON.stringify(db)); try { ["SYS_DB_LIGHT","BOT_CONFIGS"].forEach(k => CacheService.getScriptCache().remove(k)); } catch(e) {} return { icons: Object.keys(db.icons).length, personas: personaCount, knowledge: knowledgeCount, rules: Object.keys(db.rules).length }; } function countFilesRecursively(folder) { let count = 0; const files = folder.getFiles(); while(files.hasNext()) { files.next(); count++; } const subs = folder.getFolders(); while(subs.hasNext()) count += countFilesRecursively(subs.next()); return count; } function getPersonaFromDrive(personaFileId, isFallback) { try { const cacheKey = "PERSONA_" + String(personaFileId).toUpperCase(); const sc = CacheService.getScriptCache(); const cached = sc.get(cacheKey); if (cached) return cached; const personaFolder = DriveApp.getFolderById(CONFIG.FOLDER_ID_ROOT).getFoldersByName("AI_TEAM").next().getFoldersByName("PERSONA").next(); const files = personaFolder.getFiles(); while(files.hasNext()) { const f = files.next(); if (f.getName().split('.')[0].toUpperCase() === String(personaFileId).toUpperCase()) { const text = f.getBlob().getDataAsString(); try { if(text.length < 100000) sc.put(cacheKey, text, 21600); } catch(e) {} return text; } } if (!isFallback) return getPersonaFromDrive("01_PLUMERIA", true); return "あなたはAIアシスタントです。"; } catch(e) { 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 cache = CacheService.getScriptCache(); const cachedStr = cache.get("BOT_CONFIGS"); if (cachedStr) { try { botConfigs = JSON.parse(cachedStr); } catch(e) {} } 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; } cache.put("BOT_CONFIGS", JSON.stringify(botConfigs), 1800); } } catch(e) { console.error("getAppConfigByBotId sheet error:", 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: props.getProperty(CONFIG.PROP_MODEL_KEY) || 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 && fixedIconUrl.startsWith("https://")) return fixedIconUrl; let key = String(personaName).toUpperCase(); if (key.includes("_")) key = key.split("_").slice(1).join("_"); return (db&&db.icons&&db.icons[key])||(db&&db.icons&&db.icons["PLUMERIA"])||""; } function analyzeIntentAndRoute(text, currentMode, userPlan, db) { if (!text || text.length < 5) return { persona: "01_PLUMERIA" }; try { const config = getAppConfigByBotId("PLUMERIA"); const plan = String(userPlan || "").toLowerCase(); const isPremium = plan.includes("プレミアム") || plan.includes("vip"); let allowed = ["01_PLUMERIA","02_KARIS","03_LANTOM","04_PARADOX"]; if (isPremium) allowed.push("05_ORACLE","06_REY"); const payload = { systemInstruction: { parts:[{ text:`ルーターとして最適なペルソナをJSON形式で返せ。\n{"persona":"ID"}\n許可: ${allowed.join(", ")}` }] }, contents: [{ role:"user", parts:[{ text }] }], generationConfig: { responseMimeType:"application/json", temperature:0.1 } }; const resJson = fetchGeminiSafe(`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${config.GEMINI_API_KEY}`, payload); if (!resJson||!resJson.candidates||!resJson.candidates[0]) return { persona:"01_PLUMERIA" }; const result = safeJsonParse(resJson.candidates[0].content.parts[0].text.replace(/```json/g,"").replace(/```/g,"").trim()); return result || { persona:"01_PLUMERIA" }; } catch(e) { return { persona:"01_PLUMERIA" }; } } function loadMetabolicMemory(userId, botId, folderId) { try { if (!folderId) return { core: { profile:{}, achievements:[], recentTopics:[] } }; 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:[] }; if (JSON.stringify(core).length * 1.3 > 5000) { return { core: runMetabolicProcess(userId, botId, core) }; } return { core }; } catch(e) { return { core: { profile:{}, achievements:[], recentTopics:[] } }; } } function saveMetabolicMemory(userId, botId, folderId, userMsg, aiMsg) { try { if (!folderId) return; 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); } } catch(e) { console.error("saveMetabolicMemory Error:", e); } } function runMetabolicProcess(userId, botId, core) { try { const props = PropertiesService.getScriptProperties(); const apiKey = props.getProperty("GEMINI_API_KEY"); const model = props.getProperty(CONFIG.PROP_MODEL_KEY) || CONFIG.DEFAULT_MODEL; const payload = { contents: [{ parts:[{ text:`以下のコアメモリからユーザーの背景・実績・嗜好をJSON形式のみで出力せよ:\n${JSON.stringify(core)}` }] }], generationConfig: { maxOutputTokens:1024, temperature:0.1 } }; const resJson = fetchGeminiSafe(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`, payload); if (!resJson||!resJson.candidates||!resJson.candidates[0]) return core; const match = resJson.candidates[0].content.parts[0].text.match(/\{[\s\S]*\}/); if (match) { const extracted = safeJsonParse(match[0]); if (extracted) core.profile = { ...core.profile, ...extracted }; } if (core.recentTopics && core.recentTopics.length > 10) core.recentTopics = core.recentTopics.slice(-10); } catch(e) { console.error("runMetabolicProcess Error:", e); } return core; } function appendToDriveLog(userId, folderId, userMsg, aiMsg) { try { if (!folderId) return; 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 > 500 ? lines.slice(-500).join('\n') : existing; file.setContent(trimmed + logEntry); } else { userFolder.createFile(fileName, logEntry); } } catch(e) { console.error("appendToDriveLog Error:", e); } } function checkTokenLimit(userId, botSheetConf) { const props = PropertiesService.getScriptProperties(); const currentUsage = parseInt(props.getProperty(`USAGE_${userId}`) || "0"); const limit = botSheetConf ? botSheetConf.monthlyTokenLimit : 1000000; if (currentUsage >= limit) return { isLimit:true, reason:"100_PERCENT" }; return { isLimit:false }; } function generateAlertIfNeeded(userId, botSheetConf) { const props = PropertiesService.getScriptProperties(); const currentUsage = parseInt(props.getProperty(`USAGE_${userId}`) || "0"); const limit = botSheetConf ? botSheetConf.monthlyTokenLimit : 1000000; const usageRate = currentUsage / limit; const alertKey = `ALERT_STATE_${userId}`; const lastAlert = props.getProperty(alertKey) || "0"; if (usageRate >= 0.8 && lastAlert !== "80") { props.setProperty(alertKey, "80"); return "🧭【通知】今月のご利用量が80%に達しました。"; } if (usageRate >= 0.5 && lastAlert !== "50" && lastAlert !== "80") { props.setProperty(alertKey, "50"); return "🧭【通知】今月のご利用量が50%を超えました。"; } return null; } function getPersonaError(type) { const errors = { "100_PERCENT": "今月のご利用上限に達しました。来月またご利用いただくか、プランの変更をご検討ください。", "SYSTEM_ERROR": "申し訳ございません。通信が不安定です。時間をおいてお試しください。" }; return errors[type] || errors["SYSTEM_ERROR"]; } function updateTokenUsage(userId, tokens) { if (!tokens) return; const props = PropertiesService.getScriptProperties(); const current = parseInt(props.getProperty(`USAGE_${userId}`) || "0"); props.setProperty(`USAGE_${userId}`, (current + tokens).toString()); } function callGeminiAPI(currentParts, instruction, userPlan, history, config, selectedModel, botSheetConf) { try { const model = selectedModel || config.MODEL || CONFIG.DEFAULT_MODEL; const maxTokens = botSheetConf ? botSheetConf.tokenPerReply : 5000; const payload = { systemInstruction: { parts:[{ text: instruction }] }, contents: [...history, { role:"user", parts: currentParts }], generationConfig: { temperature:0.7, maxOutputTokens: maxTokens } }; const resJson = fetchGeminiSafe( `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${config.GEMINI_API_KEY}`, payload ); if (!resJson||!resJson.candidates||!resJson.candidates[0]||!resJson.candidates[0].content) { return { reply:"(考えがまとまらんかった。もう一回送ってくれ。)", tokens:0, suggestions:[] }; } const textReply = resJson.candidates[0].content.parts[0].text; 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: resJson.usageMetadata ? resJson.usageMetadata.totalTokenCount : 0, suggestions: suggestions.slice(0,3) }; } catch(e) { return { reply:"(すまん、通信の調子が悪いみたいや。もう一度試してくれ。)", tokens:0, suggestions:[] }; } } function callGeminiJsonMode(userText, systemPrompt, config) { try { const payload = { systemInstruction: { parts:[{ text: systemPrompt }] }, contents: [{ role:"user", parts:[{ text: userText }] }], generationConfig: { responseMimeType:"application/json", temperature:0.1 } }; const resJson = fetchGeminiSafe( `https://generativelanguage.googleapis.com/v1beta/models/${config.MODEL}:generateContent?key=${config.GEMINI_API_KEY}`, payload ); if (!resJson||!resJson.candidates||!resJson.candidates[0]) throw new Error("No candidates"); const result = safeJsonParse(resJson.candidates[0].content.parts[0].text.replace(/```json/g,"").replace(/```/g,"").trim()); if (!result) throw new Error("JSON parse failed"); return result; } catch(e) { return { reply_text:"もう一回送ってくれ。", ARK_Rank:"注意", Logic_Reason:"解析エラー", Action_Advice:"再送信", Reply_Message:"解析に失敗しました。" }; } } function getFilteredHistory(userId, mode, botId, cachedFolderId) { try { if (!cachedFolderId) return []; const safeBotId = String(botId||"default").toUpperCase().replace(/[^A-Z0-9_-]/g,""); const files = DriveApp.getFolderById(cachedFolderId).getFilesByName(`his_${safeBotId}_${mode}.jsonl`); if (!files.hasNext()) return []; const content = files.next().getBlob().getDataAsString(); if (!content) return []; return content.split('\n').filter(l=>l.trim()).map(l=>{ try { return JSON.parse(l); } catch(e) { return null; } }).filter(l=>l!==null).slice(-30); } catch(e) { return []; } } function saveHistory(userId, mode, role, text, personaLabel, botId, cachedFolderId) { try { if (!cachedFolderId || personaLabel === "ROUTER") return; const safeBotId = String(botId||"default").toUpperCase().replace(/[^A-Z0-9_-]/g,""); 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); } } catch(e) { console.error("saveHistory error:", e); } } function loadLongTermMemory(userId, botId, cachedFolderId) { try { if (!cachedFolderId) return { global:"", local:"" }; const safeBotId = String(botId||"default").toUpperCase().replace(/[^A-Z0-9_-]/g,""); const fileName = `mem_${safeBotId}_${userId}.json`; const files = DriveApp.getFolderById(cachedFolderId).getFilesByName(fileName); if (!files.hasNext()) return { global:"", local:"" }; const memData = safeJsonParse(files.next().getBlob().getDataAsString()); if (!memData) return { global:"", local:"" }; return { global: memData.global||"", local: memData.local||"" }; } catch(e) { return { global:"", local:"" }; } } function compressToLongTermMemory(userId, botId, oldText, config, uFolder) { try { const safeBotId = String(botId||"default").toUpperCase().replace(/[^A-Z0-9_-]/g,""); const payload = { contents: [{ role:"user", parts:[{ text:`履歴を要約してJSONで。{"global":"","local":""}\n${oldText}` }] }], generationConfig: { responseMimeType:"application/json" } }; const resJson = fetchGeminiSafe(`https://generativelanguage.googleapis.com/v1beta/models/${config.MODEL}:generateContent?key=${config.GEMINI_API_KEY}`, payload); if (!resJson||!resJson.candidates||!resJson.candidates[0]) return; const jsonRes = safeJsonParse(resJson.candidates[0].content.parts[0].text.replace(/```json/g,"").replace(/```/g,"").trim()); if (!jsonRes) 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)); } } catch(e) { console.error("compressToLongTermMemory error:", e); } } function getCustomerWithReset(userId, providedName) { providedName = providedName || null; 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) { 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 lastReset = data[i][MEIBO_COL.FIRST_PAYMENT] ? new Date(data[i][MEIBO_COL.FIRST_PAYMENT]) : new Date(0); if ((now - lastReset) / 86400000 >= 30) { meibo.getRange(row, MEIBO_COL.USE_COUNT + 1, 1, 2).setValues([[0, 0]]); meibo.getRange(row, MEIBO_COL.FIRST_PAYMENT + 1).setValue(now); } return { row, nickname: currentNickname, plan: data[i][MEIBO_COL.PLAN] || "未加入", folderId }; } } const config = getAppConfigByBotId("PLUMERIA"); let lineName = providedName || getLineUserName(userId, config.LINE_ACCESS_TOKEN); if (!lineName||lineName==="ユーザー") lineName = "新規ユーザー"; const newFolderId = getOrCreateUserFolder(userId); meibo.appendRow([userId, lineName, lineName, now, "未加入", now, 0, 0, "新規", "", newFolderId]); return { row: meibo.getLastRow(), nickname: lineName, plan: "未加入", folderId: newFolderId }; } function getOrCreateUserFolder(userId) { 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(); } function getLineUserName(userId, token) { try { if (!token) return "ユーザー"; return JSON.parse(UrlFetchApp.fetch("https://api.line.me/v2/bot/profile/" + userId, { headers:{ "Authorization":"Bearer " + token }, muteHttpExceptions:true } ).getContentText()).displayName || "ユーザー"; } catch(e) { return "ユーザー"; } } function updateCustomerStats(userId, tokens) { const meibo = SpreadsheetApp.openById(CONFIG.CUSTOMER_SHEET_ID).getSheetByName("名簿"); const data = meibo.getDataRange().getValues(); for (let i = 1; i < data.length; i++) { if (normalizeId(data[i][MEIBO_COL.USER_ID]) === userId) { meibo.getRange(i + 1, MEIBO_COL.LAST_CHAT + 1).setValue(new Date()); meibo.getRange(i + 1, MEIBO_COL.USE_COUNT + 1).setValue((Number(data[i][MEIBO_COL.USE_COUNT]) || 0) + 1); meibo.getRange(i + 1, MEIBO_COL.TOKEN_TOTAL + 1).setValue((Number(data[i][MEIBO_COL.TOKEN_TOTAL]) || 0) + tokens); break; } } } function sendLineTextReply(replyToken, text, token) { if (!token) { console.error("sendLineTextReply: No token"); return; } try { 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 }); } catch(e) { console.error("sendLineTextReply Error:", e); } } function sendLineFlexReply(replyToken, bodyText, config) { sendLineTextReply(replyToken, getLineGuideMessage(), config.LINE_ACCESS_TOKEN); } function handleCustomAction(json, config, db) { try { const ruleKey = config.ruleName || String(config.label).toUpperCase(); const rule = (ruleKey && db.rules) ? db.rules[ruleKey] : null; if (!rule||!rule.target_sheet||!rule.actions||!rule.actions[json.action]) { return { status:"error", msg:"Action not defined: " + json.action }; } const actionDef = rule.actions[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 }; const data = sheet.getDataRange().getValues(); let row = -1; for (let i = 1; i < data.length; i++) { if (normalizeId(data[i][0])===userId) { row=i+1; break; } } if (row === -1) { const customer = getCustomerWithReset(userId); initServiceSheetRow(userId, customer.nickname, rule.target_sheet); const newData = sheet.getDataRange().getValues(); for (let i = 1; i < newData.length; i++) { if (normalizeId(newData[i][0])===userId) { row=i+1; break; } } if (row === -1) 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,3).getValue() || "ユーザー"; const pushMsg = actionDef.message ? actionDef.message.replace("{nickname}", nickname) : `🚨 [${nickname}] からSOSが届きました。至急確認してください。`; 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 }); } 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) { return { status:"error", msg:e.message }; } } function initServiceSheetRow(userId, nickname, sheetName) { try { const sheet = SpreadsheetApp.openById(CONFIG.CUSTOMER_SHEET_ID).getSheetByName(sheetName); if (!sheet) return; const data = sheet.getDataRange().getValues(); for (let i = 1; i < data.length; i++) { if (normalizeId(data[i][0])===userId) return; } sheet.appendRow([userId, new Date(), nickname]); } catch(e) { console.error("initServiceSheetRow error:", e); } } function handleSaveSettings(json, config, db) { try { const ruleKey = config.ruleName || String(config.label).toUpperCase(); const rule = (ruleKey && db.rules) ? db.rules[ruleKey] : null; if (!rule||!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 data = sheet.getDataRange().getValues(); let row = -1; for (let i = 1; i < data.length; i++) { if (normalizeId(data[i][0])===userId) { row=i+1; break; } } if (row===-1) return { status:"error", msg:"User not found." }; if (rule.onboarding&&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) { return { status:"error", msg:e.message }; } } function getServiceLogs(userId) { try { const logSheet = SpreadsheetApp.openById(CONFIG.CUSTOMER_SHEET_ID).getSheetByName("Cat_Logs"); if (!logSheet) return { status:"error" }; 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) { return { status:"error", msg:e.message }; } } function getOnboardingStatus(userId, sheetName) { try { const sheet = SpreadsheetApp.openById(CONFIG.CUSTOMER_SHEET_ID).getSheetByName(sheetName); if (!sheet) return 1; const data = sheet.getDataRange().getValues(); for (let i = 1; i < data.length; i++) { if (normalizeId(data[i][0])===userId) return Number(data[i][1]||1); } return 1; } catch(e) { 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); const data = sheet.getDataRange().getValues(); let row = 0; for (let i = 1; i < data.length; i++) { if (normalizeId(data[i][0])===userId) { row=i+1; break; } } if (row===0) { row=sheet.getLastRow()+1; sheet.getRange(row,1,1,2).setValues([[userId,1]]); } const fields = rule.onboarding.required_fields || []; if (fields.length===0) { sheet.getRange(row,2).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 = callGeminiJsonMode(json.userText, personaText + `\n収集済み: ${JSON.stringify(currentData)}\nJSON形式で返せ: {'reply_text':'...','extracted_data':{...},'is_complete':boolean}`, config); if (aiResponse && 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 && aiResponse.is_complete===true) { sheet.getRange(row,2).setValue(rule.onboarding.completion_status_value); } const replyText = (aiResponse&&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) { return { status:"error", msg:"Onboarding Error: " + e.message }; } } 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) { console.error("calcMonthlyRevenue error:", e); 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}`); } console.log(`updateFreeUserLimit: 収益=${revenue}円 → 上限=${freeLimit}人`); } catch(e) { console.error("updateFreeUserLimit error:", e); } } function setupNightlyBatchTrigger() { const triggers = ScriptApp.getProjectTriggers(); if (!triggers.some(t => t.getHandlerFunction() === "runNightlyBatch")) { ScriptApp.newTrigger("runNightlyBatch").timeBased().everyDays(1).atHour(3).create(); } if (!triggers.some(t => t.getHandlerFunction() === "runMonthlyUpdate")) { ScriptApp.newTrigger("runMonthlyUpdate").timeBased().onMonthDay(1).atHour(4).create(); } } function runMonthlyUpdate() { updateFreeUserLimit(); } function runNightlyBatch() { const config = getAppConfigByBotId("PLUMERIA"); const data = SpreadsheetApp.openById(CONFIG.CUSTOMER_SHEET_ID).getSheetByName("名簿").getDataRange().getValues(); 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) { 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, uFolder); file.setContent(lines.slice(-100).join("\n")); } } } } catch(e) { console.error("NightlyBatch error for user:", userId, e); } } }