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+ / u n r e c o g n i z e d o p t i o n : p / i. test ( stderr ) || / B u s y B o x / 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