Initial code

This commit is contained in:
Aslan 2025-12-23 07:18:10 -05:00
commit 1f20a611da
26 changed files with 1050 additions and 0 deletions

33
src/auth/auth.ts Normal file
View file

@ -0,0 +1,33 @@
import { existsSync, readFileSync, writeFileSync } from "fs";
import crypto from "crypto";
import type { BotAuth, BotAuthJson } from "./types.js";
const newAuth = (): BotAuth => {
return {
secretKey: new Uint8Array(crypto.randomBytes(32)),
};
};
const saveAuth = (authPath: string, auth: BotAuth) => {
const authJson: BotAuthJson = {
secretKey: Buffer.from(auth.secretKey).toString("base64"),
};
const json = JSON.stringify(authJson);
writeFileSync(authPath, json);
};
const loadAuth = (authPath: string): BotAuth => {
if (!existsSync(authPath)) {
saveAuth(authPath, newAuth());
}
const json = readFileSync(authPath).toString();
const authJson = JSON.parse(json) as BotAuthJson;
return {
secretKey: new Uint8Array(Buffer.from(authJson.secretKey, "base64")),
};
};
export { newAuth, saveAuth, loadAuth };

2
src/auth/index.ts Normal file
View file

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

9
src/auth/types.ts Normal file
View file

@ -0,0 +1,9 @@
interface BotAuth {
secretKey: Uint8Array<ArrayBuffer>;
}
interface BotAuthJson {
secretKey: string;
}
export { type BotAuth, type BotAuthJson };

18
src/config.json Normal file
View file

@ -0,0 +1,18 @@
{
"baseUrl": "https://matrix.aslan2142.space",
"userId": "@aslobot:aslan2142.space",
"authPath": "auth.json",
"storePath": "store.json",
"auth": {
"accessToken": "mct_iW6Cif22H34s5yAHrmqfBQUsMrGaH2_0QWfYU",
"deviceId": "PBz1Ig9c3p"
},
"app": {
"triggerPrefix": "!",
"experience": {
"gain": 5,
"startingRequirement": 50,
"timeout": 60000
}
}
}

3
src/config.ts Normal file
View file

@ -0,0 +1,3 @@
import config from "./config.json" with { type: "json" };
export { config };

0
src/consts.ts Normal file
View file

51
src/helpers.ts Normal file
View file

@ -0,0 +1,51 @@
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";
const getUserById = (userId: string): IUser => {
return (
state.users.find((user) => user.id === userId) ?? {
id: ":",
role: "NONE",
experience: 0,
lastMessageTimestamp: 0,
}
);
};
const checkRoles = (roles: TRole[], userId: string) => {
const user = getUserById(userId);
return roles.includes(user.role);
};
const getRank = (experience: number): IRank => {
let tmpExperience = experience;
let expToNextRank = config.app.experience.startingRequirement;
let rank = 0;
while (tmpExperience >= expToNextRank) {
rank++;
tmpExperience -= expToNextRank;
expToNextRank = expToNextRank *= 1.2;
}
return {
rank: rank,
experience: Math.floor(experience),
experienceInRank: Math.floor(tmpExperience),
expToNextRank: Math.floor(expToNextRank),
};
};
const getUserName = (user: IUser): string => {
const userPattern = /@[a-zA-Z0-9]*/;
const match = user.id.match(userPattern)?.at(0);
if (!match) {
return "";
}
return match.replaceAll("@", "");
};
export { getUserById, checkRoles, getRank, getUserName };

41
src/index.ts Normal file
View file

@ -0,0 +1,41 @@
import config from "./config.json" with { type: "json" };
import { MatrixClient, createClient, ClientEvent } from "matrix-js-sdk";
import { registerModules } from "./modules/module.js";
let matrixClient: MatrixClient | undefined = undefined;
const initialize = async (): Promise<number> => {
matrixClient = createClient({
baseUrl: config.baseUrl,
userId: config.userId,
accessToken: config.auth.accessToken,
deviceId: config.auth.deviceId,
});
matrixClient.once(ClientEvent.Sync, function (state) {
if (state === "PREPARED") {
console.log("prepared");
}
});
matrixClient.startClient({ initialSyncLimit: 1 });
return 0;
};
const listen = async () => {
if (!matrixClient) {
return;
}
registerModules(matrixClient);
};
const initCode = await initialize();
if (initCode > 0) {
process.exit(initCode);
}
listen();

View file

@ -0,0 +1,47 @@
import { MatrixClient } from "matrix-js-sdk";
import type { ICallbackStore } from "../types.js";
import { config } from "../../config.js";
import { load, save } from "../../store/store.js";
let client: MatrixClient;
const registerModuleAdmin = (
matrixClient: MatrixClient,
callbackStore: ICallbackStore,
) => {
client = matrixClient;
callbackStore.messageCallbacks.push({
startCondition: `${config.app.triggerPrefix}shutdown`,
allowedRoles: ["ADMIN"],
callbackFunc: onShutdown,
});
callbackStore.messageCallbacks.push({
startCondition: `${config.app.triggerPrefix}loaddata`,
allowedRoles: ["MODERATOR", "ADMIN"],
callbackFunc: onLoadData,
});
callbackStore.messageCallbacks.push({
startCondition: `${config.app.triggerPrefix}savedata`,
allowedRoles: ["MODERATOR", "ADMIN"],
callbackFunc: onSaveData,
});
};
const onShutdown = (text: string) => {
if (!text.includes("nosave")) {
save();
}
process.exit(0);
};
const onLoadData = () => {
load();
};
const onSaveData = () => {
save();
};
export { registerModuleAdmin };

View file

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

63
src/modules/base/base.ts Normal file
View file

@ -0,0 +1,63 @@
import { MatrixClient } from "matrix-js-sdk";
import type { ICallbackStore } from "../types.js";
import { config } from "../../config.js";
let client: MatrixClient;
const registerModuleTest = (
matrixClient: MatrixClient,
callbackStore: ICallbackStore,
) => {
client = matrixClient;
callbackStore.messageCallbacks.push({
startCondition: `${config.app.triggerPrefix}ping`,
callbackFunc: onPing,
});
callbackStore.messageCallbacks.push({
startCondition: `${config.app.triggerPrefix}say `,
callbackFunc: onSay,
});
callbackStore.messageCallbacks.push({
startCondition: `${config.app.triggerPrefix}help`,
callbackFunc: onHelp,
});
};
const onPing = (_text: string, roomId: string) => {
client.sendTextMessage(roomId, "Pong!");
};
const onSay = (text: string, roomId: string) => {
const trigger = `${config.app.triggerPrefix}say `;
client.sendTextMessage(roomId, text.replace(trigger, ""));
};
const onHelp = (_text: string, roomId: string) => {
client.sendHtmlMessage(
roomId,
"",
`<h3>Role: User</h3>
<ul>
<li><b>!ping</b> - Pong!</li>
<li><b>!say {text}</b> - Repeats your message</li>
<li><b>!help</b> - Prints this help message</li>
<li><b>!rank</b> - Prints your rank and experience</li>
<li><b>!leaderboard</b> - Prints total user ranking</li>
</ul>
<hr/>
<h3>Role: Moderator</h3>
<ul>
<li><b>!load</b> - Load bot data</li>
<li><b>!save</b> - Save bot data</li>
</ul>
<hr/>
<h3>Role: Admin</h3>
<ul>
<li><b>!shutdown</b> - Shutdown bot</li>
</ul>`,
);
};
export { registerModuleTest };

View file

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

55
src/modules/global.ts Normal file
View file

@ -0,0 +1,55 @@
import type { MatrixClient } from "matrix-js-sdk";
import type { TRole } from "../store/types.js";
import { getRank, getUserById } from "../helpers.js";
import { config } from "../config.js";
import { state } from "../store/store.js";
const onAnyMessage = (
client: MatrixClient,
_text: string,
roomId: string,
sender: string,
) => {
const date = Date.now();
const user = getUserById(sender);
if (user.id === ":") {
state.users.push({
id: sender,
role: "USER",
experience: 0,
lastMessageTimestamp: date,
});
return onAnyMessage(client, _text, roomId, sender);
}
const rankBefore = getRank(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) {
client.sendHtmlMessage(
roomId,
"",
`${sender} - You are now rank <b>${rankAfter.rank}</b>`,
);
}
};
const onMissingRole = (
client: MatrixClient,
userRole: TRole,
roomId: string,
) => {
client.sendHtmlMessage(
roomId,
"",
`You are missing the required role.<br/>Your current role is <b>${userRole}</b>`,
);
};
export { onAnyMessage, onMissingRole };

102
src/modules/module.ts Normal file
View file

@ -0,0 +1,102 @@
import {
MatrixClient,
MatrixEvent,
RoomEvent,
type IContent,
} from "matrix-js-sdk";
import { registerModuleTest } from "./base/base.js";
import type { ICallback, ICallbackStore } from "./types.js";
import { registerModuleAdmin } from "./admin/admin.js";
import { registerModuleUser } from "./user/user.js";
import { checkRoles, getUserById } from "../helpers.js";
import { onAnyMessage, onMissingRole } from "./global.js";
import { config } from "../config.js";
const callbacks: ICallbackStore = {
messageCallbacks: [],
};
const checkMessageCallback = (
client: MatrixClient,
text: string,
callback: ICallback,
roomId: string,
sender: string,
) => {
if (callback.allowedRooms && !callback.allowedRooms.includes(roomId)) {
return false;
}
if (callback.startCondition && !text.startsWith(callback.startCondition)) {
return false;
}
if (
callback.includesCondition &&
!text.includes(callback.includesCondition)
) {
return false;
}
if (callback.allowedRoles && !checkRoles(callback.allowedRoles, sender)) {
onMissingRole(client, getUserById(sender).role, roomId);
return false;
}
return true;
};
const registerModules = (client: MatrixClient) => {
const startupTime = Date.now();
client.on(RoomEvent.Timeline, (event: MatrixEvent) => {
const ts = event.getTs();
if (ts < startupTime) {
return;
}
if (event.getType() !== "m.room.message") {
return;
}
const content = event.getContent<IContent>();
const body = content?.body;
if (!body || !client) {
return;
}
const roomId = event.getRoomId();
const sender = event.getSender();
if (!roomId || !sender) {
return;
}
if (sender === config.userId) {
return;
}
console.log(`Message from ${sender} in ${roomId}: ${body}`);
onAnyMessage(client, body.toString(), roomId, sender);
callbacks.messageCallbacks.forEach((callback) => {
if (
checkMessageCallback(
client,
body.toString(),
callback,
roomId,
sender,
)
) {
callback.callbackFunc(body.toString(), roomId, sender);
}
});
});
registerModuleTest(client, callbacks);
registerModuleAdmin(client, callbacks);
registerModuleUser(client, callbacks);
};
export { registerModules };

15
src/modules/types.ts Normal file
View file

@ -0,0 +1,15 @@
import type { TRole } from "../store/types.js";
interface ICallbackStore {
messageCallbacks: ICallback[];
}
interface ICallback {
startCondition?: string;
includesCondition?: string;
allowedRoles?: TRole[];
allowedRooms?: string;
callbackFunc: (text: string, roomId: string, sender: string) => void;
}
export { type ICallbackStore, type ICallback };

View file

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

57
src/modules/user/user.ts Normal file
View file

@ -0,0 +1,57 @@
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 { state } from "../../store/store.js";
import type { IUser } from "../../store/types.js";
let client: MatrixClient;
const registerModuleUser = (
matrixClient: MatrixClient,
callbackStore: ICallbackStore,
) => {
client = matrixClient;
callbackStore.messageCallbacks.push({
startCondition: `${config.app.triggerPrefix}rank`,
callbackFunc: onRank,
});
callbackStore.messageCallbacks.push({
startCondition: `${config.app.triggerPrefix}leaderboard`,
callbackFunc: onLeaderboard,
});
};
const onRank = (_text: string, roomId: string, sender: string) => {
const rank = getRank(getUserById(sender).experience);
client.sendHtmlMessage(
roomId,
"",
`<h3>Your Rank: ${rank.rank}</h3>
<i>Next rank progress: ${rank.experienceInRank}/${rank.expToNextRank}exp</i>`,
);
};
const onLeaderboard = (_text: string, roomId: string) => {
const mapUsersToLeaderboard = (user: IUser): string => {
const rank = getRank(user.experience);
return `<li>${getUserName(user)}: rank ${rank.rank} (${rank.experienceInRank}/${rank.expToNextRank}exp)</li>`;
};
const users = state.users.sort(
(userA, userB) => userB.experience - userA.experience,
);
client.sendHtmlMessage(
roomId,
"",
`<h3>Leaderboard</h3>
<ul>
${users.map(mapUsersToLeaderboard)}
</ul>`,
);
};
export { registerModuleUser };

2
src/store/index.ts Normal file
View file

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

25
src/store/store.ts Normal file
View file

@ -0,0 +1,25 @@
import config from "../config.json" with { type: "json" };
import { existsSync, readFileSync, writeFileSync } from "fs";
import type { IState } from "./types.js";
let state: IState = {
users: [],
};
const load = () => {
if (!existsSync(config.storePath)) {
return;
}
const json = readFileSync(config.storePath).toString();
state = JSON.parse(json) as IState;
};
const save = () => {
const json = JSON.stringify(state);
writeFileSync(config.storePath, json);
};
export { state, load, save };

14
src/store/types.ts Normal file
View file

@ -0,0 +1,14 @@
interface IState {
users: IUser[];
}
interface IUser {
id: string;
role: TRole;
experience: number;
lastMessageTimestamp: number;
}
type TRole = "NONE" | "USER" | "MODERATOR" | "ADMIN";
export { type IState, type IUser, type TRole };

8
src/types.ts Normal file
View file

@ -0,0 +1,8 @@
interface IRank {
rank: number;
experience: number;
experienceInRank: number;
expToNextRank: number;
}
export { type IRank };