import { Plugin, Configurable, EventListener, DependencyLoad, } from '@squeebot/core/lib/plugin'; import { IMessage } from '@squeebot/core/lib/types'; import { httpGET, sanitizeEscapedText } from '@squeebot/core/lib/common'; interface ATRecordEmbed { $type: string; images: [ { alt: string; aspectRatio: { height: number; width: number; }; image: { $type: string; ref: { $link: string; }; mimeType: string; size: number; }; } ]; } interface ATRecord { $type: string; createdAt: string; langs: string[]; text: string; embed?: ATRecordEmbed; facets?: unknown[]; } interface ATEmbed { $type: string; images?: [ { thumb: string; fullsize: string; alt: string; aspectRatio: { height: number; width: number; }; } ]; record?: ATRecord; } interface ATThread { thread: { $type: 'app.bsky.feed.defs#threadViewPost'; post: { uri: string; cid: string; author: { did: string; handle: string; displayName: string; avatar: string; labels: unknown[]; createdAt: string; }; record: ATRecord; embed?: ATEmbed; replyCount: number; repostCount: number; likeCount: number; quoteCount: number; indexedAt: string; labels: unknown[]; }; replies: ATThread['thread'][]; }; } @Configurable({}) class TwitterURLPlugin extends Plugin { @EventListener('pluginUnload') public unloadEventHandler(plugin: string | Plugin): void { if (plugin === this.name || plugin === this) { this.emit('pluginUnloaded', this); } } async handlePost( data: ATThread['thread']['post'], msg: IMessage ): Promise { const keys = []; const end = sanitizeEscapedText(data.record.text); keys.push(['field', 'Bluesky', { color: 'cyan', type: 'title' }]); keys.push([ 'field', data.likeCount.toString(), { color: 'red', label: ['♥', 'Likes'], type: 'metric' }, ]); keys.push([ 'field', data.repostCount.toString(), { color: 'green', label: ['↱↲', 'Reposts'], type: 'metric' }, ]); keys.push(['bold', '@' + data.author.handle + ':']); keys.push(['field', end, { type: 'content' }]); if (data.embed?.images?.length) { const amount = data.embed.images.length; keys.push([ 'field', `[${amount} attachment${amount !== 1 ? 's' : ''}]`, { color: 'brown' }, ]); } if (data.embed?.record) { keys.push(['field', '[quoted post]', { color: 'brown' }]); } msg.resolve(keys); return true; } async atprotoGetThread(url: URL, msg: IMessage): Promise { const urlString = url.toString(); const det = urlString.match( /^(https?):\/\/((?:[\w\d-]+\.)*[\w\d-]+\.\w{2,16})\/(?:profile\/)?@?([\d\w-_.]+)\/(?:post\/)?([\w\d]+[^&#?\s/])/i ); if (!det) { return false; } const [_, proto, webviewHost, atHost, postId] = det; if (proto !== 'https' || webviewHost !== 'bsky.app') { // no other webviews atm return false; } try { const postInfo = await httpGET( `https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?depth=1&parentHeight=1&uri=at://${atHost}/app.bsky.feed.post/${postId}` ); const data = JSON.parse(postInfo) as ATThread; if (data?.thread) { return this.handlePost(data.thread.post, msg); } } catch (e) { msg.resolve('Could not fetch Bluesky post, it may be private.'); } return false; } @DependencyLoad('urlreply') addURLHandler(urlreply: any): void { urlreply.registerHandler( this.name, 'bsky.app/', (url: URL, msg: IMessage) => { return this.atprotoGetThread(url, msg); } ); } } module.exports = TwitterURLPlugin;