Add ai functionality

This commit is contained in:
Aslan 2025-12-26 04:33:47 +01:00
parent da66cf9001
commit dd4da06753
19 changed files with 1118 additions and 52 deletions

View file

@ -9,11 +9,17 @@
},
"app": {
"triggerPrefix": "!",
"bowlingRoomId": "",
"experience": {
"gain": 5,
"startingRequirement": 50,
"multiplier": 1.25,
"timeout": 60000
},
"ai": {
"api": {
"key": ""
}
}
}
}

View file

@ -1,7 +1,7 @@
import { config } from "./config.js";
import { state } from "./store/store.js";
import { type IUser, type TRole } from "./store/types.js";
import type { IRank } from "./types.js";
import type { ILevel } from "./types.js";
const getUserById = (userId: string): IUser => {
return (
@ -19,22 +19,22 @@ const checkRoles = (roles: TRole[], userId: string) => {
return roles.includes(user.role);
};
const getRank = (experience: number): IRank => {
const getLevel = (experience: number): ILevel => {
let tmpExperience = experience;
let expToNextRank = config.app.experience.startingRequirement;
let rank = 0;
let expToNextLevel = config.app.experience.startingRequirement;
let level = 0;
while (tmpExperience >= expToNextRank) {
rank++;
tmpExperience -= expToNextRank;
expToNextRank = expToNextRank *= config.app.experience.multiplier;
while (tmpExperience >= expToNextLevel) {
level++;
tmpExperience -= expToNextLevel;
expToNextLevel = expToNextLevel *= config.app.experience.multiplier;
}
return {
rank: rank,
level: level,
experience: Math.floor(experience),
experienceInRank: Math.floor(tmpExperience),
expToNextRank: Math.floor(expToNextRank),
experienceInLevel: Math.floor(tmpExperience),
expToNextLevel: Math.floor(expToNextLevel),
};
};
@ -48,4 +48,4 @@ const getUserName = (user: IUser): string => {
return match.replaceAll("@", "");
};
export { getUserById, checkRoles, getRank, getUserName };
export { getUserById, checkRoles, getLevel, getUserName };

View file

@ -12,35 +12,41 @@ const registerModuleAdmin = (
client = matrixClient;
callbackStore.messageCallbacks.push({
startCondition: `${config.app.triggerPrefix}shutdown`,
startConditions: [`${config.app.triggerPrefix}shutdown`],
allowedRoles: ["ADMIN"],
callbackFunc: onShutdown,
});
callbackStore.messageCallbacks.push({
startCondition: `${config.app.triggerPrefix}loaddata`,
startConditions: [`${config.app.triggerPrefix}loaddata`],
allowedRoles: ["MODERATOR", "ADMIN"],
callbackFunc: onLoadData,
});
callbackStore.messageCallbacks.push({
startCondition: `${config.app.triggerPrefix}savedata`,
startConditions: [`${config.app.triggerPrefix}savedata`],
allowedRoles: ["MODERATOR", "ADMIN"],
callbackFunc: onSaveData,
});
};
const onShutdown = (text: string) => {
const onShutdown = (text: string, roomId: string) => {
if (!text.includes("nosave")) {
client.sendTextMessage(roomId, "Saving data...");
save();
}
process.exit(0);
const sendPromise = client.sendTextMessage(roomId, "Shutting down...");
sendPromise.finally(() => {
process.exit(0);
});
};
const onLoadData = () => {
const onLoadData = (_text: string, roomId: string) => {
client.sendTextMessage(roomId, "Loading data...");
load();
};
const onSaveData = () => {
const onSaveData = (_text: string, roomId: string) => {
client.sendTextMessage(roomId, "Saving data...");
save();
};

140
src/modules/ai/ai.ts Normal file
View file

@ -0,0 +1,140 @@
import { MatrixClient, MsgType } from "matrix-js-sdk";
import type { ICallbackStore } from "../types.js";
import { config } from "../../config.js";
import { getImageNanoBanana, getTextGemini } from "../../services/ai/ai.js";
import type { IAnimal } from "./types.js";
let client: MatrixClient;
const registerModuleAI = (
matrixClient: MatrixClient,
callbackStore: ICallbackStore,
) => {
client = matrixClient;
callbackStore.messageCallbacks.push({
startConditions: [`${config.app.triggerPrefix}ai `],
callbackFunc: onAI,
});
callbackStore.messageCallbacks.push({
startConditions: [
`satek when `,
`satek ked `,
`gabor when `,
`gabor ked `,
`martin when `,
`martin ked `,
`madys when `,
`madys ked `,
`mandak when `,
`mandak ked `,
`mando when `,
`mando ked `,
`mandik when `,
`mandik ked `,
`madik when `,
`madik ked `,
`janys when `,
`janys ked `,
`jano when `,
`jano ked `,
],
callbackFunc: onImageGen,
});
};
const getAnimal = (name: string): IAnimal | undefined => {
const animals: IAnimal[] = [
{
name: "satek",
animal: "black cat",
},
{
name: "gabor",
animal: "hedgehog",
},
{
name: "martin",
animal: "hedgehog",
},
{
name: "madys",
animal: "beaver",
},
{
name: "mandak",
animal: "beaver",
},
{
name: "mando",
animal: "beaver",
},
{
name: "mandik",
animal: "beaver",
},
{
name: "madik",
animal: "beaver",
},
{
name: "janys",
animal: "lion",
},
{
name: "jano",
animal: "lion",
},
];
const foundAnimals = animals
.map((animal) => {
if (name.includes(animal.name)) {
return animal;
}
})
.filter((animal) => animal);
return foundAnimals.at(0);
};
const onAI = async (text: string, roomId: string) => {
if (text.trim().length < 5) {
return;
}
const responseAI = await getTextGemini(text.replace("!ai ", ""));
client.sendTextMessage(roomId, responseAI);
};
const onImageGen = async (text: string, roomId: string) => {
const animal = getAnimal(text.trim().split(/\s+/)[0] ?? "");
if (!animal) {
return;
}
const textMod = text.replace(animal.name, animal.animal);
const buffer = await getImageNanoBanana(textMod.trim());
if (!buffer || buffer.length < 10) {
return;
}
const imageName = `photo-${animal.name}.png`;
const uploadResult = await client.uploadContent(buffer, {
type: "image/png",
name: imageName,
});
await client.sendMessage(roomId, {
msgtype: MsgType.Image,
body: imageName,
url: uploadResult.content_uri,
info: {
mimetype: "image/png",
size: buffer.length,
},
});
};
export { getAnimal, registerModuleAI, onImageGen };

2
src/modules/ai/index.ts Normal file
View file

@ -0,0 +1,2 @@
export * from "./ai.js";
export * from "./types.js";

6
src/modules/ai/types.ts Normal file
View file

@ -0,0 +1,6 @@
interface IAnimal {
name: string;
animal: string;
}
export { type IAnimal };

View file

@ -4,22 +4,26 @@ import { config } from "../../config.js";
let client: MatrixClient;
const registerModuleTest = (
const registerModuleBase = (
matrixClient: MatrixClient,
callbackStore: ICallbackStore,
) => {
client = matrixClient;
callbackStore.messageCallbacks.push({
startCondition: `${config.app.triggerPrefix}ping`,
startConditions: [`${config.app.triggerPrefix}ping`],
callbackFunc: onPing,
});
callbackStore.messageCallbacks.push({
startCondition: `${config.app.triggerPrefix}say `,
startConditions: [`${config.app.triggerPrefix}say `],
callbackFunc: onSay,
});
callbackStore.messageCallbacks.push({
startCondition: `${config.app.triggerPrefix}help`,
startConditions: [`${config.app.triggerPrefix}bowling `],
callbackFunc: onBowling,
});
callbackStore.messageCallbacks.push({
startConditions: [`${config.app.triggerPrefix}help`],
callbackFunc: onHelp,
});
};
@ -34,6 +38,12 @@ const onSay = (text: string, roomId: string) => {
client.sendTextMessage(roomId, text.replace(trigger, ""));
};
const onBowling = (text: string, _roomId: string) => {
const trigger = `${config.app.triggerPrefix}bowling `;
client.sendTextMessage(config.app.bowlingRoomId, text.replace(trigger, ""));
};
const onHelp = (_text: string, roomId: string) => {
client.sendHtmlMessage(
roomId,
@ -42,9 +52,11 @@ const onHelp = (_text: string, roomId: string) => {
<ul>
<li><b>!ping</b> - Pong!</li>
<li><b>!say {text}</b> - Repeats your message</li>
<li><b>!bowling {text}</b> - Repeats your message in bowling</li>
<li><b>!help</b> - Prints this help message</li>
<li><b>!rank</b> - Prints your rank and experience</li>
<li><b>!level</b> - Prints your level and experience</li>
<li><b>!leaderboard</b> - Prints total user ranking</li>
<li><b>!ai {text}</b> - Say something to Gemini 3</li>
</ul>
<hr/>
<h3>Role: Moderator</h3>
@ -60,4 +72,4 @@ const onHelp = (_text: string, roomId: string) => {
);
};
export { registerModuleTest };
export { registerModuleBase };

View file

@ -1,6 +1,6 @@
import type { MatrixClient } from "matrix-js-sdk";
import type { TRole } from "../store/types.js";
import { getRank, getUserById } from "../helpers.js";
import { getLevel, getUserById } from "../helpers.js";
import { config } from "../config.js";
import { state } from "../store/store.js";
@ -23,19 +23,19 @@ const onAnyMessage = (
return onAnyMessage(client, _text, roomId, sender);
}
const rankBefore = getRank(user.experience);
const levelBefore = getLevel(user.experience);
if (date > user.lastMessageTimestamp + config.app.experience.timeout) {
user.experience += config.app.experience.gain;
}
user.lastMessageTimestamp = date;
const rankAfter = getRank(user.experience);
if (rankAfter.rank > rankBefore.rank) {
const levelAfter = getLevel(user.experience);
if (levelAfter.level > levelBefore.level) {
client.sendHtmlMessage(
roomId,
"",
`${sender} - You are now rank <b>${rankAfter.rank}</b>`,
`${sender} - You are now level <b>${levelAfter.level}</b>`,
);
}
};

View file

@ -4,10 +4,11 @@ import {
RoomEvent,
type IContent,
} from "matrix-js-sdk";
import { registerModuleTest } from "./base/base.js";
import type { ICallback, ICallbackStore } from "./types.js";
import { registerModuleBase } from "./base/base.js";
import { registerModuleAdmin } from "./admin/admin.js";
import { registerModuleUser } from "./user/user.js";
import { registerModuleAI } from "./ai/ai.js";
import { checkRoles, getUserById } from "../helpers.js";
import { onAnyMessage, onMissingRole } from "./global.js";
import { config } from "../config.js";
@ -27,13 +28,20 @@ const checkMessageCallback = (
return false;
}
if (callback.startCondition && !text.startsWith(callback.startCondition)) {
if (
callback.startConditions &&
!callback.startConditions.some((condition) =>
text.startsWith(condition),
)
) {
return false;
}
if (
callback.includesCondition &&
!text.includes(callback.includesCondition)
callback.includesConditions &&
!callback.includesConditions.some((condition) =>
text.includes(condition),
)
) {
return false;
}
@ -94,9 +102,10 @@ const registerModules = (client: MatrixClient) => {
});
});
registerModuleTest(client, callbacks);
registerModuleBase(client, callbacks);
registerModuleAdmin(client, callbacks);
registerModuleUser(client, callbacks);
registerModuleAI(client, callbacks);
};
export { registerModules };

View file

@ -5,8 +5,8 @@ interface ICallbackStore {
}
interface ICallback {
startCondition?: string;
includesCondition?: string;
startConditions?: string[];
includesConditions?: string[];
allowedRoles?: TRole[];
allowedRooms?: string;
callbackFunc: (text: string, roomId: string, sender: string) => void;

View file

@ -1,7 +1,7 @@
import { MatrixClient } from "matrix-js-sdk";
import type { ICallbackStore } from "../types.js";
import { config } from "../../config.js";
import { getRank, getUserById, getUserName } from "../../helpers.js";
import { getLevel, getUserById, getUserName } from "../../helpers.js";
import { state } from "../../store/store.js";
import type { IUser } from "../../store/types.js";
let client: MatrixClient;
@ -13,31 +13,35 @@ const registerModuleUser = (
client = matrixClient;
callbackStore.messageCallbacks.push({
startCondition: `${config.app.triggerPrefix}rank`,
callbackFunc: onRank,
startConditions: [`${config.app.triggerPrefix}level`],
callbackFunc: onLevel,
});
callbackStore.messageCallbacks.push({
startCondition: `${config.app.triggerPrefix}leaderboard`,
startConditions: [`${config.app.triggerPrefix}leaderboard`],
callbackFunc: onLeaderboard,
});
};
const onRank = (_text: string, roomId: string, sender: string) => {
const rank = getRank(getUserById(sender).experience);
const onLevel = (_text: string, roomId: string, sender: string) => {
const level = getLevel(getUserById(sender).experience);
client.sendHtmlMessage(
roomId,
"",
`<h3>Your Rank: ${rank.rank}</h3>
<i>Next rank progress: ${rank.experienceInRank}/${rank.expToNextRank}exp</i>`,
`<h3>Your Level: <b>${level.level}</b></h3>
</br>
<i>Next level progress: ${level.experienceInLevel}/${level.expToNextLevel}xp</i>`,
);
};
const onLeaderboard = (_text: string, roomId: string) => {
const mapUsersToLeaderboard = (user: IUser): string => {
const rank = getRank(user.experience);
const level = getLevel(user.experience);
const userName = getUserName(user);
const userNameMod =
userName.charAt(0).toUpperCase() + userName.slice(1);
return `<li>${getUserName(user)}: rank ${rank.rank} (${rank.experienceInRank}/${rank.expToNextRank}exp)</li>`;
return `<li>${userNameMod}: level <b>${level.level}</b> (${level.experienceInLevel}/${level.expToNextLevel}xp)</li>`;
};
const users = state.users.sort(

44
src/services/ai/ai.ts Normal file
View file

@ -0,0 +1,44 @@
import { GoogleGenAI } from "@google/genai";
import { config } from "../../config.js";
const googleAI = new GoogleGenAI({
apiKey: config.app.ai.api.key,
});
const getTextGemini = async (input: string): Promise<string> => {
const response = await googleAI.models.generateContent({
model: "gemini-3-flash-preview",
contents: input,
});
return response.text ?? "AI Error";
};
const getImageNanoBanana = async (
input: string,
): Promise<Buffer<ArrayBuffer> | undefined> => {
const response = await googleAI.models.generateContent({
model: "gemini-2.5-flash-image",
contents: input,
});
const firstCandidate = (response.candidates ?? [])[0];
const parts = firstCandidate?.content?.parts ?? [];
let buffer: Buffer<ArrayBuffer> | undefined = undefined;
parts.forEach((part) => {
if (part.inlineData) {
const imageData = part.inlineData.data;
if (!imageData) {
return;
}
buffer = Buffer.from(imageData, "base64");
}
});
return buffer;
};
export { getTextGemini, getImageNanoBanana };

1
src/services/ai/index.ts Normal file
View file

@ -0,0 +1 @@
export * from "./ai.js";

View file

@ -1,8 +1,8 @@
interface IRank {
rank: number;
interface ILevel {
level: number;
experience: number;
experienceInRank: number;
expToNextRank: number;
experienceInLevel: number;
expToNextLevel: number;
}
export { type IRank };
export { type ILevel };