commit 3a6ded75b3d19daf3a2aaecd8a0d017013772ba8 Author: Last2014 Date: Wed Dec 17 00:36:09 2025 +0900 First Commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8171cde --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +# Warn: 全ての値は必須です。指定しないで動くかは知りません。 + +# uwuzuサーバーへの接続 +UWUZU_HOST=uwuzu.example.com # uwuzuサーバーのホスト名+":"+ポート(:ポートは任意)です。originではないのでHTTPSが必須になっています。stringです。 +UWUZU_TOKEN=APITOKEN # uwuzuサーバーのアカウントで使えるAPIトークンです。全権限を与えることが推奨されているらしいです。stringです。 + +# Botの挙動 +TWEET_ENABLED=true # 3分から3時間での間隔でランダムなつぶやきをユーズとして投稿するかどうかを指定できます。booleanです。 +CHECK_INTERVAL=300 # メンション/返信を確認する間隔です。number(second)です。 +THINK_OUTPUT_ENABLED=false # 人工無能の脳内をconsole.log()するかどうかを指定できます。量が多いため非推奨らしいです。booleanです。 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e70261a --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.env** +!/.env.example \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..90e72a0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM node:24.12.0-alpine3.23 + +# Set working directory +WORKDIR /app + +# Copy package.json +COPY ./src/package.json ./ + +# Install dependencies +RUN npm install + +# Copy source +COPY ./src ./src + +# Create replied_ids.json +RUN touch replied_ids.json + +# Setup entrypoint +COPY ./entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +# Run entrypoint +ENTRYPOINT ["/entrypoint.sh"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..863eeec --- /dev/null +++ b/README.md @@ -0,0 +1,13 @@ +# rana for Docker +## About +[Daichimarukana/rana](https://github.com/Daichimarukana/rana)がDockerで動きます。 +中身は[f970f75](https://github.com/Daichimarukana/rana/commit/f970f75b3ff9babe6c711d3b6c2ecf9bd8bfa622)です。 + +## Usage +```bash +docker run -d --restart=always -v ./memory.db:/app/memory.db -e UWUZU_HOST=uwuzu.example.com -e UWUZU_TOKEN=APITOKEN -e TWEET_ENABLED=true -e CHECK_INTERVAL=300 -e THINK_OUTPUT_ENABLED=false --name rana gitea.last2014.com/last2014/rana-for-docker:latest +``` +で動きます。 +/app/memory.dbが記憶になっているのでvolumesにしてください。 +環境変数への値は`.env.example`を確認してください。 +全ての環境変数は必須です。指定しないで動くかは知りません。 \ No newline at end of file diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..32910df --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,15 @@ +#!/bin/sh + +# Create config.json +cat < /app/config.json +{ + "host": "${UWUZU_HOST}", + "api_token": "${UWUZU_TOKEN}", + "random_ueuse": "${TWEET_ENABLED}", + "check_interval": "${CHECK_INTERVAL}", + "rana_core_log": "${THINK_OUTPUT_ENABLED}" +} +EOF + +# Run +exec npm start diff --git a/src/core.js b/src/core.js new file mode 100644 index 0000000..ca915c9 --- /dev/null +++ b/src/core.js @@ -0,0 +1,457 @@ +// Rana Core +//注意: 結構LLMのみなさんにご協力いただいています。感謝の舞。 +const kuromoji = require('kuromoji'); +const Database = require('better-sqlite3'); +const path = require('path'); + +const DB_PATH = './memory.db'; +const MARKOV_ORDER = 3; //n-gramのやつらしい(例外的にお礼メッセージは2か3) +const TOP_K = 5; //類似上位 K 件をマルコフモデルに使うらしい +const SIM_THRESHOLD = 0.5; //類似度の閾値 +const MAX_GENERATE_TOKENS = 600; //生成上限トークン数 +let RANA_LOG = false; + +// -------- DB 初期化 -------- +const db = new Database(DB_PATH); +db.pragma('encoding = "UTF-8"'); +db.exec(` +CREATE TABLE IF NOT EXISTS qa ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + question TEXT, + tokens TEXT, + vector TEXT, + answer TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +`); + +// -------- ログ出力関数 -------- +function ranaLog(label, ...args) { + if (RANA_LOG !== true) return; + + const LABEL_WIDTH = 30; + const PREFIX = "[LOG]"; + + const now = new Date(); + const timeStr = now.toLocaleTimeString('ja-JP', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); + + const formattedTime = `[${timeStr}]`; + const formattedLabel = `[${label}]`; + const paddedLabel = formattedLabel.padEnd(LABEL_WIDTH, " "); + + const message = args.map(arg => + typeof arg === 'object' ? JSON.stringify(arg) : arg + ).join(" "); + + console.log(`${formattedTime}${PREFIX}${paddedLabel}: ${message}`); +} +// -------- kuromoji 初期化 (非同期だが CLI 起動で await) -------- +function buildTokenizer() { + return new Promise((resolve, reject) => { + const dicPath = path.join(__dirname, 'node_modules', 'kuromoji', 'dict'); + kuromoji.builder({ dicPath }).build((err, tokenizer) => { + if (err) return reject(err); + resolve(tokenizer); + }); + }); +} + +// -------- トークン化(原形があれば原形を使う) -------- +function tokenizeToArray(tokenizer, text) { + const toks = tokenizer.tokenize(text); + // basic_form が '*' の場合は surface_form を使う + const arr = toks.map(t => t.surface_form); + // フィルタ(空白・記号だけのトークン省く) + // return arr.filter(tok => tok && tok.trim().length > 0); + // 英語も使えるようになるはず + return arr.filter(tok => tok !== ""); +} + +// -------- 簡易 TF-IDF 実装(バッチで全件再計算) -------- +function buildVocabAndDf(tokensList) { + const df = new Map(); + tokensList.forEach(tokens => { + const seen = new Set(tokens); + seen.forEach(t => df.set(t, (df.get(t) || 0) + 1)); + }); + const vocab = Array.from(df.keys()); + return { vocab, df }; +} +function computeTfIdfVectors(tokensList, vocab, df) { + const N = tokensList.length; + const idf = vocab.map(term => { + const d = df.get(term) || 0; + return Math.log((N + 1) / (d + 1)) + 1; // smoothing + }); + return tokensList.map(tokens => { + const tf = new Map(); + tokens.forEach(t => tf.set(t, (tf.get(t) || 0) + 1)); + return vocab.map((term, i) => { + const t = tf.get(term) || 0; + return t * idf[i]; + }); + }); +} + +// -------- コサイン類似度 -------- +function cosine(a, b) { + let dot = 0, na = 0, nb = 0; + const len = Math.max(a.length, b.length); + for (let i = 0; i < len; i++) { + const av = a[i] || 0; + const bv = b[i] || 0; + dot += av * bv; + na += av * av; + nb += bv * bv; + } + if (na === 0 || nb === 0) return 0; + return dot / (Math.sqrt(na) * Math.sqrt(nb)); +} + +// -------- Markov(n-gram) テーブル構築(形態素単位) -------- +function buildMarkovTable(n, sequences) { + const table = new Map(); + const START = ''; + const END = ''; + sequences.forEach(seq => { + if (!seq || seq.length === 0) return; + const padded = [START, ...seq, END]; + for (let i = 0; i <= padded.length - n; i++) { + const keyArr = padded.slice(i, i + n - 1); + const next = padded[i + n - 1]; + const key = keyArr.join('\u0001'); + if (!table.has(key)) table.set(key, []); + table.get(key).push(next); + } + }); + return table; +} + +function weightedRandomChoice(choices, temperature = 2.0) { + const counts = {}; + choices.forEach(c => counts[c] = (counts[c] || 0) + 1); + const entries = Object.entries(counts).map(([tok, count]) => + [tok, Math.pow(count, 1 / temperature)] + ); + const total = entries.reduce((sum, [, w]) => sum + w, 0); + let r = Math.random() * total; + for (const [token, weight] of entries) { + r -= weight; + if (r <= 0) return token; + } + return entries[0][0]; +} + +function generateFromMarkov(table, n, startTokens = null, maxTokens = MAX_GENERATE_TOKENS) { + const keys = Array.from(table.keys()); + if (keys.length === 0) return ''; + ranaLog("Start Tokens", startTokens); + ranaLog("Keys", keys); + ranaLog("Markov Table", "=== Markov Table ==="); + for (const [key, values] of table.entries()) { + ranaLog("Markov Table", `${key} => ${values.join(", ")}`); + } + ranaLog("Markov Table", "=== End of Table ==="); + + let key; + if (startTokens && startTokens.length >= n - 1) { + // startTokensに部分一致するキーを全部探す + const matchingKeys = keys.filter(k => + k.includes(startTokens.slice(0, n - 1).join('\u0001')) + ); + if (matchingKeys.length > 0) { + // 複数候補からランダムに開始キーを選ぶ + key = matchingKeys[Math.floor(Math.random() * matchingKeys.length)]; + } + } + if (!key) { + // 文頭のキーを優先して選ぶ + const startKeys = keys.filter(k => k.startsWith('')); + key = startKeys.length > 0 ? startKeys[Math.floor(Math.random() * startKeys.length)] : keys[Math.floor(Math.random() * keys.length)]; + } + + const out = key.split('\u0001').filter(tok => tok !== ''); + let loopcnt = 0; // const から let に変更 + while (out.length < maxTokens) { + ranaLog("Generating Tokens Count", "token = " + loopcnt); + ranaLog("Generating N-gram Value", "n = " + n); + loopcnt++; + + const choices = table.get(key); + ranaLog("Generating Next Tokens List", choices); + if (!choices || choices.length === 0) break; + const next = weightedRandomChoice(choices); + ranaLog("Generating Next Token Choice", next) + if (next === '') break; + out.push(next); + const nextKeyArr = out.slice(out.length - (n - 1), out.length); + key = nextKeyArr.join('\u0001'); + if (!table.has(key)) break; + } + + return out.join(''); +} + + +// -------- DB 操作 -------- +const insertStmt = db.prepare('INSERT INTO qa (question, tokens, vector, answer) VALUES (?, ?, ?, ?)'); +const selectAllStmt = db.prepare('SELECT id, question, tokens, vector, answer FROM qa ORDER BY created_at DESC'); + +// 全ベクトルを再計算してDBに保存する(小規模向けバッチ処理) +function rebuildAllVectors() { + const rows = selectAllStmt.all(); + const tokensList = rows.map(r => JSON.parse(r.tokens || '[]')); + if (tokensList.length === 0) return { vocab: [], df: new Map() }; + const { vocab, df } = buildVocabAndDf(tokensList); + const vectors = computeTfIdfVectors(tokensList, vocab, df); + const updateVec = db.prepare('UPDATE qa SET vector = ? WHERE id = ?'); + rows.forEach((r, idx) => { + updateVec.run(JSON.stringify(vectors[idx]), r.id); + }); + return { vocab, df }; +} + +// getAnswer: 入力文章から最適な応答(Markov生成) or 学習要求を返す +function getAnswerForInput(inputTokens) { + const allRows = selectAllStmt.all(); // includes answered and unanswered + const answeredRows = allRows.filter(r => r.answer); + // tokensList includes ALL answered rows tokens + current input tokens for TF-IDF calc + const tokensList = answeredRows.map(r => JSON.parse(r.tokens || '[]')).concat([inputTokens]); + if (tokensList.length === 1) { + // データが全く無い(最初の1件だけ):必ず学習フロー + return { needTeach: true, reason: 'no_data' }; + } + const { vocab, df } = buildVocabAndDf(tokensList); + const vectors = computeTfIdfVectors(tokensList, vocab, df); + const inputVec = vectors[vectors.length - 1]; + + // compute score for each answeredRow + const candidates = answeredRows.map((r, idx) => { + const vec = vectors[idx]; + const score = cosine(inputVec, vec); + return { row: r, score }; + }).sort((a, b) => b.score - a.score); + + if (candidates.length === 0 || candidates[0].score < SIM_THRESHOLD) { + return { needTeach: true, reason: 'low_similarity', bestScore: candidates[0] ? candidates[0].score : 0 }; + } + + const highSimilarityCandidates = candidates.filter(c => c.score >= SIM_THRESHOLD); + + // 閾値を超える候補が3件以下の場合 + if (highSimilarityCandidates.length <= 3) { + // 2/3の確率で学習モードにすることでもっと頭を良くしようっていう感じ + if (Math.random() < 2 / 3) { + return { needTeach: true, reason: 'less_than_3_high_sim_candidates', bestScore: candidates[0].score }; + } + } + + const top = candidates.slice(0, TOP_K).filter(x => x.row.answer); + const answerTexts = top.map(x => x.row.answer).filter(Boolean); + if (answerTexts.length === 0) { + return { needTeach: true, reason: 'no_answer_text' }; + } + + const sequences = top.map(t => tokenizeToArray(globalTokenizer, t.row.answer)); + const table = buildMarkovTable(MARKOV_ORDER, sequences); + const generated = generateFromMarkov(table, MARKOV_ORDER, inputTokens); + const finalReply = (generated && generated.length >= 4) ? generated : top[0].row.answer; + + return { + needTeach: false, + reply: finalReply, + bestScore: candidates[0].score, + candidates: top.map(t => ({ id: t.row.id, score: t.score })) + }; +} + +// 学習はここだけ(重いので非同期) +async function teachAnswer(question, answer, tokens) { + ranaLog("Teaching Answer", "データベースにデータを保存しました!"); + insertStmt.run(question, JSON.stringify(tokens), JSON.stringify([]), answer); + + setImmediate(() => { + ranaLog("Rebuild All Vectors", "ベクトルのバックグラウンド再計算を開始"); + const start = Date.now(); + rebuildAllVectors(); + ranaLog("Rebuild All Vectors", `再計算完了 (${Date.now() - start}ms)`); + }); +} + +function sampleArray(array, n) { + const result = []; + const copy = array.slice(); + const len = Math.min(n, copy.length); + for (let i = 0; i < len; i++) { + const idx = Math.floor(Math.random() * copy.length); + result.push(copy.splice(idx, 1)[0]); + } + return result; +} + +// あらかじめ用意しておく定型文(たまに呟くときに使う) +const casualMutterings = [ + "お腹すいたなー。", + "今日の天気バチボコに悪いんだけど", + "さて、と。", + "ふぁ〜、ちょっと眠いかも…", + "何か面白いことないかなぁ。", + "ふへー...疲れた...", + "圧倒的疲労感...", + "眠すぎて死にそうかも", + "ちょっと暇ですね...", + "布団から出られないんですが", + "甘いものしか勝たん", + "血糖値スパイクで絶体絶命です...", + "ほら、らなちゃんですよー!", + "uwuzuってめっちゃ軽いらしいですよ~", + "布教布教...", +]; + +let globalTokenizer = null; + +async function init(log = false) { + globalTokenizer = await buildTokenizer(); + if (log === true) { + RANA_LOG = true; + } + ranaLog("Rana Core Start", "初期化完了!"); +} + +// ここでランダムなテキストを生成する(事前に学習データがある程度ないとうごけないよ) +function generateRandomText() { + ranaLog("Random Generating Start", "ランダム生成を開始しました!"); + const allRows = selectAllStmt.all(); + if (allRows.length === 0) return "何を話そうかな…"; + + // 質問と回答とさっきのcasualMutteringsから学習データを作成 + const filteredQuestions = allRows.filter(r => !r.question.includes("?") && !r.question.includes("?")); + const sampledQuestions = sampleArray(filteredQuestions, 25); + const questionSequences = sampledQuestions.map(r => tokenizeToArray(globalTokenizer, r.question)); + + const answeredRows = allRows.filter(r => r.answer); + const sampledAnsweredRows = sampleArray(answeredRows, 25); + const answerSequences = sampledAnsweredRows.map(r => tokenizeToArray(globalTokenizer, r.answer)); + + const casualSequences = casualMutterings.map(text => tokenizeToArray(globalTokenizer, text)); + + const sequences = [...questionSequences, ...answerSequences, ...casualSequences].filter(s => s.length > 0); + + // マルコフ連鎖します + //const n = [2, 3][Math.floor(Math.random() * 2)]; + const table = buildMarkovTable(MARKOV_ORDER, sequences); + let text = generateFromMarkov(table, MARKOV_ORDER, null, 50); + + // 短すぎたりしたらちょっとだめかも + if (!text || text.length < 2) { + if (answeredRows.length > 0) { + ranaLog("Generate Result(Too short)", answeredRows[0].answer); + text = answeredRows[0].answer; + } else { + ranaLog("Generate Error", "生成できませんでした..."); + text = "うーん、うまく言葉が出てこないかも"; + } + }else{ + ranaLog("Generate Result", text); + } + + return text; +} + +// これは質問に答えるやつ +function generateInputText(input) { + ranaLog("Generating Start", "生成を開始しました!"); + ranaLog("Generating Input", input); + const tokens = tokenizeToArray(globalTokenizer, input); + const result = getAnswerForInput(tokens); + ranaLog("Generate Raw Result", result); + if (result.needTeach) { + ranaLog("Generate Error", "生成できませんでした..."); + return "ごめんなさい...よくわかりませんでした..."; + } else { + ranaLog("Generate Result", result.reply); + return result.reply; + } +} + +function generateThanksText(template) { + ranaLog("Generate Thanks Start", "生成を開始しました!"); + + const thanksKeywords = [ + 'ありがとう', 'ありがと', '感謝', '覚えました', '助かり', 'うれしい', '嬉しい', + '勉強になります', '賢く', '把握', '了解', 'やったー', '最高' + ]; + + const whereClause = thanksKeywords.map(() => "answer LIKE ?").join(" OR "); + const query = `SELECT answer FROM qa WHERE ${whereClause} LIMIT 50`; + + const params = thanksKeywords.map(k => `%${k}%`); + let thanksRows = db.prepare(query).all(...params); + + if (template !== null && Array.isArray(template)) { + const templateRows = template.map(text => ({ answer: text })); + thanksRows = thanksRows.concat(templateRows); + } + + if (!thanksRows || thanksRows.length >= 5) { + const sequences = thanksRows.map(r => tokenizeToArray(globalTokenizer, r.answer)); + + // nは結構ランダムに! + const n = [2, 3][Math.floor(Math.random() * 2)]; + const table = buildMarkovTable(n, sequences); + + const generated = generateFromMarkov(table, n, null, 50); + + if (generated.length > 0) { + ranaLog("Generate Result", generated); + return generated; + } + } else { + ranaLog("Generate Error", "生成できませんでした..."); + return null; + } +} + +// お勉強はこちら +function studyInputText(Question, Answer) { + ranaLog("Learning Start", "学習を開始しました!"); + ranaLog("Learning Question", Question); + ranaLog("Learning Answer", Answer); + + const tokens = tokenizeToArray(globalTokenizer, Question); + teachAnswer(Question, Answer, tokens); + + const thanks_to_user = [ + "教えてくださりありがとうございます...!", + "しっかり覚えました!", + "また一つ賢くなれました!", + "次からは自信を持って答えられます!", + "ありがとうございます!しっかり学びました!" + ]; + + const thanks_message = generateThanksText(thanks_to_user); + if (thanks_message === null) { + ranaLog("Learning Reply", "定型文から返します。"); + return thanks_to_user[Math.floor(Math.random() * thanks_to_user.length)]; + } else { + ranaLog("Learning Reply", thanks_message+"を返します"); + return thanks_message; + } +} + +module.exports = { + init, + generateRandomText, + generateInputText, + tokenizeToArray, + getAnswerForInput, + studyInputText +}; \ No newline at end of file diff --git a/src/img/rana_bot_icon.png b/src/img/rana_bot_icon.png new file mode 100644 index 0000000..f423cd9 Binary files /dev/null and b/src/img/rana_bot_icon.png differ diff --git a/src/img/rana_logo.png b/src/img/rana_logo.png new file mode 100644 index 0000000..28f55ad Binary files /dev/null and b/src/img/rana_logo.png differ diff --git a/src/license.txt b/src/license.txt new file mode 100644 index 0000000..d7d6729 --- /dev/null +++ b/src/license.txt @@ -0,0 +1,54 @@ +------------------ + +ライセンス名 : uwuzu公衆利用ライセンス(英: uwuzu Public Use License) +バージョン : 1.0.0 +ライセンス著作権 : uwuzu +本ライセンス使用著作者帰属先 : daichimarukana +本ライセンス使用著作者連絡先 : daichimarukana@gmail.com + +この文書を完全にコピーして利用、2-15以降に追記することは許可されていますが、この文書の2-14以前の改変、保存・利用することは許可されていません。 + + +0. まえがき +uwuzu公衆利用ライセンスは本ライセンスを使用する全ての著作物の利用条件を明確にするためのライセンスです。 +主にコンピューターで実行されるソフトウェアに対して使用しやすいように作成されています。 + +本ライセンスでは、ソフトウェアの作成者、利用者、改変者、それぞれが負担なく利用できるように考えられています。 + +1. 定義 +ここにある定義はこの文書全体に適用されます。 +「本ライセンス」とはuwuzu公衆利用ライセンス バージョン1.0.0を指します。 +「著作権」とはベルヌ条約で示されている著作権及び各国の法律により示されている著作権を指します。 +「著作物」とは上記の著作権に基づく作成された物を指します。 +「著作者」とは本ライセンスの適用されている著作物を作成した人物を指します。 + +2. 利用条件 + 2-1. 本ライセンスが適用されている著作物は完全無料で閲覧・利用・改変が可能なものとします。 + 2-1-1. 改変し、公開する場合、本ライセンスが適用されている著作物のライセンスを変更することはできないものとします。 + 2-1-2. 改変する場合、著作物の原型を残す必要があります。 + 2-1-3. 改変する場合、「本ライセンス使用著作者帰属先」「本ライセンス使用著作者連絡先」を含め、本文書を引き継いで適用し、著作者の情報を削除しない必要があります。 + 2-1-3-1. 改変者の情報を追記することが可能ですが、著作者の権利を侵害しない範囲に限ります。 + 2-2. 本ライセンスが適用されている著作物を改変した場合、改変者は著作者による内容の開示を請求された場合に開示する必要があります。 + 2-2-1. 改変者は、改変した著作物を自身で公開する場合、著作者の同意を得る必要はありません。 + 2-3. 本ライセンスが適用されている著作物を改変して自身で利用する場合は、改変内容を開示する必要はありません。ただし、著作者から開示要求があった場合は、これに応じる必要があります。 + 2-3-1. 改変してから改変者が著作者以外の他人に譲渡・共有・配布する際は改変した著作物を誰でも使用できるものとします。 + 2-3-2. もし改変者が改変済の著作物を他人に譲渡・共有・配布せずに改変者自身で利用してサービスを提供する場合はサービス利用者に改変した著作物を公開する必要はありません。 + 2-4. 本ライセンスが適用されている著作物を二次配布したり改変したものを配布することは可能とします。 + 2-5. 本ライセンスはいかなる著作物にも著作者が適用することが可能です。 + 2-6. 著作物に本ライセンスを適用した著作者はいつでもこのライセンスの適用を取り消し、別のライセンスに変更することが可能なものとします。 + 2-6-1. 本ライセンスを適用した著作物のライセンスを変更した場合著作者はライセンスを変更したことを著作物内に明記する必要があります。 + 2-7. 本ライセンスが適用されている場合でも著作権は著作者に帰属します。 + 2-7-1. 著作者が本ライセンスが適用されている著作物の著作権の放棄を明記しない限り著作権は保護されます。 + 2-8. 著作者は本文書の「本ライセンス使用著作者帰属先」欄に著作者を判別できる文字列を記入する必要があります。 + 2-8-1. 可能であれば著作者は本文書の「本ライセンス使用著作者連絡先」に連絡先を記入する必要があります。 + 2-9. 本ライセンスが適用されている著作物の取り扱いは本ライセンス及び法律に則って扱う必要があります。 + 2-10. 本ライセンスが適用されている著作物を著作者が公開を停止し、著作者が利用者・改変者などに削除を求める旨の文章を公開していて、利用者・改変者などの関係者がそれに気づくことができた場合は削除する必要があるものとします。 + 2-11. 本ライセンスが適用されている著作物を使用し、何らかの損害が発生した場合に著作者は責任を負う必要はありません。 + 2-11-1. 本ライセンスが適用されている著作物を使用する際の責任は全て使用者にあるものとします。 + 2-12. 本ライセンスが適用されている著作物から本ライセンスを無効化するには著作者の許可、もしくは著作者によるライセンス変更が必要となります。 + 2-13. 本ライセンスが適用されている著作物は、営利目的を含む、いかなる利用目的であっても利用が可能です。 + 2-14. 本ライセンスはどなたでも自由にご利用いただけます。 + 2-15. 著作者は本ライセンスに追記して独自の規約を作成することが可能なものとし、利用者・改変者はその規約に従う必要があるものとします。 +/----------(以下追記欄)----------/ +追加事項はありません。 +-------------------<以上>------------------- \ No newline at end of file diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..0c254cf --- /dev/null +++ b/src/main.js @@ -0,0 +1,266 @@ +const rana = require('./core.js'); +const fs = require('fs'); +const path = require('path'); +const config = require('./config.json'); + +// 返信済みIDを保存するファイルパス(いつかはSQLiteとかに移行したほうが良いのかも?) +const REPLIED_IDS_FILE = path.join(__dirname, 'replied_ids.json'); + +// --- ここを自分の環境に合わせて設定 --- +const API_DOMAIN = config.host; +const API_TOKEN = config.api_token; +const CHECK_INTERVAL = config.check_interval * 1000; // 5分間隔(ミリ秒) +const RANDOM_UEUSE = config.random_ueuse; +const CORE_LOG = config.rana_core_log; +// ------------------------------------ + +function getRandomInterval() { + // ミリ秒単位で計算する + const minMinutes = 3; + const maxMinutes = 180; + const interval = Math.random() * (maxMinutes - minMinutes) + minMinutes; + return interval * 60 * 1000; // 分をミリ秒に変換 +} + +// 返信済みIDをファイルから読み込む関数 +function loadRepliedIds() { + try { + if (fs.existsSync(REPLIED_IDS_FILE)) { + const data = fs.readFileSync(REPLIED_IDS_FILE, 'utf8'); + return new Set(JSON.parse(data)); + } + } catch (error) { + console.error('返信済みIDファイルの読み込みに失敗しました:', error); + } + return new Set(); // ファイルがない、またはエラーの場合は空のSetを返す +} + +// 返信済みIDをファイルに保存する関数 +function saveRepliedIds(repliedIds) { + try { + fs.writeFileSync(REPLIED_IDS_FILE, JSON.stringify(Array.from(repliedIds))); + } catch (error) { + console.error('返信済みIDファイルの保存に失敗しました:', error); + } +} + +// ランダムな投稿処理 +async function randomPostLoop() { + console.log(`[${new Date().toLocaleString()}] ランダム投稿を開始します...`); + + // ランダムなテキストを生成 + const postText = rana.generateRandomText(); + console.log(`投稿テキスト: "${postText}"`); + + // 投稿するAPIを叩く + const url = `https://${API_DOMAIN}/api/ueuse/create`; + const params = { + token: API_TOKEN, + text: postText + }; + + try { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(params) + }); + const data = await response.json(); + if (data.uniqid) { + console.log(`ランダム投稿に成功しました!(投稿ID: ${data.uniqid})`); + } else { + console.error('ランダム投稿に失敗しました。'); + } + } catch (error) { + console.error('ランダム投稿エラー:', error); + } + + // 次の投稿までの時間をランダムに決めて、再度この関数を呼び出す + const nextInterval = getRandomInterval(); + console.log(`次のランダム投稿は ${(nextInterval / 60000).toFixed(2)} 分後です。`); + setTimeout(randomPostLoop, nextInterval); +} + +async function getMentions() { + const url = `https://${API_DOMAIN}/api/ueuse/mentions.php`; + const params = { token: API_TOKEN }; + + try { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(params) + }); + const data = await response.json(); + // 成功した場合はオブジェクトのキーを配列に変換 + if (data.success) { + return Object.values(data).filter(item => typeof item === 'object'); + } + return []; + } catch (error) { + console.error('メンション取得エラー:', error); + return []; + } +} + +async function getReplies() { + const url = `https://${API_DOMAIN}/api/me/notification/`; + const params = { token: API_TOKEN }; + + try { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(params) + }); + const data = await response.json(); + + if (data.success) { + return Object.values(data) + .filter(item => typeof item === 'object' && item.category === 'reply' && item.valueid !== null); + } + return []; + } catch (error) { + console.error('返信通知取得エラー:', error); + return []; + } +} + + +// 返信を投稿するAPIを叩く関数 +async function replyToPost(replyId, text) { + const url = `https://${API_DOMAIN}/api/ueuse/create`; + const params = { + token: API_TOKEN, + text: text, + replyid: replyId + }; + + try { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(params) + }); + const data = await response.json(); + return data; + } catch (error) { + console.error('返信投稿エラー:', error); + return { success: false }; + } +} + +async function getPostById(postId) { + const url = `https://${API_DOMAIN}/api/ueuse/get`; + const params = { token: API_TOKEN, uniqid: postId }; + + try { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(params) + }); + const data = await response.json(); + + if (data.success) { + // 数字キーの最初の要素を取得 + const firstKey = Object.keys(data).find(key => key !== "success"); + if (firstKey && data[firstKey] && data[firstKey].text) { + return data[firstKey]; // 投稿データ全体を返す + } + } + console.warn(`投稿ID ${postId} の本文が取得できませんでした。`); + return null; + } catch (error) { + console.error(`投稿取得エラー (ID: ${postId}):`, error); + return null; + } +} + +async function processReply(targetId, repliedIds, studyIds) { + const postData = await getPostById(targetId); + if (!postData || !postData.text) { + console.log(`ID: ${targetId} の本文が取得できないためスキップします。`); + return; + } + + const cleanedText = postData.text.replace(/@\w+\s/, ""); + console.log(cleanedText) + + let replyResult = null; + let replyText = ""; + + console.log(postData) + if (studyIds.has(postData.replyid)) { + const QuestionSubData = await getPostById(postData.replyid); + const QuestionData = await getPostById(QuestionSubData.replyid); + replyText = rana.studyInputText(QuestionData.text, cleanedText); + studyIds.delete(QuestionSubData.uniqid); + }else{ + replyText = rana.generateInputText(cleanedText); + } + + if(replyText == "ごめんなさい...よくわかりませんでした..."){ + replyText = replyText+"\nなんと答えれば良いか、教えていただけますか?\n```\nらなちゃんにこう答えてほしい!という理想のメッセージを返信してください!\nそれが求められてる回答なんだ!って感じで学習します!\n何も学習させたくなければ、このユーズには返信しないでください!\n```" + replyResult = await replyToPost(targetId, replyText); + studyIds.add(replyResult.uniqid); + console.log(studyIds); + }else{ + replyResult = await replyToPost(targetId, replyText); + } + + if (replyResult && replyResult.uniqid) { + console.log(`返信に成功しました!(投稿ID: ${replyResult.uniqid})`); + repliedIds.add(targetId); + } else { + console.error(`ID: ${targetId} への返信に失敗しました。`); + } +} + +const studyIds = new Set(); +// メインの処理(メンション+返信対応) +async function main() { + console.log(`[${new Date().toLocaleString()}] メンション&返信のチェックを開始します...`); + + const repliedIds = loadRepliedIds(); + + // メンション取得 + const allMentions = await getMentions(); + const newMentions = allMentions.filter(mention => + !repliedIds.has(mention.uniqid) && + mention.account && !mention.account.is_bot + ); + + // 返信取得 + const allReplies = await getReplies(); + const newReplies = allReplies.filter(reply => + !repliedIds.has(reply.valueid) // 通知の valueid が元投稿 ID + ); + + console.log(`新しいメンション: ${newMentions.length} 件, 新しい返信: ${newReplies.length} 件`); + + // メンション処理 + for (const mention of newMentions) { + await processReply(mention.uniqid, repliedIds, studyIds); + } + + // 返信処理 + for (const reply of newReplies) { + await processReply(reply.valueid, repliedIds, studyIds); + } + + saveRepliedIds(repliedIds); + console.log('今回の処理が完了しました。'); +} + +rana.init(CORE_LOG).then(() => { + // 10分ごとにメイン処理を実行 + setInterval(main, CHECK_INTERVAL); + // 最初に一度実行する + main(); + if(RANDOM_UEUSE === true){ + randomPostLoop(); + } +}).catch(err => { + console.error("Initialization failed:", err); +}); diff --git a/src/memory.db b/src/memory.db new file mode 100644 index 0000000..637751d Binary files /dev/null and b/src/memory.db differ diff --git a/src/package.json b/src/package.json new file mode 100644 index 0000000..7dbe510 --- /dev/null +++ b/src/package.json @@ -0,0 +1,17 @@ +{ + "name": "rana", + "version": "1.0.0", + "description": "Yuzuhare Rana", + "main": "main.js", + "scripts": { + "start": "node main.js" + }, + "keywords": [], + "author": "daichimarukana", + "license": "UPUL", + "type": "commonjs", + "dependencies": { + "better-sqlite3": "^12.5.0", + "kuromoji": "^0.1.2" + } +}