From ccf6e685cea1b20a44be91a9fd08337ba074ad08 Mon Sep 17 00:00:00 2001 From: Guillaume ARM Date: Tue, 9 Jun 2026 20:10:52 +0200 Subject: [PATCH] feat(ai): add trapgpt chat bot --- apis/libai.lua | 23 +++-- apis/libtrapgpt.lua | 178 +++++++++++++++++++++++++++++++++++ packages/trapos-ai/ccpm.json | 4 +- programs/trapgpt.lua | 54 +++++++++++ tests/ai.lua | 48 ++++++++++ tests/trapgpt.lua | 155 ++++++++++++++++++++++++++++++ 6 files changed, 451 insertions(+), 11 deletions(-) create mode 100644 apis/libtrapgpt.lua create mode 100644 programs/trapgpt.lua create mode 100644 tests/trapgpt.lua diff --git a/apis/libai.lua b/apis/libai.lua index 42147ba..faf7b45 100644 --- a/apis/libai.lua +++ b/apis/libai.lua @@ -6,6 +6,7 @@ local DEFAULT_POLL_TIMEOUT_SECONDS = 300; local DEFAULT_POLL_INTERVAL_SECONDS = 2; local DEFAULT_LUA_EXEC_MAX_RETRIES = 2; local DEFAULT_LUA_EXEC_TIMEOUT_SECONDS = 5; +local DEFAULT_SESSION_SETTING_KEY = 'opencc.session_id'; local B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; @@ -255,9 +256,9 @@ local function createAi(opts) return nil; end - local function handleMissingSession(persist) + local function handleMissingSession(persist, sessionSettingKey) if persist then - settingsLib.unset('opencc.session_id'); + settingsLib.unset(sessionSettingKey or DEFAULT_SESSION_SETTING_KEY); if settingsLib.save then settingsLib.save(); end end return false, 'session introuvable; lance: ai new '; @@ -266,7 +267,7 @@ local function createAi(opts) local doGet; local doPost; - local function pollMessage(cfg, sessionId, messageId, persist) + local function pollMessage(cfg, sessionId, messageId, persist, sessionSettingKey) local loop = eventloopFactory(); local deadline = nowFunc() + cfg.pollTimeoutSeconds; local resultOk, resultValue; @@ -280,7 +281,7 @@ local function createAi(opts) local body, code = doGet(cfg, '/session/' .. sessionId .. '/message'); if not body then return finish(false, code); end if code == 404 then - local ok, value = handleMissingSession(persist); + local ok, value = handleMissingSession(persist, sessionSettingKey); return finish(ok, value); end if code and code ~= 200 then @@ -349,8 +350,9 @@ local function createAi(opts) }); end - function api.clearSession() - settingsLib.unset('opencc.session_id'); + function api.clearSession(options) + options = options or {}; + settingsLib.unset(options.sessionSettingKey or DEFAULT_SESSION_SETTING_KEY); if settingsLib.save then settingsLib.save(); end end @@ -385,9 +387,10 @@ local function createAi(opts) if not cfg then return false, err; end local persist = options.persist ~= false; + local sessionSettingKey = options.sessionSettingKey or DEFAULT_SESSION_SETTING_KEY; local sessionId = options.sessionId; if persist and sessionId == nil then - sessionId = settingsLib.get('opencc.session_id'); + sessionId = settingsLib.get(sessionSettingKey); end if not sessionId or sessionId == '' then @@ -402,7 +405,7 @@ local function createAi(opts) end sessionId = decoded.id; if persist then - settingsLib.set('opencc.session_id', sessionId); + settingsLib.set(sessionSettingKey, sessionId); if settingsLib.save then settingsLib.save(); end end end @@ -414,7 +417,7 @@ local function createAi(opts) }); if not body then return false, code; end if code == 404 then - return handleMissingSession(persist); + return handleMissingSession(persist, sessionSettingKey); end if code and code ~= 204 and code ~= 200 then return false, 'erreur message: HTTP ' .. tostring(code); @@ -428,7 +431,7 @@ local function createAi(opts) return true, { reply = reply, sessionId = sessionId, messageId = messageId }; end - return pollMessage(cfg, sessionId, messageId, persist); + return pollMessage(cfg, sessionId, messageId, persist, sessionSettingKey); end function api.createLuaExecutor(options) diff --git a/apis/libtrapgpt.lua b/apis/libtrapgpt.lua new file mode 100644 index 0000000..2936d35 --- /dev/null +++ b/apis/libtrapgpt.lua @@ -0,0 +1,178 @@ +local DEFAULT_THROTTLE_SECONDS = 5; +local DEFAULT_MAX_REPLY_CHARS = 160; +local DEFAULT_PREFIX = 'TrapGPT'; +local DEFAULT_SESSION_SETTING_KEY = 'trapgpt.opencc.session_id'; +local SILENCE = 'SILENCE'; + +local function nowSeconds() + if os.epoch then + return os.epoch('utc') / 1000; + end + return os.clock(); +end + +local function resolveNumber(value, defaultValue) + local n = tonumber(value); + if not n or n < 0 then return defaultValue; end + return n; +end + +local function trim(s) + return tostring(s or ''):gsub('^%s+', ''):gsub('%s+$', ''); +end + +local function truncate(s, maxChars) + s = trim(s); + if #s <= maxChars then return s; end + if maxChars <= 3 then return string.sub(s, 1, maxChars); end + return string.sub(s, 1, maxChars - 3) .. '...'; +end + +local function formatChatLine(message) + local at = message.at and ('@' .. tostring(math.floor(message.at)) .. ' ') or ''; + return at .. tostring(message.username or '?') .. ': ' .. tostring(message.text or ''); +end + +local function buildPrompt(messages, firstBatch, maxReplyChars) + local lines = { + 'Tu es TrapGPT dans le chat Minecraft.', + 'Reponds seulement si utile.', + 'Reponse tres concise: une phrase courte, maximum ' .. tostring(maxReplyChars) .. ' caracteres.', + 'Pas de markdown. Ne repete pas l historique.', + 'Si aucune reponse utile, reponds exactement: ' .. SILENCE, + }; + if firstBatch then + lines[#lines + 1] = 'Contexte initial: voici les premiers messages recus.'; + end + lines[#lines + 1] = ''; + lines[#lines + 1] = 'Nouveaux messages chat depuis le dernier envoi:'; + for _, message in ipairs(messages) do + lines[#lines + 1] = formatChatLine(message); + end + return table.concat(lines, '\n'); +end + +local function createTrapGpt(opts) + opts = opts or {}; + + local settingsLib = opts.settings or settings; + local nowFunc = opts.now or nowSeconds; + local sleepFunc = opts.sleep or sleep; + local ai = opts.ai or require('/apis/libai')(); + local chatBox = opts.chatBox; + local log = opts.log or print; + + local api = {}; + local history = {}; + local sentIndex = 0; + local firstBatch = true; + local lastSendAt = 0; + local active = false; + local stopped = false; + + local function throttleSeconds() + return resolveNumber(settingsLib.get('trapgpt.throttle_seconds'), DEFAULT_THROTTLE_SECONDS); + end + + local function maxReplyChars() + return math.max(1, resolveNumber(settingsLib.get('trapgpt.max_reply_chars'), DEFAULT_MAX_REPLY_CHARS)); + end + + local function prefix() + local value = settingsLib.get('trapgpt.prefix'); + if type(value) ~= 'string' or value == '' then return DEFAULT_PREFIX; end + return value; + end + + local function queuedMessages() + local messages = {}; + for i = sentIndex + 1, #history do + messages[#messages + 1] = history[i]; + end + return messages; + end + + local function shouldIgnore(username, text, isHidden) + if isHidden then return true; end + if type(text) ~= 'string' or trim(text) == '' then return true; end + if type(username) == 'string' and username == prefix() then return true; end + return false; + end + + function api.onChat(username, message, uuid, isHidden, messageUtf8) + local text = messageUtf8 or message; + if shouldIgnore(username, text, isHidden) then return false; end + history[#history + 1] = { + username = username, + text = text, + uuid = uuid, + at = nowFunc(), + }; + return true; + end + + function api.pendingCount() + return #history - sentIndex; + end + + function api.history() + return history; + end + + function api.buildPrompt(messages) + return buildPrompt(messages, firstBatch, maxReplyChars()); + end + + function api.processOnce() + if active or api.pendingCount() <= 0 then return false; end + + local waitSeconds = throttleSeconds() - (nowFunc() - lastSendAt); + if waitSeconds > 0 then sleepFunc(waitSeconds); end + + local startIndex = sentIndex + 1; + local messages = queuedMessages(); + if #messages == 0 then return false; end + + active = true; + local ok, result = ai.ask(buildPrompt(messages, firstBatch, maxReplyChars()), { + sessionSettingKey = DEFAULT_SESSION_SETTING_KEY, + sessionTitle = 'trapgpt', + }); + active = false; + lastSendAt = nowFunc(); + + if not ok then + log('trapgpt ai error: ' .. tostring(result)); + return false; + end + + sentIndex = startIndex + #messages - 1; + firstBatch = false; + + local reply = truncate(result.reply, maxReplyChars()); + if reply == '' or reply == SILENCE then return true; end + if chatBox then + local sent, err = chatBox.sendMessage(reply, prefix()); + if not sent then log('trapgpt chat error: ' .. tostring(err)); end + end + return true, reply; + end + + function api.stop() + stopped = true; + end + + function api.run() + while not stopped do + if api.pendingCount() > 0 then + api.processOnce(); + else + sleepFunc(0.25); + end + end + end + + return api; +end + +return createTrapGpt; diff --git a/packages/trapos-ai/ccpm.json b/packages/trapos-ai/ccpm.json index 21b76a3..540308b 100644 --- a/packages/trapos-ai/ccpm.json +++ b/packages/trapos-ai/ccpm.json @@ -5,7 +5,9 @@ "dependencies": ["trapos-core"], "files": [ "apis/libai.lua", - "programs/ai.lua" + "apis/libtrapgpt.lua", + "programs/ai.lua", + "programs/trapgpt.lua" ], "autostart": [] } diff --git a/programs/trapgpt.lua b/programs/trapgpt.lua new file mode 100644 index 0000000..ac32f34 --- /dev/null +++ b/programs/trapgpt.lua @@ -0,0 +1,54 @@ +local createTrapGpt = require('/apis/libtrapgpt'); +local createVersion = require('/apis/libversion'); + +local args = table.pack(...); + +local function printUsage() + print('trapgpt usage:'); + print(); + print(' trapgpt'); + print(' trapgpt --version'); + print(' trapgpt --help'); + print(); + print('settings required:'); + print(' opencc.server_url'); + print(); + print('settings optional:'); + print(' trapgpt.throttle_seconds (default: 5)'); + print(' trapgpt.max_reply_chars (default: 160)'); + print(' trapgpt.prefix (default: TrapGPT)'); +end + +local command = args[1]; + +if command == '--version' or command == '-version' or command == 'version' then + print('v' .. createVersion().forSelf()); + return; +end + +if command == '--help' or command == '-help' or command == 'help' then + printUsage(); + return; +end + +if args.n > 0 then + printUsage(); + return; +end + +local chatBox = peripheral.find('chat_box') or peripheral.find('chatBox'); +if not chatBox then + error('chat_box peripheral not found'); +end + +local trapgpt = createTrapGpt({ chatBox = chatBox }); + +local function listenChat() + while true do + local _, username, message, uuid, isHidden, messageUtf8 = os.pullEvent('chat'); + trapgpt.onChat(username, message, uuid, isHidden, messageUtf8); + end +end + +print('trapgpt listening'); +parallel.waitForAny(listenChat, function() trapgpt.run(); end); diff --git a/tests/ai.lua b/tests/ai.lua index b2ca2c0..9ed93ed 100644 --- a/tests/ai.lua +++ b/tests/ai.lua @@ -275,6 +275,21 @@ testlib.test('ask saves new session_id to settings', function() testlib.assertEquals(settingsStub.saveCount(), 1); end); +testlib.test('ask saves custom sessionSettingKey to settings', function() + local httpStub = fakeHttp( + { sessionResp('ses_trapgpt'), messageResp('reply') }, + {} + ); + local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host' }); + local ai = createAi({ http = httpStub, settings = settingsStub }); + + ai.ask('hello', { sessionSettingKey = 'trapgpt.opencc.session_id' }); + + testlib.assertEquals(settingsStub.values['trapgpt.opencc.session_id'], 'ses_trapgpt'); + testlib.assertEquals(settingsStub.values['opencc.session_id'], nil); + testlib.assertEquals(settingsStub.saveCount(), 1); +end); + testlib.test('ask reuses existing session_id without creating a new session', function() local httpStub = fakeHttp( { messageResp('reply') }, @@ -293,6 +308,25 @@ testlib.test('ask reuses existing session_id without creating a new session', fu testlib.assertTrue(string.find(httpStub.postCalls[1].url, '/session/ses_existing/prompt_async', 1, true) ~= nil); end); +testlib.test('ask reuses custom sessionSettingKey without creating a new session', function() + local httpStub = fakeHttp( + { messageResp('reply') }, + {} + ); + local settingsStub = fakeSettings({ + ['opencc.server_url'] = 'http://host', + ['opencc.session_id'] = 'ses_other', + ['trapgpt.opencc.session_id'] = 'ses_trapgpt', + }); + local ai = createAi({ http = httpStub, settings = settingsStub }); + + local ok = ai.ask('hello', { sessionSettingKey = 'trapgpt.opencc.session_id' }); + + testlib.assertTrue(ok); + testlib.assertEquals(#httpStub.postCalls, 1); + testlib.assertTrue(string.find(httpStub.postCalls[1].url, '/session/ses_trapgpt/prompt_async', 1, true) ~= nil); +end); + testlib.test('ask sends exact prompt text', function() local httpStub = fakeHttp( { messageResp('reply') }, @@ -940,4 +974,18 @@ testlib.test('clearSession unsets persisted session id', function() testlib.assertEquals(settingsStub.saveCount(), 1); end); +testlib.test('clearSession unsets custom sessionSettingKey', function() + local settingsStub = fakeSettings({ + ['opencc.session_id'] = 'ses_old', + ['trapgpt.opencc.session_id'] = 'ses_trapgpt', + }); + local ai = createAi({ http = fakeHttp({}, {}), settings = settingsStub }); + + ai.clearSession({ sessionSettingKey = 'trapgpt.opencc.session_id' }); + + testlib.assertEquals(settingsStub.values['opencc.session_id'], 'ses_old'); + testlib.assertEquals(settingsStub.values['trapgpt.opencc.session_id'], nil); + testlib.assertEquals(settingsStub.saveCount(), 1); +end); + testlib.run(); diff --git a/tests/trapgpt.lua b/tests/trapgpt.lua new file mode 100644 index 0000000..7b77e97 --- /dev/null +++ b/tests/trapgpt.lua @@ -0,0 +1,155 @@ +local createLibTest = require('/apis/libtest'); +local createTrapGpt = require('/apis/libtrapgpt'); + +local testlib = createLibTest({ ... }); + +local function fakeSettings(values) + values = values or {}; + return { + get = function(key) return values[key]; end, + }; +end + +local function fakeAi(replies) + local calls = {}; + local idx = 0; + return { + ask = function(prompt, options) + idx = idx + 1; + calls[#calls + 1] = { prompt = prompt, options = options }; + local reply = replies[idx]; + if reply == false then return false, 'ai failed'; end + return true, { reply = reply or 'ok' }; + end, + calls = calls, + }; +end + +local function fakeChatBox() + local messages = {}; + return { + sendMessage = function(message, prefix) + messages[#messages + 1] = { message = message, prefix = prefix }; + return true; + end, + messages = messages, + }; +end + +testlib.test('trapgpt queues visible chat messages only', function() + local ai = fakeAi({ 'hi' }); + local chatBox = fakeChatBox(); + local bot = createTrapGpt({ + ai = ai, + chatBox = chatBox, + settings = fakeSettings({ trapgpt_throttle_seconds = 0 }), + sleep = function() end, + now = function() return 10; end, + }); + + testlib.assertEquals(bot.onChat('alice', 'hello', 'uuid-a', false), true); + testlib.assertEquals(bot.onChat('bob', '$secret', 'uuid-b', true), false); + testlib.assertEquals(bot.pendingCount(), 1); +end); + +testlib.test('trapgpt sends queued messages in one prompt', function() + local ai = fakeAi({ 'bonjour' }); + local chatBox = fakeChatBox(); + local bot = createTrapGpt({ + ai = ai, + chatBox = chatBox, + settings = fakeSettings({ ['trapgpt.throttle_seconds'] = 0 }), + sleep = function() end, + now = function() return 20; end, + }); + + bot.onChat('alice', 'one', nil, false); + bot.onChat('bob', 'two', nil, false); + local processed, reply = bot.processOnce(); + + testlib.assertEquals(processed, true); + testlib.assertEquals(reply, 'bonjour'); + testlib.assertEquals(#ai.calls, 1); + testlib.assertTrue(ai.calls[1].prompt:find('alice: one', 1, true) ~= nil); + testlib.assertTrue(ai.calls[1].prompt:find('bob: two', 1, true) ~= nil); + testlib.assertEquals(ai.calls[1].options.sessionSettingKey, 'trapgpt.opencc.session_id'); + testlib.assertEquals(#chatBox.messages, 1); + testlib.assertEquals(chatBox.messages[1].prefix, 'TrapGPT'); +end); + +testlib.test('trapgpt only sends missing messages after success', function() + local ai = fakeAi({ 'first', 'second' }); + local bot = createTrapGpt({ + ai = ai, + chatBox = fakeChatBox(), + settings = fakeSettings({ ['trapgpt.throttle_seconds'] = 0 }), + sleep = function() end, + now = function() return 30; end, + }); + + bot.onChat('alice', 'one', nil, false); + bot.processOnce(); + bot.onChat('bob', 'two', nil, false); + bot.processOnce(); + + testlib.assertEquals(#ai.calls, 2); + testlib.assertTrue(ai.calls[2].prompt:find('bob: two', 1, true) ~= nil); + testlib.assertTrue(ai.calls[2].prompt:find('alice: one', 1, true) == nil); +end); + +testlib.test('trapgpt keeps queue when ai fails', function() + local ai = fakeAi({ false, 'retry' }); + local chatBox = fakeChatBox(); + local bot = createTrapGpt({ + ai = ai, + chatBox = chatBox, + settings = fakeSettings({ ['trapgpt.throttle_seconds'] = 0 }), + sleep = function() end, + now = function() return 40; end, + log = function() end, + }); + + bot.onChat('alice', 'one', nil, false); + testlib.assertEquals(bot.processOnce(), false); + testlib.assertEquals(bot.pendingCount(), 1); + testlib.assertEquals(bot.processOnce(), true); + testlib.assertEquals(bot.pendingCount(), 0); + testlib.assertEquals(#chatBox.messages, 1); +end); + +testlib.test('trapgpt does not send SILENCE replies', function() + local ai = fakeAi({ 'SILENCE' }); + local chatBox = fakeChatBox(); + local bot = createTrapGpt({ + ai = ai, + chatBox = chatBox, + settings = fakeSettings({ ['trapgpt.throttle_seconds'] = 0 }), + sleep = function() end, + now = function() return 50; end, + }); + + bot.onChat('alice', 'one', nil, false); + testlib.assertEquals(bot.processOnce(), true); + testlib.assertEquals(#chatBox.messages, 0); +end); + +testlib.test('trapgpt truncates long replies', function() + local ai = fakeAi({ 'abcdefghij' }); + local chatBox = fakeChatBox(); + local bot = createTrapGpt({ + ai = ai, + chatBox = chatBox, + settings = fakeSettings({ + ['trapgpt.throttle_seconds'] = 0, + ['trapgpt.max_reply_chars'] = 6, + }), + sleep = function() end, + now = function() return 60; end, + }); + + bot.onChat('alice', 'one', nil, false); + bot.processOnce(); + testlib.assertEquals(chatBox.messages[1].message, 'abc...'); +end); + +testlib.run();