Room stats for ${room} [${month}]
`;
buf += `
Total days with logs: ${stats.average.days}`;
buf += this.visualizeStats(stats.average);
buf += `
`;
buf += `
Stats by day
`;
for (const day of stats.days) {
buf += `${day.day}`;
buf += this.visualizeStats(day);
buf += `
`;
}
buf += "";
return LogViewer.linkify(buf);
}
visualizeStats(stats) {
const titles = {
deadTime: "Average time between lines",
deadPercent: "Average % of the day spent more than 5 minutes inactive",
linesPerUser: "Average lines per user",
averagePresent: "Average users present",
totalLines: "Average lines per day"
};
let buf = `
`;
buf += Object.values(titles).join(" | ");
buf += ` |
---|
`;
for (const k in titles) {
buf += ``;
switch (k) {
case "deadTime":
buf += Chat.toDurationString(stats.deadTime, { precision: 2 });
break;
case "linesPerUser":
case "totalLines":
case "averagePresent":
case "deadPercent":
buf += (stats[k] || 0).toFixed(2);
break;
}
buf += ` | `;
}
buf += `
`;
return buf;
}
}
class FSLogSearcher extends Searcher {
constructor() {
super();
this.results = 0;
}
async searchLinecounts(roomid, month, user) {
const directory = Monitor.logPath(`chat/${roomid}/${month}`);
if (!directory.existsSync()) {
return this.renderLinecountResults(null, roomid, month, user);
}
const files = await directory.readdir();
const results = {};
for (const file of files) {
const day = file.slice(0, -4);
const stream = Monitor.logPath(`chat/${roomid}/${month}/${file}`).createReadStream();
for await (const line of stream.byLine()) {
const parts = line.split("|").map(toID);
const id = parts[2];
if (!id)
continue;
if (parts[1] === "c") {
if (user && id !== user)
continue;
if (!results[day])
results[day] = {};
if (!results[day][id])
results[day][id] = 0;
results[day][id]++;
}
}
}
return this.renderLinecountResults(results, roomid, month, user);
}
async dayStats(room, day) {
const cached = this.roomstatsCache.get(room + "-" + day);
if (cached)
return cached;
const results = {
deadTime: 0,
deadPercent: 0,
lines: {},
users: {},
days: 1,
// irrelevant
linesPerUser: 0,
totalLines: 0,
averagePresent: 0,
day
};
const path = Monitor.logPath(`chat/${room}/${LogReader.getMonth(day)}/${day}.txt`);
if (!path.existsSync())
return false;
const stream = path.createReadStream();
let lastTime = new Date(day).getTime();
let userstatCount = 0;
const waitIncrements = [];
for await (const line of stream.byLine()) {
const [, type, ...rest] = line.split("|");
switch (type) {
case "J":
case "j": {
if (rest[0]?.startsWith("*"))
continue;
const userid = toID(rest[0]);
if (!results.users[userid]) {
results.users[userid] = 0;
}
results.users[userid]++;
break;
}
case "c:":
case "c": {
const { time, username } = LogViewer.parseChatLine(line, day);
const curTime = time.getTime();
if (curTime - lastTime > 5 * 60 * 1e3) {
waitIncrements.push(curTime - lastTime);
lastTime = curTime;
}
const userid = toID(username);
if (!results.lines[userid])
results.lines[userid] = 0;
results.lines[userid]++;
results.totalLines++;
break;
}
case "userstats": {
const [rawTotal] = rest;
const total = parseInt(rawTotal.split(":")[1]);
results.averagePresent += total;
userstatCount++;
break;
}
}
}
results.deadTime = waitIncrements.length ? this.calculateDead(waitIncrements) : 0;
results.deadPercent = !results.totalLines ? 100 : waitIncrements.length / results.totalLines * 100;
results.linesPerUser = results.totalLines / Object.keys(results.users).length || 0;
results.averagePresent /= userstatCount;
if (day !== LogReader.today()) {
this.roomstatsCache.set(room + "-" + day, results);
}
return results;
}
calculateDead(waitIncrements) {
let num = 0;
for (const k of waitIncrements) {
num += k;
}
return num / waitIncrements.length;
}
async activityStats(room, month) {
const days = (await Monitor.logPath(`chat/${room}/${month}`).readdir()).map((f) => f.slice(0, -4));
const stats = [];
const today = Chat.toTimestamp(new Date()).split(" ")[0];
for (const day of days) {
if (day === today) {
continue;
}
const curStats = await this.dayStats(room, day);
if (!curStats)
continue;
stats.push(curStats);
}
const collected = {
deadTime: 0,
deadPercent: 0,
lines: {},
users: {},
days: days.length,
linesPerUser: 0,
totalLines: 0,
averagePresent: 0
};
for (const entry of stats) {
for (const k of ["deadTime", "deadPercent", "linesPerUser", "totalLines", "averagePresent"]) {
collected[k] += entry[k];
}
for (const type of ["lines"]) {
for (const k in entry[type]) {
if (!collected[type][k])
collected[type][k] = 0;
collected[type][k] += entry[type][k];
}
}
}
for (const k of ["deadTime", "deadPercent", "linesPerUser", "totalLines", "averagePresent"]) {
collected[k] /= stats.length;
}
return { average: collected, days: stats };
}
}
class RipgrepLogSearcher extends FSLogSearcher {
async ripgrepSearchMonth(opts) {
const { search, room: roomid, date: month, args } = opts;
let results;
let lineCount = 0;
if (Config.disableripgrep) {
return { lineCount: 0, results: [] };
}
const resultSep = args?.includes("-m") ? "--" : "\n";
try {
const options = [
"-e",
search,
Monitor.logPath(`chat/${roomid}/${month}`).path,
"-i"
];
if (args) {
options.push(...args);
}
const { stdout } = await import_lib.ProcessManager.exec(["rg", ...options], {
maxBuffer: MAX_MEMORY,
cwd: import_lib.FS.ROOT_PATH
});
results = stdout.split(resultSep);
} catch (e) {
if (e.code !== 1 && !e.message.includes("stdout maxBuffer") && !e.message.includes("No such file or directory")) {
throw e;
}
if (e.stdout) {
results = e.stdout.split(resultSep);
} else {
results = [];
}
}
lineCount += results.length;
return { results, lineCount };
}
async searchLinecounts(room, month, user) {
const regexString = (user ? `\\|c\\|${this.constructUserRegex(user)}\\|` : `\\|c\\|([^|]+)\\|`) + `(?!\\/uhtml(change)?)`;
const args = user ? ["--count"] : [];
args.push(`--pcre2`);
const { results: rawResults } = await this.ripgrepSearchMonth({
search: regexString,
raw: true,
date: month,
room,
args
});
const results = {};
for (const fullLine of rawResults) {
const [data, line] = fullLine.split(".txt:");
const date = data.split("/").pop();
if (!results[date])
results[date] = {};
if (!toID(date))
continue;
if (user) {
if (!results[date][user])
results[date][user] = 0;
const parsed = parseInt(line);
results[date][user] += isNaN(parsed) ? 0 : parsed;
} else {
const parts = line?.split("|").map(toID);
if (!parts || parts[1] !== "c")
continue;
const id = parts[2];
if (!id)
continue;
if (!results[date][id])
results[date][id] = 0;
results[date][id]++;
}
}
return this.renderLinecountResults(results, room, month, user);
}
}
class DatabaseLogSearcher extends Searcher {
async searchLinecounts(roomid, month, user) {
user = toID(user);
if (!Rooms.Roomlogs.table)
throw new Error(`Database search made while database is disabled.`);
const results = {};
const [monthStart, monthEnd] = LogReader.monthToRange(month);
const rows = await Rooms.Roomlogs.table.selectAll()`
WHERE ${user ? import_database.SQL`userid = ${user} AND ` : import_database.SQL``}roomid = ${roomid} AND
time BETWEEN ${monthStart}::int::timestamp AND ${monthEnd}::int::timestamp AND
type = ${"c"}
`;
for (const row of rows) {
if (!row.userid)
continue;
const day = Chat.toTimestamp(row.time).split(" ")[0];
if (!results[day])
results[day] = {};
if (!results[day][row.userid])
results[day][row.userid] = 0;
results[day][row.userid]++;
}
return this.renderLinecountResults(results, roomid, month, user);
}
activityStats(room, month) {
throw new Chat.ErrorMessage("This is not yet implemented for the new logs database.");
}
}
const LogSearcher = new (Rooms.Roomlogs.table ? DatabaseLogSearcher : (
// no db, determine fs reader type.
Config.chatlogreader === "ripgrep" ? RipgrepLogSearcher : FSLogSearcher
))();
const accessLog = Monitor.logPath(`chatlog-access.txt`).createAppendStream();
const pages = {
async chatlog(args, user, connection) {
if (!user.named)
return Rooms.RETRY_AFTER_LOGIN;
let [roomid, date, opts] = import_lib.Utils.splitFirst(args.join("-"), "--", 2);
if (!roomid || roomid.startsWith("-")) {
this.title = "[Logs]";
return LogViewer.list(user, roomid?.slice(1));
}
this.title = "[Logs] " + roomid;
const room = Rooms.get(roomid);
if (!user.trusted) {
if (room) {
this.checkCan("declare", null, room);
} else {
return this.errorReply(`Access denied.`);
}
}
if (!user.can("rangeban")) {
if (roomid.startsWith("spl") && roomid !== "splatoon") {
return this.errorReply("SPL team discussions are super secret.");
}
if (roomid.startsWith("wcop")) {
return this.errorReply("WCOP team discussions are super secret.");
}
if (UPPER_STAFF_ROOMS.includes(roomid) && !user.inRooms.has(roomid)) {
return this.errorReply("Upper staff rooms are super secret.");
}
}
if (room) {
if (!user.can("lock") || room.settings.isPrivate === "hidden" && !room.checkModjoin(user)) {
if (!room.persist)
return this.errorReply(`Access denied.`);
this.checkCan("mute", null, room);
}
} else {
this.checkCan("lock");
}
void accessLog.writeLine(`${user.id}: <${roomid}> ${date}`);
if (!date) {
return LogViewer.room(roomid);
}
date = date.trim();
let search;
const parsedDate = new Date(date);
const validDateStrings = ["all", "alltime"];
const validNonDateTerm = search ? validDateStrings.includes(date) : date === "today";
if (isNaN(parsedDate.getTime()) && !validNonDateTerm) {
return this.errorReply(`Invalid date.`);
}
const isTime = opts?.startsWith("time-");
if (isTime && opts)
opts = toID(opts.slice(5));
if (search) {
Searcher.checkEnabled();
this.checkCan("bypassall");
return LogSearcher.runSearch();
} else {
if (date === "today") {
this.setHTML(await LogViewer.day(roomid, LogReader.today(), opts));
if (isTime)
this.send(`|scroll|div[data-server="${opts}"]`);
} else if (date.split("-").length === 3) {
this.setHTML(await LogViewer.day(roomid, parsedDate.toISOString().slice(0, 10), opts));
if (isTime)
this.send(`|scroll|div[data-server="${opts}"]`);
} else {
return LogViewer.month(roomid, parsedDate.toISOString().slice(0, 7));
}
}
},
roomstats(args, user) {
Searcher.checkEnabled();
const room = this.extractRoom();
if (room) {
this.checkCan("mute", null, room);
} else {
if (!user.can("bypassall")) {
return this.errorReply(`You cannot view logs for rooms that no longer exist.`);
}
}
const [, date, target] = import_lib.Utils.splitFirst(args.join("-"), "--", 3).map((item) => item.trim());
if (isNaN(new Date(date).getTime())) {
return this.errorReply(`Invalid date.`);
}
if (!LogReader.isMonth(date)) {
return this.errorReply(`You must specify an exact month - both a year and a month.`);
}
this.title = `[Log Stats] ${date}`;
return LogSearcher.runLinecountSearch(this, room ? room.roomid : args[2], date, toID(target));
},
async logsaccess(query) {
this.checkCan("rangeban");
const type = toID(query.shift());
if (type && !["chat", "battle", "all", "battles"].includes(type)) {
return this.errorReply(`Invalid log type.`);
}
let title = "";
switch (type) {
case "battle":
case "battles":
title = "Battlelog access log";
break;
case "chat":
title = "Chatlog access log";
break;
default:
title = "Logs access log";
break;
}
const userid = toID(query.shift());
let buf = `
${title}`;
if (userid)
buf += ` for ${userid}`;
buf += `
`;
const accessStream = Monitor.logPath(`chatlog-access.txt`).createReadStream();
for await (const line of accessStream.byLine()) {
const [id, rest] = import_lib.Utils.splitFirst(line, ": ");
if (userid && id !== userid)
continue;
if (type === "battle" && !line.includes("battle-"))
continue;
if (userid) {
buf += `- ${rest}
`;
} else {
buf += `- ${id}: ${rest}
`;
}
}
buf += `
`;
return buf;
},
roominfo(query, user) {
this.checkCan("rangeban");
const args = import_lib.Utils.splitFirst(query.join("-"), "--", 2);
const roomid = toID(args.shift());
if (!roomid) {
return this.errorReply(`Specify a room.`);
}
const date = args.shift() || LogReader.getMonth();
this.title = `[${roomid}] Activity Stats (${date})`;
this.setHTML(`
Collecting stats for ${roomid} in ${date}...
`);
return LogSearcher.roomStats(roomid, date);
}
};
const commands = {
chatlogs: "chatlog",
cl: "chatlog",
roomlog: "chatlog",
rl: "chatlog",
roomlogs: "chatlog",
chatlog(target, room, user) {
const [tarRoom, ...opts] = target.split(",");
const targetRoom = tarRoom ? Rooms.search(tarRoom) : room;
const roomid = targetRoom ? targetRoom.roomid : target;
return this.parse(`/join view-chatlog-${roomid}--today${opts ? `--${opts.map(toID).join("--")}` : ""}`);
},
chatloghelp() {
const strings = [
`/chatlog [optional room], [opts] - View chatlogs from the given room. `,
`If none is specified, shows logs from the room you're in. Requires: % @ * # ~`,
`Supported options:`,
`
txt
- Do not render logs.`,
`
txt-onlychat
- Show only chat lines, untransformed.`,
`
onlychat
- Show only chat lines.`,
`
all
- Show all lines, including userstats and join/leave messages.`
];
this.runBroadcast();
return this.sendReplyBox(strings.join("
"));
},
sl: "searchlogs",
logsearch: "searchlogs",
searchlog: "searchlogs",
searchlogs(target, room) {
target = target.trim();
const args = target.split(",").map((item) => item.trim());
if (!target)
return this.parse("/help searchlogs");
let date = "all";
const searches = [];
let limit = "500";
let targetRoom = room?.roomid;
for (const arg of args) {
if (arg.startsWith("room=")) {
targetRoom = arg.slice(5).trim().toLowerCase();
} else if (arg.startsWith("limit=")) {
limit = arg.slice(6);
} else if (arg.startsWith("date=")) {
date = arg.slice(5);
} else if (arg.startsWith("user=")) {
args.push(`user-${toID(arg.slice(5))}`);
} else {
searches.push(arg);
}
}
if (!targetRoom) {
return this.parse(`/help searchlogs`);
}
return this.parse(
`/join view-chatlog-${targetRoom}--${date}--search-${import_lib.Dashycode.encode(searches.join("+"))}--limit-${limit}`
);
},
searchlogshelp() {
const buffer = `
/searchlogs [arguments]
: searches logs in the current room using the [arguments]
.
A room can be specified using the argument room=[roomid]
. Defaults to the room it is used in.
A limit can be specified using the argument limit=[number less than or equal to 3000]
. Defaults to 500.
A date can be specified in ISO (YYYY-MM-DD) format using the argument date=[month]
(for example, date: 2020-05
). Defaults to searching all logs.
If you provide a user argument in the form user=username
, it will search for messages (that match the other arguments) only from that user.
All other arguments will be considered part of the search (if more than one argument is specified, it searches for lines containing all terms).
Requires: ~`;
return this.sendReplyBox(buffer);
},
topusers: "linecount",
roomstats: "linecount",
linecount(target, room, user) {
const params = target.split(",").map((f) => f.trim());
const search = {};
for (const [i, param] of params.entries()) {
let [key, val] = param.split("=");
if (!val) {
switch (i) {
case 0:
val = key;
key = "room";
break;
case 1:
val = key;
key = "date";
break;
case 2:
val = key;
key = "user";
break;
default:
return this.parse(`/help linecount`);
}
}
if (!toID(val))
continue;
key = key.toLowerCase().replace(/ /g, "");
switch (key) {
case "room":
case "roomid":
const tarRoom = Rooms.search(val);
if (!tarRoom) {
return this.errorReply(`Room '${val}' not found.`);
}
search.roomid = tarRoom.roomid;
break;
case "user":
case "id":
case "userid":
search.user = toID(val);
break;
case "date":
case "month":
case "time":
if (!LogReader.isMonth(val)) {
return this.errorReply(`Invalid date.`);
}
search.date = val;
}
}
if (!search.roomid) {
if (!room) {
return this.errorReply(`If you're not specifying a room, you must use this command in a room.`);
}
search.roomid = room.roomid;
}
if (!search.date) {
search.date = LogReader.getMonth();
}
return this.parse(`/join view-roomstats-${search.roomid}--${search.date}${search.user ? `--${search.user}` : ""}`);
},
linecounthelp() {
return this.sendReplyBox(
`
/linecount OR /roomstats OR /topusers
[
key=value
formatted parameters] - Searches linecounts with the given parameters.
Parameters:
- room
(aliases: roomid
) - Select a room to search. If no room is given, defaults to current room.
- date
(aliases: month
, time
) - Select a month to search linecounts on (requires YYYY-MM format). Defaults to current month.
- user
(aliases: id
, userid
) - Searches for linecounts only from a given user. If this is not provided, /linecount instead shows line counts for all users from that month.Parameters may also be specified without a [key]. When using this, arguments are provided in the format
/linecount [room], [month], [user].
. This does not use any defaults.
`
);
},
battlelog(target, room, user) {
this.checkCan("lock");
target = target.trim();
if (!target)
return this.errorReply(`Specify a battle.`);
if (target.startsWith("http://"))
target = target.slice(7);
if (target.startsWith("https://"))
target = target.slice(8);
if (target.startsWith(`${Config.routes.client}/`))
target = target.slice(Config.routes.client.length + 1);
if (target.startsWith(`${Config.routes.replays}/`))
target = `battle-${target.slice(Config.routes.replays.length + 1)}`;
if (target.startsWith("psim.us/"))
target = target.slice(8);
return this.parse(`/join view-battlelog-${target}`);
},
battleloghelp: [
`/battlelog [battle link] - View the log of the given [battle link], even if the replay was not saved.`,
`Requires: % @ ~`
],
gbc: "getbattlechat",
async getbattlechat(target, room, user) {
this.checkCan("lock");
let [roomName, userName] = import_lib.Utils.splitFirst(target, ",").map((f) => f.trim());
if (!roomName) {
if (!room) {
return this.errorReply(`If you are not specifying a room, use this command in a room.`);
}
roomName = room.roomid;
}
if (roomName.startsWith("http://"))
roomName = roomName.slice(7);
if (roomName.startsWith("https://"))
roomName = roomName.slice(8);
if (roomName.startsWith(`${Config.routes.client}/`)) {
roomName = roomName.slice(Config.routes.client.length + 1);
}
if (roomName.startsWith(`${Config.routes.replays}/`)) {
roomName = `battle-${roomName.slice(Config.routes.replays.length + 1)}`;
}
if (roomName.startsWith("psim.us/"))
roomName = roomName.slice(8);
const queryStringStart = roomName.indexOf("?");
if (queryStringStart > -1) {
roomName = roomName.slice(0, queryStringStart);
}
const roomid = roomName.toLowerCase().replace(/[^a-z0-9-]+/g, "");
if (!roomid)
return this.parse("/help getbattlechat");
const userid = toID(userName);
if (userName && !userid)
return this.errorReply(`Invalid username.`);
if (!roomid.startsWith("battle-"))
return this.errorReply(`You must specify a battle.`);
const tarRoom = Rooms.get(roomid);
let log;
if (tarRoom) {
log = tarRoom.log.log;
} else if (Rooms.Replays.db) {
let battleId = roomid.replace("battle-", "");
if (battleId.endsWith("pw")) {
battleId = battleId.slice(0, battleId.lastIndexOf("-", battleId.length - 2));
}
const replayData = await Rooms.Replays.get(battleId);
if (!replayData) {
return this.errorReply(`No room or replay found for that battle.`);
}
log = replayData.log.split("\n");
} else {
try {
const raw = await (0, import_lib.Net)(`https://${Config.routes.replays}/${roomid.slice("battle-".length)}.json`).get();
const data = JSON.parse(raw);
log = data.log ? data.log.split("\n") : [];
} catch {
return this.errorReply(`No room or replay found for that battle.`);
}
}
log = log.filter((l) => l.startsWith("|c|"));
let buf = "";
let atLeastOne = false;
let i = 0;
for (const line of log) {
const [, , username, message] = import_lib.Utils.splitFirst(line, "|", 3);
if (userid && toID(username) !== userid)
continue;
i++;
buf += import_lib.Utils.html`
${username}: ${message}
`;
atLeastOne = true;
}
if (i > 20)
buf = `
${buf} `;
if (!atLeastOne)
buf = `
None found.`;
this.runBroadcast();
return this.sendReplyBox(
import_lib.Utils.html`
Chat messages in the battle '${roomid}'` + (userid ? `from the user '${userid}'` : "") + `` + buf
);
},
getbattlechathelp: [
`/getbattlechat [battle link][, username] - Gets all battle chat logs from the given [battle link].`,
`If a [username] is given, searches only chat messages from the given username.`,
`Requires: % @ ~`
],
logsaccess(target, room, user) {
this.checkCan("rangeban");
const [type, userid] = target.split(",").map(toID);
return this.parse(`/j view-logsaccess-${type || "all"}${userid ? `-${userid}` : ""}`);
},
logsaccesshelp: [
`/logsaccess [type], [user] - View chatlog access logs for the given [type] and [user].`,
`If no arguments are given, shows the entire access log.`,
`Requires: ~`
],
gcsearch: "groupchatsearch",
async groupchatsearch(target, room, user) {
this.checkCan("lock");
target = target.toLowerCase().replace(/[^a-z0-9-]+/g, "");
if (!target)
return this.parse(`/help groupchatsearch`);
if (target.length < 3) {
return this.errorReply(`Too short of a search term.`);
}
const files = await Monitor.logPath(`chat`).readdir();
const buffer = [];
for (const roomid of files) {
if (roomid.startsWith("groupchat-") && roomid.includes(target)) {
buffer.push(roomid);
}
}
import_lib.Utils.sortBy(buffer, (roomid) => !!Rooms.get(roomid));
return this.sendReplyBox(
`Groupchats with a roomid matching '${target}': ` + (buffer.length ? buffer.map((id) => `
${id}`).join("; ") : "None found.")
);
},
groupchatsearchhelp: [
`/groupchatsearch [target] - Searches for logs of groupchats with names containing the [target]. Requires: % @ ~`
],
roomact: "roomactivity",
roomactivity(target, room, user) {
this.checkCan("bypassall");
const [id, date] = target.split(",").map((i) => i.trim());
if (id)
room = Rooms.search(toID(id));
if (!room)
return this.errorReply(`Either use this command in the target room or specify a room.`);
return this.parse(`/join view-roominfo-${room}${date ? `--${date}` : ""}`);
},
roomactivityhelp: [
`/roomactivity [room][, date] - View room activity logs for the given room.`,
`If a date is provided, it searches for logs from that date. Otherwise, it searches the current month.`,
`Requires: ~`
]
};
//# sourceMappingURL=chatlog.js.map