diff --git a/squeebot.repo.json b/squeebot.repo.json index 9a55169..6b7bf77 100644 --- a/squeebot.repo.json +++ b/squeebot.repo.json @@ -25,6 +25,10 @@ "name": "timezone", "version": "1.1.0" }, + { + "name": "url-atproto", + "version": "1.0.1" + }, { "name": "url-fediverse", "version": "1.0.4" diff --git a/url-atproto/plugin.json b/url-atproto/plugin.json new file mode 100644 index 0000000..da6ef4f --- /dev/null +++ b/url-atproto/plugin.json @@ -0,0 +1,9 @@ +{ + "main": "plugin.js", + "name": "url-atproto", + "description": "AT Protocol (Bluesky) URL", + "version": "1.0.1", + "tags": ["urlreply", "at-protocol", "bluesky"], + "dependencies": ["urlreply"], + "npmDependencies": [] +} diff --git a/url-atproto/plugin.ts b/url-atproto/plugin.ts new file mode 100644 index 0000000..25fbaff --- /dev/null +++ b/url-atproto/plugin.ts @@ -0,0 +1,175 @@ +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; diff --git a/url-atproto/schema.json b/url-atproto/schema.json new file mode 100644 index 0000000..6018e0c --- /dev/null +++ b/url-atproto/schema.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://squeebot.lunasqu.ee/pkg/plugins-evert/url-atproto.schema.json", + "title": "url-atproto", + "description": "url-atproto plugin configuration", + "type": "object", + "properties": {}, + "required": [] +}