402 lines
No EOL
16 KiB
JavaScript
402 lines
No EOL
16 KiB
JavaScript
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 { 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('<command>', '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>', 'Text to type')
|
||
.action(text => adb(`input text ${text}`));
|
||
program
|
||
.command('press')
|
||
.description('Press key')
|
||
.addArgument(
|
||
new Argument('<key>', '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('<url>', '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}\`` } };
|
||
});
|
||
program
|
||
.command('playlist.rm')
|
||
.alias('pl.rm')
|
||
.description('Remove playlist')
|
||
.argument('<name>', '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,
|
||
new Date(item.date).toLocaleTimeString(),
|
||
`${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)); |