discord-home-cinema/main.js

403 lines
No EOL
16 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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('<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}\` (${playlistItems.indexOf(matchingPlaylistItem) + 1}/${playlistItems.length})` } };
});
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,
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));