feat(ai): add trapgpt chat bot

This commit is contained in:
Guillaume ARM 2026-06-09 20:10:52 +02:00
parent 8e9958b1c3
commit ccf6e685ce
6 changed files with 451 additions and 11 deletions

View File

@ -6,6 +6,7 @@ local DEFAULT_POLL_TIMEOUT_SECONDS = 300;
local DEFAULT_POLL_INTERVAL_SECONDS = 2; local DEFAULT_POLL_INTERVAL_SECONDS = 2;
local DEFAULT_LUA_EXEC_MAX_RETRIES = 2; local DEFAULT_LUA_EXEC_MAX_RETRIES = 2;
local DEFAULT_LUA_EXEC_TIMEOUT_SECONDS = 5; local DEFAULT_LUA_EXEC_TIMEOUT_SECONDS = 5;
local DEFAULT_SESSION_SETTING_KEY = 'opencc.session_id';
local B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; local B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
@ -255,9 +256,9 @@ local function createAi(opts)
return nil; return nil;
end end
local function handleMissingSession(persist) local function handleMissingSession(persist, sessionSettingKey)
if persist then if persist then
settingsLib.unset('opencc.session_id'); settingsLib.unset(sessionSettingKey or DEFAULT_SESSION_SETTING_KEY);
if settingsLib.save then settingsLib.save(); end if settingsLib.save then settingsLib.save(); end
end end
return false, 'session introuvable; lance: ai new <prompt>'; return false, 'session introuvable; lance: ai new <prompt>';
@ -266,7 +267,7 @@ local function createAi(opts)
local doGet; local doGet;
local doPost; local doPost;
local function pollMessage(cfg, sessionId, messageId, persist) local function pollMessage(cfg, sessionId, messageId, persist, sessionSettingKey)
local loop = eventloopFactory(); local loop = eventloopFactory();
local deadline = nowFunc() + cfg.pollTimeoutSeconds; local deadline = nowFunc() + cfg.pollTimeoutSeconds;
local resultOk, resultValue; local resultOk, resultValue;
@ -280,7 +281,7 @@ local function createAi(opts)
local body, code = doGet(cfg, '/session/' .. sessionId .. '/message'); local body, code = doGet(cfg, '/session/' .. sessionId .. '/message');
if not body then return finish(false, code); end if not body then return finish(false, code); end
if code == 404 then if code == 404 then
local ok, value = handleMissingSession(persist); local ok, value = handleMissingSession(persist, sessionSettingKey);
return finish(ok, value); return finish(ok, value);
end end
if code and code ~= 200 then if code and code ~= 200 then
@ -349,8 +350,9 @@ local function createAi(opts)
}); });
end end
function api.clearSession() function api.clearSession(options)
settingsLib.unset('opencc.session_id'); options = options or {};
settingsLib.unset(options.sessionSettingKey or DEFAULT_SESSION_SETTING_KEY);
if settingsLib.save then settingsLib.save(); end if settingsLib.save then settingsLib.save(); end
end end
@ -385,9 +387,10 @@ local function createAi(opts)
if not cfg then return false, err; end if not cfg then return false, err; end
local persist = options.persist ~= false; local persist = options.persist ~= false;
local sessionSettingKey = options.sessionSettingKey or DEFAULT_SESSION_SETTING_KEY;
local sessionId = options.sessionId; local sessionId = options.sessionId;
if persist and sessionId == nil then if persist and sessionId == nil then
sessionId = settingsLib.get('opencc.session_id'); sessionId = settingsLib.get(sessionSettingKey);
end end
if not sessionId or sessionId == '' then if not sessionId or sessionId == '' then
@ -402,7 +405,7 @@ local function createAi(opts)
end end
sessionId = decoded.id; sessionId = decoded.id;
if persist then if persist then
settingsLib.set('opencc.session_id', sessionId); settingsLib.set(sessionSettingKey, sessionId);
if settingsLib.save then settingsLib.save(); end if settingsLib.save then settingsLib.save(); end
end end
end end
@ -414,7 +417,7 @@ local function createAi(opts)
}); });
if not body then return false, code; end if not body then return false, code; end
if code == 404 then if code == 404 then
return handleMissingSession(persist); return handleMissingSession(persist, sessionSettingKey);
end end
if code and code ~= 204 and code ~= 200 then if code and code ~= 204 and code ~= 200 then
return false, 'erreur message: HTTP ' .. tostring(code); return false, 'erreur message: HTTP ' .. tostring(code);
@ -428,7 +431,7 @@ local function createAi(opts)
return true, { reply = reply, sessionId = sessionId, messageId = messageId }; return true, { reply = reply, sessionId = sessionId, messageId = messageId };
end end
return pollMessage(cfg, sessionId, messageId, persist); return pollMessage(cfg, sessionId, messageId, persist, sessionSettingKey);
end end
function api.createLuaExecutor(options) function api.createLuaExecutor(options)

178
apis/libtrapgpt.lua Normal file
View File

@ -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;

View File

@ -5,7 +5,9 @@
"dependencies": ["trapos-core"], "dependencies": ["trapos-core"],
"files": [ "files": [
"apis/libai.lua", "apis/libai.lua",
"programs/ai.lua" "apis/libtrapgpt.lua",
"programs/ai.lua",
"programs/trapgpt.lua"
], ],
"autostart": [] "autostart": []
} }

54
programs/trapgpt.lua Normal file
View File

@ -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);

View File

@ -275,6 +275,21 @@ testlib.test('ask saves new session_id to settings', function()
testlib.assertEquals(settingsStub.saveCount(), 1); testlib.assertEquals(settingsStub.saveCount(), 1);
end); 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() testlib.test('ask reuses existing session_id without creating a new session', function()
local httpStub = fakeHttp( local httpStub = fakeHttp(
{ messageResp('reply') }, { 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); testlib.assertTrue(string.find(httpStub.postCalls[1].url, '/session/ses_existing/prompt_async', 1, true) ~= nil);
end); 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() testlib.test('ask sends exact prompt text', function()
local httpStub = fakeHttp( local httpStub = fakeHttp(
{ messageResp('reply') }, { messageResp('reply') },
@ -940,4 +974,18 @@ testlib.test('clearSession unsets persisted session id', function()
testlib.assertEquals(settingsStub.saveCount(), 1); testlib.assertEquals(settingsStub.saveCount(), 1);
end); 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(); testlib.run();

155
tests/trapgpt.lua Normal file
View File

@ -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();