import { exec } from 'node:child_process'; import { readFile, writeFile } from 'node:fs/promises'; import { overrideConsole } from 'nodejs-better-console'; import Eris from 'eris'; import { Command, Argument } from 'commander'; import KeyEvent from 'keyevent.json' assert { type: 'json' }; import { M3uParser } from 'm3u-parser-generator'; import { parse as parseXspf } from 'xspf-js'; import outdent from 'outdent'; import AsciiTable from 'ascii-table'; import dayjs from 'dayjs'; import { parse } from 'shell-quote'; import { botToken, botWhitelist, adbIp, adbPort, localMediaRootPath } from './config.js'; overrideConsole(); const eris = new Eris( botToken, { intents: ['directMessages'] } ), adb = command => new Promise((resolve, reject) => exec( `adb -s ${adbIp}:${adbPort} shell "${command.replaceAll('"', '\\"')}"`, ( error, stdout, stderr ) => { if(error || stderr) reject(error || stderr); else resolve(stdout.replace(/\r/g, '').trim()); } )), getConfig = async id => { try { return JSON.parse(await readFile(`./data/${id}.json`, 'utf8')); } catch { return {}; } }, setConfig = async (id, config) => await writeFile(`./data/${id}.json`, JSON.stringify(config)), parseMediaUrl = url => { const pathname = decodeURI(new URL(url).pathname), title = pathname .slice(pathname.lastIndexOf('/') + 1, pathname.lastIndexOf('.')) .replace(/[\s\-._()&+\[\],]/g, ' ') .replace(/\s+/g, ' '); return { url, title, keywords: title.toLowerCase().split(' ') }; }, handleCommand = async ({ content, authorID, attachments }) => { content = `${content[0].toLowerCase()}${content.slice(1)}`; const program = new Command(''); let response; program.exitOverride(); program.configureOutput({ writeOut: output => response = { output, isRaw: true }, writeErr: output => response = { output, isRaw: true } }); program .command('shell') .description('Execute arbitrary shell command') .argument('', 'Arbitrary shell command') .action(async command => { response = { output: await adb(command), isRaw: true } }); program .command('home') .alias('h') .description('Go to home') .action(() => adb(`input keyevent ${KeyEvent.KEYCODE_HOME}`)); program .command('back') .alias('b') .description('Go back') .action(() => adb(`input keyevent ${KeyEvent.KEYCODE_BACK}`)); program .command('p') .description('Play/pause media') .action(() => adb(`input keyevent ${KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE}`)); program .command('play') .description('Play media') .action(() => adb(`input keyevent ${KeyEvent.KEYCODE_MEDIA_PLAY}`)); program .command('pause') .description('Pause media') .action(() => adb(`input keyevent ${KeyEvent.KEYCODE_MEDIA_PAUSE}`)); program .command('prev') .description('Previous media') .action(() => adb(`input keyevent ${KeyEvent.KEYCODE_MEDIA_PREVIOUS}`)); program .command('next') .alias('n') .description('Next media') .action(() => adb(`input keyevent ${KeyEvent.KEYCODE_MEDIA_NEXT}`)); program .command('type') .description('Type text') .argument('', 'Text to type') .action(text => adb(`input text ${text}`)); program .command('press') .description('Press key') .addArgument( new Argument('', 'Key to press') .choices(['tab', 'enter']) ) .action(key => adb(`input keyevent ${{ 'tab': KeyEvent.KEYCODE_TAB, 'enter': KeyEvent.KEYCODE_ENTER }[key]}`)); program .command('spotify') .description('Launch Spotify') .action(() => adb('monkey -p com.spotify.music 1')); program .command('mpv') .description('Play media on MPV') .argument('', 'Media URL') .action(url => adb(`am start -a android.intent.action.VIEW -d ${url} -t video/any is.xyz.mpv`)); program .command('playlist') .alias('pl') .description('Manage playlists & play on MPV') .argument('[name]', 'Playlist name') .argument('[media]', 'Playlist media index/title/partial title') .action(async (name, media) => { const config = await getConfig(authorID) || {}, newPlaylistUrl = attachments[0]?.url; let { playlists } = config; if(!playlists) config.playlists = playlists = []; if(!name){ if(!playlists.length) return response = { embed: { title: `❌ No existing playlist` } }; return response = { embed: { title: `ℹ️ Your playlists :`, description: playlists.map(playlist => `- [${playlist.name}](${playlist.url})`).join('\n') } }; } let playlist = playlists.find(playlist => playlist.name === name); if(newPlaylistUrl){ if(!/\.(m3u8?|xspf)$/i.test(new URL(newPlaylistUrl).pathname)) return response = { embed: { title: `❌ Invalid playlist` } }; response = { embed: { title: `✅ Playlist updated` } }; if(!playlist){ playlists.push(playlist = { name }); response = { embed: { title: `✅ Playlist created` } }; } playlist.url = newPlaylistUrl; return setConfig(authorID, config); } if(!playlist) return response = { embed: { title: `❌ Playlist not found` } }; const { pathname: playlistPathname } = new URL(playlist.url); let rawPlaylistData = await (await fetch(playlist.url)).text(), playlistItems; if(/\.m3u8?$/i.test(playlistPathname)){ if(!rawPlaylistData.startsWith('#EXTM3U')) rawPlaylistData = `#EXTM3U\n${rawPlaylistData}`; playlistItems = M3uParser.parse(rawPlaylistData).medias.map(item => parseMediaUrl(item['location'])); } if(/\.xspf?$/i.test(playlistPathname)) playlistItems = parseXspf(rawPlaylistData)['playlist']['tracks'].map(item => parseMediaUrl(item['location'])); if(!media) return response = { output: outdent ` 📀 ${playlist.name} ${playlistItems.map((item, index) => `${index + 1}. \`${item.title}\``).join('\n')} ` }; const isIndex = /^\d+$/.test(media), keywords = isIndex ? undefined : media.toLowerCase().split(' '), matchingPlaylistItems = isIndex ? [playlistItems[parseInt(media) - 1]] : playlistItems.filter(item => keywords.every(keyword => item.title.toLowerCase().includes(keyword))); if(matchingPlaylistItems.length === 0) return response = { embed: { title: `❌ Media not found` } }; if(matchingPlaylistItems.length > 1) return response = { output: outdent ` 🔎 Multiple media found : ${matchingPlaylistItems.map(item => `${playlistItems.indexOf(item) + 1}\\. \`${item.title}\``).join('\n')} ` }; const [matchingPlaylistItem] = matchingPlaylistItems; await adb(`am start -a android.intent.action.VIEW -d ${matchingPlaylistItem.url} -t video/any is.xyz.mpv`); return response = { embed: { title: `▶️ \`${matchingPlaylistItem.title}\` (${playlistItems.indexOf(matchingPlaylistItem) + 1}/${playlistItems.length})` } }; }); program .command('playlist.rm') .alias('pl.rm') .description('Remove playlist') .argument('', 'Playlist name') .action(async name => { const config = await getConfig(authorID) || {}, { playlists } = config, playlistIndex = playlists?.findIndex(playlist => playlist.name === name); if([undefined, -1].includes(playlistIndex)) return response = { embed: { title: `❌ No existing playlist by that name` } }; playlists.splice(playlistIndex, 1); await setConfig(authorID, config); response = { embed: { title: `✅ Playlist removed` } }; }); program .command('local') .alias('l') .description('List & play local media') .argument('[media]', 'Media title/partial title') .action(async title => { const mediaList = (await adb(`find ${localMediaRootPath} -iname "*.mkv" -o -iname "*.mp4"`)).split('\n').map(path => parseMediaUrl(`file://${path}`)); if(!title) return response = { output: outdent ` 📂 Local media : ${mediaList.map(item => `- \`${item.title}\``).join('\n')} ` }; const keywords = title.toLowerCase().split(' '), matchingMediaList = mediaList.filter(item => keywords.every(keyword => item.title.toLowerCase().includes(keyword))); if(matchingMediaList.length === 0) return response = { embed: { title: `❌ Media not found` } }; if(matchingMediaList.length > 1) return response = { output: outdent ` 🔎 Multiple media found : ${matchingMediaList.map(item => `- \`${item.title}\``).join('\n')} ` }; const [matchingMedia] = matchingMediaList; await adb(`am start -a android.intent.action.VIEW -n com.android.gallery3d/.app.MovieActivity -d "${matchingMedia.url}" -e MediaPlayerType 2`); return response = { embed: { title: `▶️ \`${matchingMedia.title}\`` } }; }); program .command('kiwi') .description('Launch Kiwi browser') .argument('[url]', 'Website URL') .action(url => adb(url ? `am start -a android.intent.action.VIEW -d ${url} -t text/plain com.kiwibrowser.browser` : 'monkey -p com.kiwibrowser.browser 1' )); program .command('notifications') .description('Show notification history') .action(async () => { const items = []; let item; for(let line of (await adb('dumpsys notification --noredact')).split('\n')){ line = line.trim(); if(line.startsWith('NotificationRecord(')) items.push(item = { app: line.slice(35).split(' ')[0] }); if(line.startsWith('android.title=')) item.title = line.slice(line.indexOf('(') + 1, -1); if(line.startsWith('android.text=')) item.text = line.slice(line.indexOf('(') + 1, -1); if(line.startsWith('mCreationTimeMs=')) item.date = parseInt(line.slice(16)); } items.sort((a, b) => b.date - a.date); response = { output: new AsciiTable() .setHeading('App', 'Time', 'Content') .addRowMatrix(items .sort((a, b) => b.date - a.date) .map(item => [ item.app, dayjs(item.date).format('DD/MM HH:mm'), `${item.title}${item.title.length + item.text.length < 50 ? ` · ${item.text}` : ''}` ]) ).toString(), isRaw: true }; }); try { await program.parseAsync( parse(content), { from: 'user' } ); } catch(error){ if(!error.code?.startsWith?.('commander.')) throw error; } return response; }; eris.on( 'error', error => console.error(error) ); eris.on( 'messageCreate', async message => { const { guildID, author: { id: authorID }, channel: { id: channelID }, id: messageID, content, attachments } = message, reply = async (content, isCodeBlock) => { const lines = content.split('\n'); let chunk = isCodeBlock ? '```\n' : ''; for(let lineIndex = 0; lineIndex < lines.length; lineIndex++){ const line = lines[lineIndex] + '\n', canAddLine = chunk.length + line.length + (isCodeBlock ? 3 : 0) <= 2000; if(canAddLine) chunk += line; if(!canAddLine || lineIndex === lines.length - 1){ if(isCodeBlock) chunk += '```'; await eris.createMessage( channelID, { content: chunk, messageReference: { messageID } } ); chunk = isCodeBlock ? `\`\`\`\n${line}` : line; } } }; if(message.author.bot) return; if( guildID || !botWhitelist.includes(authorID) ) return message.addReaction('❌'); await message.addReaction('⏳'); try { const { output, isRaw, embed } = await handleCommand({ content, authorID, attachments }) || {}; if(output) await reply(output, isRaw); if(embed) await eris.createMessage( channelID, { embed, messageReference: { messageID } } ); await message.addReaction('✅'); } catch(error){ console.error(error); await message.addReaction('❌'); } } ); (async () => { await Promise.all([ eris.connect(), new Promise(resolve => eris.once('ready', resolve)), new Promise(resolve => exec(`adb connect ${adbIp}:${adbPort}`, resolve)) ]); console.log('Ready'); })().catch(error => console.error(error));