Skip to content

Commit 53daecc

Browse files
authored
Merge pull request #7 from ItsAlexIK/pr-6
Pr 6
2 parents 9041a82 + f97a652 commit 53daecc

3 files changed

Lines changed: 320 additions & 1 deletion

File tree

.github/assets/pidinfo.png

56.1 KB
Loading

README.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ A lightweight Discord bot designed to monitor system stats from a Raspberry Pi 5
1414
- 💾 Disk space used
1515
- ⏱️ System uptime
1616
- `/htop` command to display an interactive process list directly in Discord (like the Linux `htop`)
17+
- `/pidinfo` command to display detailed PID information (PID, PPID, user, CPU %, MEM %, uptime, command)
1718

1819
![Status](.github/assets/status.png)
1920

@@ -30,8 +31,23 @@ Use the `/htop` command to view a live list of the most resource-intensive proce
3031
- Output is styled to be Discord-friendly and readable
3132

3233
![Active processes](.github/assets/htop-preview.png)
34+
## 🔍 `/pidinfo` Command
3335

34-
## 🛡️ Permission Restriction for `/htop` Command
36+
Use the `/pidinfo` command to inspect a specific process by PID.
37+
38+
- Displays:
39+
- 🔹 PID
40+
- 🔗 PPID
41+
- 👤 User
42+
- 🖥️ CPU usage
43+
- 📊 Memory usage
44+
- ⏱️ Uptime
45+
- 📝 Command
46+
47+
48+
![PID Info](.github/assets/pidinfo.png)
49+
50+
## 🛡️ Permission Restriction for `/htop` and `/pidinfo` Commands
3551

3652
The `/htop` command is restricted to a specific Discord user ID for security reasons. Only the designated user can run this command to view system processes.
3753

src/commands/pidinfo.js

Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
const {
2+
SlashCommandBuilder,
3+
EmbedBuilder,
4+
MessageFlags,
5+
} = require("discord.js");
6+
const { execFile } = require("node:child_process");
7+
const { promisify } = require("node:util");
8+
const fs = require("node:fs/promises");
9+
const os = require("node:os");
10+
11+
const ALLOWED_USER_ID = process.env.ALLOWED_USER_ID || "";
12+
const ALLOWED_ROLE_ID = process.env.ALLOWED_ROLE_ID || "";
13+
const execFileAsync = promisify(execFile);
14+
15+
if (!ALLOWED_USER_ID && !ALLOWED_ROLE_ID) {
16+
throw new Error(
17+
"PIDInfo command is disabled: ALLOWED_USER_ID and ALLOWED_ROLE_ID are not set."
18+
);
19+
}
20+
21+
function truncate(text, max = 1000) {
22+
return text.length <= max ? text : text.slice(0, max - 3) + "...";
23+
}
24+
25+
function formatEtime(etime) {
26+
// Handle common ps/busybox shapes; fall back to raw if unknown
27+
const patterns = [
28+
/^(?:(\d+)-)?(\d{1,2}):(\d{2}):(\d{2})$/, // dd-hh:mm:ss or hh:mm:ss
29+
/^(\d+)d(\d{1,2})(?::(\d{2}))?(?::(\d{2}))?$/, // 2d03:04:09 or 2d03
30+
/^(\d+)d(\d{1,2})h(?:(\d{1,2})m)?(?:(\d{1,2})s)?$/, // 2d03h04m09s
31+
/^(\d{1,2}):(\d{2})$/,
32+
/^(\d+)d(\d{1,2})$/, // 2d03 (days + hours only)
33+
];
34+
35+
for (const re of patterns) {
36+
const m = etime.match(re);
37+
if (!m) continue;
38+
const [, d = "0", h = "0", m1 = "0", s = "0"] = m;
39+
const days = Number(d || 0);
40+
const hours = Number(h || 0);
41+
const mins = Number(m1 || 0);
42+
const secs = Number(s || 0);
43+
44+
const parts = [];
45+
if (days) parts.push(`${days}d`);
46+
if (hours || days) parts.push(`${hours}h`);
47+
if (mins || hours || days) parts.push(`${mins}m`);
48+
parts.push(`${secs}s`);
49+
return parts.join(" ");
50+
}
51+
52+
return etime;
53+
}
54+
55+
async function fetchPidInfo(pid) {
56+
try {
57+
const { stdout } = await execFileAsync("ps", [
58+
"-p",
59+
String(pid),
60+
"-o",
61+
"pid,ppid,user,%cpu,%mem,etime,cmd",
62+
]);
63+
64+
const lines = stdout.trim().split("\n");
65+
if (lines.length < 2) {
66+
throw new Error("PID not found");
67+
}
68+
69+
const line = lines[1].trim();
70+
const match = line.match(
71+
/^(\d+)\s+(\d+)\s+(\S+)\s+([\d.]+)\s+([\d.]+)\s+(\S+)\s+(.+)$/
72+
);
73+
74+
if (!match) {
75+
throw new Error(`Unable to parse ps output: "${truncate(line, 200)}"`);
76+
}
77+
78+
const [, parsedPid, ppid, user, cpu, mem, etime, cmd] = match;
79+
80+
return {
81+
pid: Number(parsedPid),
82+
ppid: Number(ppid),
83+
user,
84+
cpu: Number(cpu),
85+
mem: Number(mem),
86+
etime,
87+
cmd,
88+
};
89+
} catch (err) {
90+
const stderr = err?.stderr || "";
91+
const busyboxPs =
92+
/unrecognized option: p/i.test(stderr) || /BusyBox/i.test(stderr);
93+
if (!busyboxPs) throw err;
94+
return fetchPidInfoBusybox(pid);
95+
}
96+
}
97+
98+
async function fetchPidInfoBusybox(pid) {
99+
// BusyBox ps lacks -p and has limited columns; pull all and filter
100+
const { stdout } = await execFileAsync("ps", [
101+
"-o",
102+
"pid,ppid,user,etime,time,stat,args",
103+
]);
104+
105+
const lines = stdout.trim().split("\n");
106+
if (lines.length < 2) {
107+
throw new Error("PID not found");
108+
}
109+
110+
const rows = lines.slice(1);
111+
const row = rows.find((line) => line.trim().startsWith(`${pid} `));
112+
if (!row) {
113+
throw new Error("PID not found");
114+
}
115+
116+
const match = row
117+
.trim()
118+
.match(/^(\d+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(.+)$/);
119+
120+
if (!match) {
121+
throw new Error(
122+
`Unable to parse ps output for PID ${pid}: ${truncate(row, 200)}`
123+
);
124+
}
125+
126+
const [, parsedPid, ppid, user, etime, cputime, _stat, cmd] = match;
127+
128+
const { cpu, mem } = await readProcUsage(pid);
129+
130+
return {
131+
pid: Number(parsedPid),
132+
ppid: Number(ppid),
133+
user,
134+
cpu,
135+
mem,
136+
etime,
137+
cmd,
138+
};
139+
}
140+
141+
async function readProcUsage(pid) {
142+
const [cpu, mem] = await Promise.all([
143+
sampleCpuPercent(pid).catch(() => 0),
144+
sampleMemPercent(pid).catch(() => 0),
145+
]);
146+
return { cpu, mem };
147+
}
148+
149+
async function sampleCpuPercent(pid, delayMs = 250) {
150+
const [total1, proc1] = await Promise.all([
151+
readTotalJiffies(),
152+
readProcJiffies(pid),
153+
]);
154+
await sleep(delayMs);
155+
const [total2, proc2] = await Promise.all([
156+
readTotalJiffies(),
157+
readProcJiffies(pid),
158+
]);
159+
160+
const totalDelta = total2.total - total1.total;
161+
const procDelta = proc2.total - proc1.total;
162+
if (totalDelta > 0 && procDelta >= 0) {
163+
const pct = (procDelta / totalDelta) * 100;
164+
if (pct > 0) return pct;
165+
}
166+
167+
// Fallback to lifetime average if instantaneous delta is zero
168+
const uptime = await readUptimeSeconds();
169+
const procTicks = proc2.total;
170+
const startTicks = proc2.start;
171+
const clkTck = getClkTck();
172+
const elapsed = Math.max(0.01, uptime - startTicks / clkTck);
173+
return (procTicks / clkTck / elapsed) * 100;
174+
}
175+
176+
async function sampleMemPercent(pid) {
177+
const status = await fs.readFile(`/proc/${pid}/status`, "utf8");
178+
const meminfo = await fs.readFile("/proc/meminfo", "utf8");
179+
180+
const rssLine = status.split("\n").find((l) => l.startsWith("VmRSS:"));
181+
if (!rssLine) return 0;
182+
const rssKb = Number(rssLine.replace(/[^\d]/g, ""));
183+
if (!rssKb) return 0;
184+
185+
const totalLine = meminfo.split("\n").find((l) => l.startsWith("MemTotal:"));
186+
if (!totalLine) return 0;
187+
const totalKb = Number(totalLine.replace(/[^\d]/g, ""));
188+
if (!totalKb) return 0;
189+
190+
return (rssKb / totalKb) * 100;
191+
}
192+
193+
async function readTotalJiffies() {
194+
const firstLine = (await fs.readFile("/proc/stat", "utf8")).split("\n")[0];
195+
const parts = firstLine.trim().split(/\s+/).slice(1);
196+
return { total: parts.reduce((sum, val) => sum + Number(val || 0), 0) };
197+
}
198+
199+
async function readProcJiffies(pid) {
200+
const stat = await fs.readFile(`/proc/${pid}/stat`, "utf8");
201+
202+
// /proc/[pid]/stat format:
203+
// pid (comm) state ppid ... utime stime ... starttime ...
204+
// The comm field is in parentheses and can contain spaces, so we must
205+
// locate the closing ')' and only then split the remainder on whitespace.
206+
const endComm = stat.lastIndexOf(")");
207+
if (endComm === -1) {
208+
return { total: 0, start: 0 };
209+
}
210+
211+
const after = stat.slice(endComm + 1).trim().split(/\s+/);
212+
// After removing "pid (comm)", field indices shift by 2 positions.
213+
// Field 3 (state) becomes after[0], so:
214+
// - utime (field 14) -> after[11]
215+
// - stime (field 15) -> after[12]
216+
// - starttime (field 22) -> after[19]
217+
const utime = Number(after[11] || 0);
218+
const stime = Number(after[12] || 0);
219+
const start = Number(after[19] || 0);
220+
return { total: utime + stime, start };
221+
}
222+
223+
async function readUptimeSeconds() {
224+
const content = await fs.readFile("/proc/uptime", "utf8");
225+
const seconds = Number(content.split(/\s+/)[0] || 0);
226+
return seconds;
227+
}
228+
229+
function getClkTck() {
230+
const env = Number(process.env.CLOCK_TICK_RATE || 0);
231+
if (env > 0) return env;
232+
const osTicks = os.constants?.clockTicks;
233+
if (typeof osTicks === "number" && osTicks > 0) return osTicks;
234+
return 100; // common default
235+
}
236+
237+
function sleep(ms) {
238+
return new Promise((resolve) => setTimeout(resolve, ms));
239+
}
240+
241+
function buildEmbed(info) {
242+
return new EmbedBuilder()
243+
.setTitle(`ℹ️ Process ${info.pid}`)
244+
.addFields(
245+
{ name: "PID", value: `${info.pid}`, inline: true },
246+
{ name: "PPID", value: `${info.ppid}`, inline: true },
247+
{ name: "User", value: info.user, inline: true },
248+
{ name: "CPU %", value: info.cpu.toFixed(1), inline: true },
249+
{ name: "MEM %", value: info.mem.toFixed(1), inline: true },
250+
{ name: "Uptime", value: formatEtime(info.etime), inline: true },
251+
{ name: "Command", value: truncate(info.cmd) }
252+
)
253+
.setColor(0x2b2d31)
254+
.setTimestamp();
255+
}
256+
257+
module.exports = {
258+
data: new SlashCommandBuilder()
259+
.setName("pidinfo")
260+
.setDescription("🔎 Show detailed info for a PID")
261+
.addIntegerOption((option) =>
262+
option
263+
.setName("pid")
264+
.setDescription("Process ID to inspect")
265+
.setRequired(true)
266+
.setMinValue(1)
267+
.setMaxValue(2147483647)
268+
),
269+
270+
async execute(interaction) {
271+
const allowedByUser =
272+
ALLOWED_USER_ID && interaction.user.id === ALLOWED_USER_ID;
273+
274+
const allowedByRole =
275+
ALLOWED_ROLE_ID &&
276+
interaction.inGuild() &&
277+
interaction.member?.roles?.cache.has(ALLOWED_ROLE_ID);
278+
279+
if (!allowedByUser && !allowedByRole) {
280+
return interaction.reply({
281+
content: "❌ You do not have permission to use this command.",
282+
flags: MessageFlags.Ephemeral,
283+
});
284+
}
285+
const pid = interaction.options.getInteger("pid", true);
286+
287+
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
288+
289+
try {
290+
const info = await fetchPidInfo(pid);
291+
const embed = buildEmbed(info);
292+
await interaction.editReply({ embeds: [embed] });
293+
} catch (err) {
294+
console.error("Error in /pidinfo command:", err);
295+
const message =
296+
err?.message === "PID not found"
297+
? "❌ No process found for that PID."
298+
: "❌ Failed to fetch PID info.";
299+
300+
await interaction.editReply({ content: message });
301+
}
302+
},
303+
};

0 commit comments

Comments
 (0)