-**🌎 **[Sharky](https://dev.transfem.social/)** is an open source, decentralized social media platform that's free forever! 🚀**
+**🌎 **[Sharky](https://test.transfem.social/)** is an open source, decentralized social media platform that's free forever! 🚀**
---
@@ -15,13 +15,19 @@
-
+
## ✨ Features
- **ActivityPub support**\
Not on Sharky? No problem! Not only can Sharky instances talk to each other, but you can make friends with people on other networks like Mastodon and Pixelfed!
- **Reactions**\
You can add emoji reactions to any post! No longer are you bound by a like button, show everyone exactly how you feel with the tap of a button.
+- **Post Editing**\
+In Sharkey you can edit your post, this is not possible in normal Misskey
+- **Mastodon API**\
+Sharkey implements the mastodon api unlike normal Misskey
+- **UI/UX Improvements**\
+Sharkey makes some Ui/UX improvements to make it easier to navigate
- **Drive**\
With Sharky's built in drive, you get cloud storage right in your social media, where you can upload any files, make folders, and find media from posts you've made!
- **Rich Web UI**\
diff --git a/package.json b/package.json
index 8b5f57494..6389287c4 100644
--- a/package.json
+++ b/package.json
@@ -1,10 +1,10 @@
{
- "name": "misskey",
+ "name": "sharkey",
"version": "2023.9.0-beta.10",
"codename": "nasubi",
"repository": {
"type": "git",
- "url": "https://github.com/misskey-dev/misskey.git"
+ "url": "https://github.com/transfem-org/sharkey.git"
},
"packageManager": "pnpm@8.7.6",
"workspaces": [
diff --git a/packages/backend/package.json b/packages/backend/package.json
index 3d3fc8700..b82bd73a0 100644
--- a/packages/backend/package.json
+++ b/packages/backend/package.json
@@ -15,7 +15,7 @@
"watch:swc": "swc src -d built -D -w",
"build:tsc": "tsc -p tsconfig.json && tsc-alias -p tsconfig.json",
"watch": "node watch.mjs",
- "typecheck": "tsc --noEmit",
+ "typecheck": "pnpm --filter megalodon build && tsc --noEmit",
"eslint": "eslint --quiet \"src/**/*.ts\"",
"lint": "pnpm typecheck && pnpm eslint",
"jest": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit",
@@ -99,6 +99,7 @@
"date-fns": "2.30.0",
"deep-email-validator": "0.1.21",
"fastify": "4.23.2",
+ "fastify-multer": "^2.0.3",
"feed": "4.2.2",
"file-type": "18.5.0",
"fluent-ffmpeg": "2.1.2",
@@ -116,6 +117,7 @@
"json5": "2.2.3",
"jsonld": "8.3.1",
"jsrsasign": "10.8.6",
+ "megalodon": "workspace:*",
"meilisearch": "0.34.2",
"mfm-js": "0.23.3",
"microformats-parser": "1.5.2",
diff --git a/packages/backend/src/misc/emoji-regex.ts b/packages/backend/src/misc/emoji-regex.ts
index 24e4092ae..2dd079bd7 100644
--- a/packages/backend/src/misc/emoji-regex.ts
+++ b/packages/backend/src/misc/emoji-regex.ts
@@ -7,3 +7,4 @@
const twemojiRegex = /(?:\ud83d\udc68\ud83c\udffb\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udffc\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udffd\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udffe\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udfff\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffb\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffb\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffc\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffc\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffd\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffd\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffe\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffe\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udfff\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udfff\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83e\uddd1\ud83c\udffb\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83e\uddd1\ud83c[\udffc-\udfff]|\ud83e\uddd1\ud83c\udffc\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83e\uddd1\ud83c[\udffb\udffd-\udfff]|\ud83e\uddd1\ud83c\udffd\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83e\uddd1\ud83c[\udffb\udffc\udffe\udfff]|\ud83e\uddd1\ud83c\udffe\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83e\uddd1\ud83c[\udffb-\udffd\udfff]|\ud83e\uddd1\ud83c\udfff\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83e\uddd1\ud83c[\udffb-\udffe]|\ud83d\udc68\ud83c\udffb\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udffb\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffc-\udfff]|\ud83d\udc68\ud83c\udffc\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udffc\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffb\udffd-\udfff]|\ud83d\udc68\ud83c\udffd\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udffd\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffb\udffc\udffe\udfff]|\ud83d\udc68\ud83c\udffe\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udffe\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffb-\udffd\udfff]|\ud83d\udc68\ud83c\udfff\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udfff\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffb-\udffe]|\ud83d\udc69\ud83c\udffb\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffb\u200d\u2764\ufe0f\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffb\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffc-\udfff]|\ud83d\udc69\ud83c\udffb\u200d\ud83e\udd1d\u200d\ud83d\udc69\ud83c[\udffc-\udfff]|\ud83d\udc69\ud83c\udffc\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffc\u200d\u2764\ufe0f\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffc\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffb\udffd-\udfff]|\ud83d\udc69\ud83c\udffc\u200d\ud83e\udd1d\u200d\ud83d\udc69\ud83c[\udffb\udffd-\udfff]|\ud83d\udc69\ud83c\udffd\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffd\u200d\u2764\ufe0f\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffd\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffb\udffc\udffe\udfff]|\ud83d\udc69\ud83c\udffd\u200d\ud83e\udd1d\u200d\ud83d\udc69\ud83c[\udffb\udffc\udffe\udfff]|\ud83d\udc69\ud83c\udffe\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffe\u200d\u2764\ufe0f\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffe\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffb-\udffd\udfff]|\ud83d\udc69\ud83c\udffe\u200d\ud83e\udd1d\u200d\ud83d\udc69\ud83c[\udffb-\udffd\udfff]|\ud83d\udc69\ud83c\udfff\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udfff\u200d\u2764\ufe0f\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udfff\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffb-\udffe]|\ud83d\udc69\ud83c\udfff\u200d\ud83e\udd1d\u200d\ud83d\udc69\ud83c[\udffb-\udffe]|\ud83e\uddd1\ud83c\udffb\u200d\u2764\ufe0f\u200d\ud83e\uddd1\ud83c[\udffc-\udfff]|\ud83e\uddd1\ud83c\udffb\u200d\ud83e\udd1d\u200d\ud83e\uddd1\ud83c[\udffb-\udfff]|\ud83e\uddd1\ud83c\udffc\u200d\u2764\ufe0f\u200d\ud83e\uddd1\ud83c[\udffb\udffd-\udfff]|\ud83e\uddd1\ud83c\udffc\u200d\ud83e\udd1d\u200d\ud83e\uddd1\ud83c[\udffb-\udfff]|\ud83e\uddd1\ud83c\udffd\u200d\u2764\ufe0f\u200d\ud83e\uddd1\ud83c[\udffb\udffc\udffe\udfff]|\ud83e\uddd1\ud83c\udffd\u200d\ud83e\udd1d\u200d\ud83e\uddd1\ud83c[\udffb-\udfff]|\ud83e\uddd1\ud83c\udffe\u200d\u2764\ufe0f\u200d\ud83e\uddd1\ud83c[\udffb-\udffd\udfff]|\ud83e\uddd1\ud83c\udffe\u200d\ud83e\udd1d\u200d\ud83e\uddd1\ud83c[\udffb-\udfff]|\ud83e\uddd1\ud83c\udfff\u200d\u2764\ufe0f\u200d\ud83e\uddd1\ud83c[\udffb-\udffe]|\ud83e\uddd1\ud83c\udfff\u200d\ud83e\udd1d\u200d\ud83e\uddd1\ud83c[\udffb-\udfff]|\ud83d\udc68\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68|\ud83d\udc69\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d[\udc68\udc69]|\ud83e\udef1\ud83c\udffb\u200d\ud83e\udef2\ud83c[\udffc-\udfff]|\ud83e\udef1\ud83c\udffc\u200d\ud83e\udef2\ud83c[\udffb\udffd-\udfff]|\ud83e\udef1\ud83c\udffd\u200d\ud83e\udef2\ud83c[\udffb\udffc\udffe\udfff]|\ud83e\udef1\ud83c\udffe\u200d\ud83e\udef2\ud83c[\udffb-\udffd\udfff]|\ud83e\udef1\ud83c\udfff\u200d\ud83e\udef2\ud83c[\udffb-\udffe]|\ud83d\udc68\u200d\u2764\ufe0f\u200d\ud83d\udc68|\ud83d\udc69\u200d\u2764\ufe0f\u200d\ud83d[\udc68\udc69]|\ud83e\uddd1\u200d\ud83e\udd1d\u200d\ud83e\uddd1|\ud83d\udc6b\ud83c[\udffb-\udfff]|\ud83d\udc6c\ud83c[\udffb-\udfff]|\ud83d\udc6d\ud83c[\udffb-\udfff]|\ud83d\udc8f\ud83c[\udffb-\udfff]|\ud83d\udc91\ud83c[\udffb-\udfff]|\ud83e\udd1d\ud83c[\udffb-\udfff]|\ud83d[\udc6b-\udc6d\udc8f\udc91]|\ud83e\udd1d)|(?:\ud83d[\udc68\udc69]|\ud83e\uddd1)(?:\ud83c[\udffb-\udfff])?\u200d(?:\u2695\ufe0f|\u2696\ufe0f|\u2708\ufe0f|\ud83c[\udf3e\udf73\udf7c\udf84\udf93\udfa4\udfa8\udfeb\udfed]|\ud83d[\udcbb\udcbc\udd27\udd2c\ude80\ude92]|\ud83e[\uddaf-\uddb3\uddbc\uddbd])|(?:\ud83c[\udfcb\udfcc]|\ud83d[\udd74\udd75]|\u26f9)((?:\ud83c[\udffb-\udfff]|\ufe0f)\u200d[\u2640\u2642]\ufe0f)|(?:\ud83c[\udfc3\udfc4\udfca]|\ud83d[\udc6e\udc70\udc71\udc73\udc77\udc81\udc82\udc86\udc87\ude45-\ude47\ude4b\ude4d\ude4e\udea3\udeb4-\udeb6]|\ud83e[\udd26\udd35\udd37-\udd39\udd3d\udd3e\uddb8\uddb9\uddcd-\uddcf\uddd4\uddd6-\udddd])(?:\ud83c[\udffb-\udfff])?\u200d[\u2640\u2642]\ufe0f|(?:\ud83d\udc68\u200d\ud83d\udc68\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc68\u200d\ud83d\udc68\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc69\u200d\ud83d\udc69\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc69\u200d\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc68\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc68\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc68\u200d\ud83d\udc68\u200d\ud83d[\udc66\udc67]|\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d[\udc66\udc67]|\ud83d\udc69\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc69\u200d\ud83d\udc69\u200d\ud83d[\udc66\udc67]|\ud83c\udff3\ufe0f\u200d\u26a7\ufe0f|\ud83c\udff3\ufe0f\u200d\ud83c\udf08|\ud83d\ude36\u200d\ud83c\udf2b\ufe0f|\u2764\ufe0f\u200d\ud83d\udd25|\u2764\ufe0f\u200d\ud83e\ude79|\ud83c\udff4\u200d\u2620\ufe0f|\ud83d\udc15\u200d\ud83e\uddba|\ud83d\udc3b\u200d\u2744\ufe0f|\ud83d\udc41\u200d\ud83d\udde8|\ud83d\udc68\u200d\ud83d[\udc66\udc67]|\ud83d\udc69\u200d\ud83d[\udc66\udc67]|\ud83d\udc6f\u200d\u2640\ufe0f|\ud83d\udc6f\u200d\u2642\ufe0f|\ud83d\ude2e\u200d\ud83d\udca8|\ud83d\ude35\u200d\ud83d\udcab|\ud83e\udd3c\u200d\u2640\ufe0f|\ud83e\udd3c\u200d\u2642\ufe0f|\ud83e\uddde\u200d\u2640\ufe0f|\ud83e\uddde\u200d\u2642\ufe0f|\ud83e\udddf\u200d\u2640\ufe0f|\ud83e\udddf\u200d\u2642\ufe0f|\ud83d\udc08\u200d\u2b1b)|[#*0-9]\ufe0f?\u20e3|(?:[©®\u2122\u265f]\ufe0f)|(?:\ud83c[\udc04\udd70\udd71\udd7e\udd7f\ude02\ude1a\ude2f\ude37\udf21\udf24-\udf2c\udf36\udf7d\udf96\udf97\udf99-\udf9b\udf9e\udf9f\udfcd\udfce\udfd4-\udfdf\udff3\udff5\udff7]|\ud83d[\udc3f\udc41\udcfd\udd49\udd4a\udd6f\udd70\udd73\udd76-\udd79\udd87\udd8a-\udd8d\udda5\udda8\uddb1\uddb2\uddbc\uddc2-\uddc4\uddd1-\uddd3\udddc-\uddde\udde1\udde3\udde8\uddef\uddf3\uddfa\udecb\udecd-\udecf\udee0-\udee5\udee9\udef0\udef3]|[\u203c\u2049\u2139\u2194-\u2199\u21a9\u21aa\u231a\u231b\u2328\u23cf\u23ed-\u23ef\u23f1\u23f2\u23f8-\u23fa\u24c2\u25aa\u25ab\u25b6\u25c0\u25fb-\u25fe\u2600-\u2604\u260e\u2611\u2614\u2615\u2618\u2620\u2622\u2623\u2626\u262a\u262e\u262f\u2638-\u263a\u2640\u2642\u2648-\u2653\u2660\u2663\u2665\u2666\u2668\u267b\u267f\u2692-\u2697\u2699\u269b\u269c\u26a0\u26a1\u26a7\u26aa\u26ab\u26b0\u26b1\u26bd\u26be\u26c4\u26c5\u26c8\u26cf\u26d1\u26d3\u26d4\u26e9\u26ea\u26f0-\u26f5\u26f8\u26fa\u26fd\u2702\u2708\u2709\u270f\u2712\u2714\u2716\u271d\u2721\u2733\u2734\u2744\u2747\u2757\u2763\u2764\u27a1\u2934\u2935\u2b05-\u2b07\u2b1b\u2b1c\u2b50\u2b55\u3030\u303d\u3297\u3299])(?:\ufe0f|(?!\ufe0e))|(?:(?:\ud83c[\udfcb\udfcc]|\ud83d[\udd74\udd75\udd90]|[\u261d\u26f7\u26f9\u270c\u270d])(?:\ufe0f|(?!\ufe0e))|(?:\ud83c[\udf85\udfc2-\udfc4\udfc7\udfca]|\ud83d[\udc42\udc43\udc46-\udc50\udc66-\udc69\udc6e\udc70-\udc78\udc7c\udc81-\udc83\udc85-\udc87\udcaa\udd7a\udd95\udd96\ude45-\ude47\ude4b-\ude4f\udea3\udeb4-\udeb6\udec0\udecc]|\ud83e[\udd0c\udd0f\udd18-\udd1c\udd1e\udd1f\udd26\udd30-\udd39\udd3d\udd3e\udd77\uddb5\uddb6\uddb8\uddb9\uddbb\uddcd-\uddcf\uddd1-\udddd\udec3-\udec5\udef0-\udef6]|[\u270a\u270b]))(?:\ud83c[\udffb-\udfff])?|(?:\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc65\udb40\udc6e\udb40\udc67\udb40\udc7f|\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc73\udb40\udc63\udb40\udc74\udb40\udc7f|\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc77\udb40\udc6c\udb40\udc73\udb40\udc7f|\ud83c\udde6\ud83c[\udde8-\uddec\uddee\uddf1\uddf2\uddf4\uddf6-\uddfa\uddfc\uddfd\uddff]|\ud83c\udde7\ud83c[\udde6\udde7\udde9-\uddef\uddf1-\uddf4\uddf6-\uddf9\uddfb\uddfc\uddfe\uddff]|\ud83c\udde8\ud83c[\udde6\udde8\udde9\uddeb-\uddee\uddf0-\uddf5\uddf7\uddfa-\uddff]|\ud83c\udde9\ud83c[\uddea\uddec\uddef\uddf0\uddf2\uddf4\uddff]|\ud83c\uddea\ud83c[\udde6\udde8\uddea\uddec\udded\uddf7-\uddfa]|\ud83c\uddeb\ud83c[\uddee-\uddf0\uddf2\uddf4\uddf7]|\ud83c\uddec\ud83c[\udde6\udde7\udde9-\uddee\uddf1-\uddf3\uddf5-\uddfa\uddfc\uddfe]|\ud83c\udded\ud83c[\uddf0\uddf2\uddf3\uddf7\uddf9\uddfa]|\ud83c\uddee\ud83c[\udde8-\uddea\uddf1-\uddf4\uddf6-\uddf9]|\ud83c\uddef\ud83c[\uddea\uddf2\uddf4\uddf5]|\ud83c\uddf0\ud83c[\uddea\uddec-\uddee\uddf2\uddf3\uddf5\uddf7\uddfc\uddfe\uddff]|\ud83c\uddf1\ud83c[\udde6-\udde8\uddee\uddf0\uddf7-\uddfb\uddfe]|\ud83c\uddf2\ud83c[\udde6\udde8-\udded\uddf0-\uddff]|\ud83c\uddf3\ud83c[\udde6\udde8\uddea-\uddec\uddee\uddf1\uddf4\uddf5\uddf7\uddfa\uddff]|\ud83c\uddf4\ud83c\uddf2|\ud83c\uddf5\ud83c[\udde6\uddea-\udded\uddf0-\uddf3\uddf7-\uddf9\uddfc\uddfe]|\ud83c\uddf6\ud83c\udde6|\ud83c\uddf7\ud83c[\uddea\uddf4\uddf8\uddfa\uddfc]|\ud83c\uddf8\ud83c[\udde6-\uddea\uddec-\uddf4\uddf7-\uddf9\uddfb\uddfd-\uddff]|\ud83c\uddf9\ud83c[\udde6\udde8\udde9\uddeb-\udded\uddef-\uddf4\uddf7\uddf9\uddfb\uddfc\uddff]|\ud83c\uddfa\ud83c[\udde6\uddec\uddf2\uddf3\uddf8\uddfe\uddff]|\ud83c\uddfb\ud83c[\udde6\udde8\uddea\uddec\uddee\uddf3\uddfa]|\ud83c\uddfc\ud83c[\uddeb\uddf8]|\ud83c\uddfd\ud83c\uddf0|\ud83c\uddfe\ud83c[\uddea\uddf9]|\ud83c\uddff\ud83c[\udde6\uddf2\uddfc]|\ud83c[\udccf\udd8e\udd91-\udd9a\udde6-\uddff\ude01\ude32-\ude36\ude38-\ude3a\ude50\ude51\udf00-\udf20\udf2d-\udf35\udf37-\udf7c\udf7e-\udf84\udf86-\udf93\udfa0-\udfc1\udfc5\udfc6\udfc8\udfc9\udfcf-\udfd3\udfe0-\udff0\udff4\udff8-\udfff]|\ud83d[\udc00-\udc3e\udc40\udc44\udc45\udc51-\udc65\udc6a\udc6f\udc79-\udc7b\udc7d-\udc80\udc84\udc88-\udc8e\udc90\udc92-\udca9\udcab-\udcfc\udcff-\udd3d\udd4b-\udd4e\udd50-\udd67\udda4\uddfb-\ude44\ude48-\ude4a\ude80-\udea2\udea4-\udeb3\udeb7-\udebf\udec1-\udec5\uded0-\uded2\uded5-\uded7\udedd-\udedf\udeeb\udeec\udef4-\udefc\udfe0-\udfeb\udff0]|\ud83e[\udd0d\udd0e\udd10-\udd17\udd20-\udd25\udd27-\udd2f\udd3a\udd3c\udd3f-\udd45\udd47-\udd76\udd78-\uddb4\uddb7\uddba\uddbc-\uddcc\uddd0\uddde-\uddff\ude70-\ude74\ude78-\ude7c\ude80-\ude86\ude90-\udeac\udeb0-\udeba\udec0-\udec2\uded0-\uded9\udee0-\udee7]|[\u23e9-\u23ec\u23f0\u23f3\u267e\u26ce\u2705\u2728\u274c\u274e\u2753-\u2755\u2795-\u2797\u27b0\u27bf\ue50a])|\ufe0f/g;
export const emojiRegex = new RegExp(`(${twemojiRegex.source})`);
+export const emojiRegexAtStartToEnd = new RegExp(`^(${twemojiRegex.source})$`);
diff --git a/packages/backend/src/server/ServerModule.ts b/packages/backend/src/server/ServerModule.ts
index fa81380f0..fc6f01960 100644
--- a/packages/backend/src/server/ServerModule.ts
+++ b/packages/backend/src/server/ServerModule.ts
@@ -39,6 +39,7 @@ import { QueueStatsChannelService } from './api/stream/channels/queue-stats.js';
import { ServerStatsChannelService } from './api/stream/channels/server-stats.js';
import { UserListChannelService } from './api/stream/channels/user-list.js';
import { OpenApiServerService } from './api/openapi/OpenApiServerService.js';
+import { MastodonApiServerService } from './api/mastodon/MastodonApiServerService.js';
import { ClientLoggerService } from './web/ClientLoggerService.js';
import { RoleTimelineChannelService } from './api/stream/channels/role-timeline.js';
import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js';
@@ -84,6 +85,7 @@ import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js';
ServerStatsChannelService,
UserListChannelService,
OpenApiServerService,
+ MastodonApiServerService,
OAuth2ProviderService,
],
exports: [
diff --git a/packages/backend/src/server/ServerService.ts b/packages/backend/src/server/ServerService.ts
index 0e4a5ece3..a1189e219 100644
--- a/packages/backend/src/server/ServerService.ts
+++ b/packages/backend/src/server/ServerService.ts
@@ -30,6 +30,7 @@ import { WellKnownServerService } from './WellKnownServerService.js';
import { FileServerService } from './FileServerService.js';
import { ClientServerService } from './web/ClientServerService.js';
import { OpenApiServerService } from './api/openapi/OpenApiServerService.js';
+import { MastodonApiServerService } from './api/mastodon/MastodonApiServerService.js';
import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js';
const _dirname = fileURLToPath(new URL('.', import.meta.url));
@@ -56,6 +57,7 @@ export class ServerService implements OnApplicationShutdown {
private userEntityService: UserEntityService,
private apiServerService: ApiServerService,
private openApiServerService: OpenApiServerService,
+ private mastodonApiServerService: MastodonApiServerService,
private streamingApiServerService: StreamingApiServerService,
private activityPubServerService: ActivityPubServerService,
private wellKnownServerService: WellKnownServerService,
@@ -95,6 +97,7 @@ export class ServerService implements OnApplicationShutdown {
fastify.register(this.apiServerService.createServer, { prefix: '/api' });
fastify.register(this.openApiServerService.createServer);
+ fastify.register(this.mastodonApiServerService.createServer, { prefix: '/api' });
fastify.register(this.fileServerService.createServer);
fastify.register(this.activityPubServerService.createServer);
fastify.register(this.nodeinfoServerService.createServer);
diff --git a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts
new file mode 100644
index 000000000..883d25aaa
--- /dev/null
+++ b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts
@@ -0,0 +1,783 @@
+import { Inject, Injectable } from '@nestjs/common';
+import megalodon, { Entity, MegalodonInterface } from 'megalodon';
+import { IsNull } from 'typeorm';
+import multer from 'fastify-multer';
+import type { UsersRepository } from '@/models/_.js';
+import { DI } from '@/di-symbols.js';
+import { bindThis } from '@/decorators.js';
+import type { Config } from '@/config.js';
+import { MetaService } from '@/core/MetaService.js';
+import { convertId, IdConvertType as IdType, convertAccount, convertAnnouncement, convertFilter, convertAttachment, convertFeaturedTag, convertList } from './converters.js';
+import { getInstance } from './endpoints/meta.js';
+import { ApiAuthMastodon, ApiAccountMastodon, ApiFilterMastodon, ApiNotifyMastodon, ApiSearchMastodon, ApiTimelineMastodon, ApiStatusMastodon } from './endpoints.js';
+import type { FastifyInstance, FastifyPluginOptions } from 'fastify';
+
+export function getClient(BASE_URL: string, authorization: string | undefined): MegalodonInterface {
+ const accessTokenArr = authorization?.split(' ') ?? [null];
+ const accessToken = accessTokenArr[accessTokenArr.length - 1];
+ const generator = (megalodon as any).default;
+ const client = generator('misskey', BASE_URL, accessToken) as MegalodonInterface;
+ return client;
+}
+
+@Injectable()
+export class MastodonApiServerService {
+ constructor(
+ @Inject(DI.usersRepository)
+ private usersRepository: UsersRepository,
+ @Inject(DI.config)
+ private config: Config,
+ private metaService: MetaService,
+ ) { }
+
+ @bindThis
+ public createServer(fastify: FastifyInstance, _options: FastifyPluginOptions, done: (err?: Error) => void) {
+ const upload = multer({
+ storage: multer.diskStorage({}),
+ limits: {
+ fileSize: this.config.maxFileSize || 262144000,
+ files: 1,
+ },
+ });
+
+ fastify.register(multer.contentParser);
+
+ fastify.get('/v1/custom_emojis', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ try {
+ const data = await client.getInstanceCustomEmojis();
+ reply.send(data.data);
+ } catch (e: any) {
+ console.error(e);
+ reply.code(401).send(e.response.data);
+ }
+ });
+
+ fastify.get('/v1/instance', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt
+ // displayed without being logged in
+ try {
+ const data = await client.getInstance();
+ const admin = await this.usersRepository.findOne({
+ where: {
+ host: IsNull(),
+ isRoot: true,
+ isDeleted: false,
+ isSuspended: false,
+ },
+ order: { id: 'ASC' },
+ });
+ const contact = admin == null ? null : convertAccount((await client.getAccount(admin.id)).data);
+ reply.send(await getInstance(data.data, contact, this.config, await this.metaService.fetch()));
+ } catch (e: any) {
+ console.error(e);
+ reply.code(401).send(e.response.data);
+ }
+ });
+
+ fastify.get('/v1/announcements', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ try {
+ const data = await client.getInstanceAnnouncements();
+ reply.send(data.data.map((announcement) => convertAnnouncement(announcement)));
+ } catch (e: any) {
+ console.error(e);
+ reply.code(401).send(e.response.data);
+ }
+ });
+
+ fastify.post<{ Body: { id: string } }>('/v1/announcements/:id/dismiss', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ try {
+ const data = await client.dismissInstanceAnnouncement(
+ convertId(_request.body['id'], IdType.SharkeyId),
+ );
+ reply.send(data.data);
+ } catch (e: any) {
+ console.error(e);
+ reply.code(401).send(e.response.data);
+ }
+ },
+ );
+
+ fastify.post('/v1/media', { preHandler: upload.single('file') }, async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ try {
+ const multipartData = await _request.file;
+ if (!multipartData) {
+ reply.code(401).send({ error: 'No image' });
+ return;
+ }
+ const data = await client.uploadMedia(multipartData);
+ reply.send(convertAttachment(data.data as Entity.Attachment));
+ } catch (e: any) {
+ console.error(e);
+ reply.code(401).send(e.response.data);
+ }
+ });
+
+ fastify.post('/v2/media', { preHandler: upload.single('file') }, async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ try {
+ const multipartData = await _request.file;
+ if (!multipartData) {
+ reply.code(401).send({ error: 'No image' });
+ return;
+ }
+ const data = await client.uploadMedia(multipartData, _request.body!);
+ reply.send(convertAttachment(data.data as Entity.Attachment));
+ } catch (e: any) {
+ console.error(e);
+ reply.code(401).send(e.response.data);
+ }
+ });
+
+ fastify.get('/v1/filters', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt
+ // displayed without being logged in
+ try {
+ const data = await client.getFilters();
+ reply.send(data.data.map((filter) => convertFilter(filter)));
+ } catch (e: any) {
+ console.error(e);
+ reply.code(401).send(e.response.data);
+ }
+ });
+
+ fastify.get('/v1/trends', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt
+ // displayed without being logged in
+ try {
+ const data = await client.getInstanceTrends();
+ reply.send(data.data);
+ } catch (e: any) {
+ console.error(e);
+ reply.code(401).send(e.response.data);
+ }
+ });
+
+ fastify.post('/v1/apps', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const client = getClient(BASE_URL, ''); // we are using this here, because in private mode some info isnt
+ // displayed without being logged in
+ try {
+ const data = await ApiAuthMastodon(_request, client);
+ reply.send(data);
+ } catch (e: any) {
+ console.error(e);
+ reply.code(401).send(e.response.data);
+ }
+ });
+
+ fastify.get('/v1/preferences', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt
+ // displayed without being logged in
+ try {
+ const data = await client.getPreferences();
+ reply.send(data.data);
+ } catch (e: any) {
+ console.error(e);
+ reply.code(401).send(e.response.data);
+ }
+ });
+
+ //#region Accounts
+ fastify.get('/v1/accounts/verify_credentials', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt
+ // displayed without being logged in
+ try {
+ const account = new ApiAccountMastodon(_request, client, BASE_URL);
+ reply.send(await account.verifyCredentials());
+ } catch (e: any) {
+ console.error(e);
+ reply.code(401).send(e.response.data);
+ }
+ });
+
+ fastify.patch('/v1/accounts/update_credentials', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt
+ // displayed without being logged in
+ try {
+ const account = new ApiAccountMastodon(_request, client, BASE_URL);
+ reply.send(await account.updateCredentials());
+ } catch (e: any) {
+ console.error(e);
+ reply.code(401).send(e.response.data);
+ }
+ });
+
+ fastify.get('/v1/accounts/lookup', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt
+ // displayed without being logged in
+ try {
+ const account = new ApiAccountMastodon(_request, client, BASE_URL);
+ reply.send(await account.lookup());
+ } catch (e: any) {
+ console.error(e);
+ reply.code(401).send(e.response.data);
+ }
+ });
+
+ fastify.get('/v1/accounts/relationships', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt
+ // displayed without being logged in
+ let users;
+ try {
+ let ids = _request.query ? (_request.query as any)['id[]'] : null;
+ if (typeof ids === 'string') {
+ ids = [ids];
+ }
+ users = ids;
+ const account = new ApiAccountMastodon(_request, client, BASE_URL);
+ reply.send(await account.getRelationships(users));
+ } catch (e: any) {
+ console.error(e);
+ const data = e.response.data;
+ data.users = users;
+ console.error(data);
+ reply.code(401).send(data);
+ }
+ });
+
+ fastify.get<{ Params: { id: string } }>('/v1/accounts/:id', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ try {
+ const sharkId = convertId(_request.params.id, IdType.SharkeyId);
+ const data = await client.getAccount(sharkId);
+ reply.send(convertAccount(data.data));
+ } catch (e: any) {
+ console.error(e);
+ console.error(e.response.data);
+ reply.code(401).send(e.response.data);
+ }
+ });
+
+ fastify.get<{ Params: { id: string } }>('/v1/accounts/:id/statuses', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ try {
+ const account = new ApiAccountMastodon(_request, client, BASE_URL);
+ reply.send(await account.getStatuses());
+ } catch (e: any) {
+ console.error(e);
+ console.error(e.response.data);
+ reply.code(401).send(e.response.data);
+ }
+ });
+
+ fastify.get<{ Params: { id: string } }>('/v1/accounts/:id/featured_tags', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ try {
+ const data = await client.getFeaturedTags();
+ reply.send(data.data.map((tag) => convertFeaturedTag(tag)));
+ } catch (e: any) {
+ console.error(e);
+ console.error(e.response.data);
+ reply.code(401).send(e.response.data);
+ }
+ });
+
+ fastify.get<{ Params: { id: string } }>('/v1/accounts/:id/followers', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ try {
+ const account = new ApiAccountMastodon(_request, client, BASE_URL);
+ reply.send(await account.getFollowers());
+ } catch (e: any) {
+ console.error(e);
+ console.error(e.response.data);
+ reply.code(401).send(e.response.data);
+ }
+ });
+
+ fastify.get<{ Params: { id: string } }>('/v1/accounts/:id/following', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ try {
+ const account = new ApiAccountMastodon(_request, client, BASE_URL);
+ reply.send(await account.getFollowing());
+ } catch (e: any) {
+ console.error(e);
+ console.error(e.response.data);
+ reply.code(401).send(e.response.data);
+ }
+ });
+
+ fastify.get<{ Params: { id: string } }>('/v1/accounts/:id/lists', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ try {
+ const data = await client.getAccountLists(convertId(_request.params.id, IdType.SharkeyId));
+ reply.send(data.data.map((list) => convertList(list)));
+ } catch (e: any) {
+ console.error(e);
+ console.error(e.response.data);
+ reply.code(401).send(e.response.data);
+ }
+ });
+
+ fastify.post<{ Params: { id: string } }>('/v1/accounts/:id/follow', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ try {
+ const account = new ApiAccountMastodon(_request, client, BASE_URL);
+ reply.send(await account.addFollow());
+ } catch (e: any) {
+ console.error(e);
+ console.error(e.response.data);
+ reply.code(401).send(e.response.data);
+ }
+ });
+
+ fastify.post<{ Params: { id: string } }>('/v1/accounts/:id/unfollow', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ try {
+ const account = new ApiAccountMastodon(_request, client, BASE_URL);
+ reply.send(await account.rmFollow());
+ } catch (e: any) {
+ console.error(e);
+ console.error(e.response.data);
+ reply.code(401).send(e.response.data);
+ }
+ });
+
+ fastify.post<{ Params: { id: string } }>('/v1/accounts/:id/block', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ try {
+ const account = new ApiAccountMastodon(_request, client, BASE_URL);
+ reply.send(await account.addBlock());
+ } catch (e: any) {
+ console.error(e);
+ console.error(e.response.data);
+ reply.code(401).send(e.response.data);
+ }
+ });
+
+ fastify.post<{ Params: { id: string } }>('/v1/accounts/:id/unblock', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ try {
+ const account = new ApiAccountMastodon(_request, client, BASE_URL);
+ reply.send(await account.rmBlock());
+ } catch (e: any) {
+ console.error(e);
+ console.error(e.response.data);
+ reply.code(401).send(e.response.data);
+ }
+ });
+
+ fastify.post<{ Params: { id: string } }>('/v1/accounts/:id/mute', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ try {
+ const account = new ApiAccountMastodon(_request, client, BASE_URL);
+ reply.send(await account.addMute());
+ } catch (e: any) {
+ console.error(e);
+ console.error(e.response.data);
+ reply.code(401).send(e.response.data);
+ }
+ });
+
+ fastify.post<{ Params: { id: string } }>('/v1/accounts/:id/unmute', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ try {
+ const account = new ApiAccountMastodon(_request, client, BASE_URL);
+ reply.send(await account.rmMute());
+ } catch (e: any) {
+ console.error(e);
+ console.error(e.response.data);
+ reply.code(401).send(e.response.data);
+ }
+ });
+
+ fastify.get('/v1/followed_tags', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ try {
+ const data = await client.getFollowedTags();
+ reply.send(data.data);
+ } catch (e: any) {
+ console.error(e);
+ console.error(e.response.data);
+ reply.code(401).send(e.response.data);
+ }
+ });
+
+ fastify.get('/v1/bookmarks', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ try {
+ const account = new ApiAccountMastodon(_request, client, BASE_URL);
+ reply.send(await account.getBookmarks());
+ } catch (e: any) {
+ console.error(e);
+ console.error(e.response.data);
+ reply.code(401).send(e.response.data);
+ }
+ });
+
+ fastify.get('/v1/favourites', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ try {
+ const account = new ApiAccountMastodon(_request, client, BASE_URL);
+ reply.send(await account.getFavourites());
+ } catch (e: any) {
+ console.error(e);
+ console.error(e.response.data);
+ reply.code(401).send(e.response.data);
+ }
+ });
+
+ fastify.get('/v1/mutes', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ try {
+ const account = new ApiAccountMastodon(_request, client, BASE_URL);
+ reply.send(await account.getMutes());
+ } catch (e: any) {
+ console.error(e);
+ console.error(e.response.data);
+ reply.code(401).send(e.response.data);
+ }
+ });
+
+ fastify.get('/v1/blocks', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ try {
+ const account = new ApiAccountMastodon(_request, client, BASE_URL);
+ reply.send(await account.getBlocks());
+ } catch (e: any) {
+ console.error(e);
+ console.error(e.response.data);
+ reply.code(401).send(e.response.data);
+ }
+ });
+
+ fastify.get('/v1/follow_requests', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ try {
+ const data = await client.getFollowRequests( ((_request.query as any) || { limit: 20 }).limit );
+ reply.send(data.data.map((account) => convertAccount(account as Entity.Account)));
+ } catch (e: any) {
+ console.error(e);
+ console.error(e.response.data);
+ reply.code(401).send(e.response.data);
+ }
+ });
+
+ fastify.post<{ Params: { id: string } }>('/v1/follow_requests/:id/authorize', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ try {
+ const account = new ApiAccountMastodon(_request, client, BASE_URL);
+ reply.send(await account.acceptFollow());
+ } catch (e: any) {
+ console.error(e);
+ console.error(e.response.data);
+ reply.code(401).send(e.response.data);
+ }
+ });
+
+ fastify.post<{ Params: { id: string } }>('/v1/follow_requests/:id/reject', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ try {
+ const account = new ApiAccountMastodon(_request, client, BASE_URL);
+ reply.send(await account.rejectFollow());
+ } catch (e: any) {
+ console.error(e);
+ console.error(e.response.data);
+ reply.code(401).send(e.response.data);
+ }
+ });
+ //#endregion
+
+ //#region Search
+ fastify.get('/v1/search', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ try {
+ const search = new ApiSearchMastodon(_request, client, BASE_URL);
+ reply.send(await search.SearchV1());
+ } catch (e: any) {
+ console.error(e);
+ console.error(e.response.data);
+ reply.code(401).send(e.response.data);
+ }
+ });
+
+ fastify.get('/v2/search', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ try {
+ const search = new ApiSearchMastodon(_request, client, BASE_URL);
+ reply.send(await search.SearchV2());
+ } catch (e: any) {
+ console.error(e);
+ console.error(e.response.data);
+ reply.code(401).send(e.response.data);
+ }
+ });
+
+ fastify.get('/v1/trends/statuses', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ try {
+ const search = new ApiSearchMastodon(_request, client, BASE_URL);
+ reply.send(await search.getStatusTrends());
+ } catch (e: any) {
+ console.error(e);
+ console.error(e.response.data);
+ reply.code(401).send(e.response.data);
+ }
+ });
+
+ fastify.get('/v2/suggestions', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ try {
+ const search = new ApiSearchMastodon(_request, client, BASE_URL);
+ reply.send(await search.getSuggestions());
+ } catch (e: any) {
+ console.error(e);
+ console.error(e.response.data);
+ reply.code(401).send(e.response.data);
+ }
+ });
+ //#endregion
+
+ //#region Notifications
+ fastify.get('/v1/notifications', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ try {
+ const notify = new ApiNotifyMastodon(_request, client);
+ reply.send(await notify.getNotifications());
+ } catch (e: any) {
+ console.error(e);
+ console.error(e.response.data);
+ reply.code(401).send(e.response.data);
+ }
+ });
+
+ fastify.get<{ Params: { id: string } }>('/v1/notification/:id', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ try {
+ const notify = new ApiNotifyMastodon(_request, client);
+ reply.send(await notify.getNotification());
+ } catch (e: any) {
+ console.error(e);
+ console.error(e.response.data);
+ reply.code(401).send(e.response.data);
+ }
+ });
+
+ fastify.post<{ Params: { id: string } }>('/v1/notification/:id/dismiss', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ try {
+ const notify = new ApiNotifyMastodon(_request, client);
+ reply.send(await notify.rmNotification());
+ } catch (e: any) {
+ console.error(e);
+ console.error(e.response.data);
+ reply.code(401).send(e.response.data);
+ }
+ });
+
+ fastify.post('/v1/notifications/clear', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ try {
+ const notify = new ApiNotifyMastodon(_request, client);
+ reply.send(await notify.rmNotifications());
+ } catch (e: any) {
+ console.error(e);
+ console.error(e.response.data);
+ reply.code(401).send(e.response.data);
+ }
+ });
+ //#endregion
+
+ //#region Filters
+ fastify.get<{ Params: { id: string } }>('/v1/filters/:id', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ try {
+ const filter = new ApiFilterMastodon(_request, client);
+ !_request.params.id ? reply.send(await filter.getFilters()) : reply.send(await filter.getFilter());
+ } catch (e: any) {
+ console.error(e);
+ console.error(e.response.data);
+ reply.code(401).send(e.response.data);
+ }
+ });
+
+ fastify.post('/v1/filters', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ try {
+ const filter = new ApiFilterMastodon(_request, client);
+ reply.send(await filter.createFilter());
+ } catch (e: any) {
+ console.error(e);
+ console.error(e.response.data);
+ reply.code(401).send(e.response.data);
+ }
+ });
+
+ fastify.post<{ Params: { id: string } }>('/v1/filters/:id', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ try {
+ const filter = new ApiFilterMastodon(_request, client);
+ reply.send(await filter.updateFilter());
+ } catch (e: any) {
+ console.error(e);
+ console.error(e.response.data);
+ reply.code(401).send(e.response.data);
+ }
+ });
+
+ fastify.delete<{ Params: { id: string } }>('/v1/filters/:id', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ try {
+ const filter = new ApiFilterMastodon(_request, client);
+ reply.send(await filter.rmFilter());
+ } catch (e: any) {
+ console.error(e);
+ console.error(e.response.data);
+ reply.code(401).send(e.response.data);
+ }
+ });
+ //#endregion
+
+ //#region Timelines
+ const TLEndpoint = new ApiTimelineMastodon(fastify);
+
+ // GET Endpoints
+ TLEndpoint.getTL();
+ TLEndpoint.getHomeTl();
+ TLEndpoint.getListTL();
+ TLEndpoint.getTagTl();
+ TLEndpoint.getConversations();
+ TLEndpoint.getList();
+ TLEndpoint.getLists();
+ TLEndpoint.getListAccounts();
+
+ // POST Endpoints
+ TLEndpoint.createList();
+ TLEndpoint.addListAccount();
+
+ // PUT Endpoint
+ TLEndpoint.updateList();
+
+ // DELETE Endpoints
+ TLEndpoint.deleteList();
+ TLEndpoint.rmListAccount();
+ //#endregion
+
+ //#region Status
+ const NoteEndpoint = new ApiStatusMastodon(fastify);
+
+ // GET Endpoints
+ NoteEndpoint.getStatus();
+ NoteEndpoint.getContext();
+ NoteEndpoint.getHistory();
+ NoteEndpoint.getReblogged();
+ NoteEndpoint.getFavourites();
+ NoteEndpoint.getMedia();
+ NoteEndpoint.getPoll();
+
+ //POST Endpoints
+ NoteEndpoint.postStatus();
+ NoteEndpoint.addFavourite();
+ NoteEndpoint.rmFavourite();
+ NoteEndpoint.reblogStatus();
+ NoteEndpoint.unreblogStatus();
+ NoteEndpoint.bookmarkStatus();
+ NoteEndpoint.unbookmarkStatus();
+ NoteEndpoint.pinStatus();
+ NoteEndpoint.unpinStatus();
+ NoteEndpoint.reactStatus();
+ NoteEndpoint.unreactStatus();
+ NoteEndpoint.votePoll();
+
+ // PUT Endpoint
+ NoteEndpoint.updateMedia();
+
+ // DELETE Endpoint
+ NoteEndpoint.deleteStatus();
+ //#endregion
+ done();
+ }
+}
diff --git a/packages/backend/src/server/api/mastodon/converters.ts b/packages/backend/src/server/api/mastodon/converters.ts
new file mode 100644
index 000000000..4621a50ff
--- /dev/null
+++ b/packages/backend/src/server/api/mastodon/converters.ts
@@ -0,0 +1,132 @@
+import { Entity } from 'megalodon';
+
+const CHAR_COLLECTION = '0123456789abcdefghijklmnopqrstuvwxyz';
+
+export enum IdConvertType {
+ MastodonId,
+ SharkeyId,
+}
+
+export function convertId(in_id: string, id_convert_type: IdConvertType): string {
+ switch (id_convert_type) {
+ case IdConvertType.MastodonId: {
+ let out = BigInt(0);
+ const lowerCaseId = in_id.toLowerCase();
+ for (let i = 0; i < lowerCaseId.length; i++) {
+ const charValue = numFromChar(lowerCaseId.charAt(i));
+ out += BigInt(charValue) * BigInt(36) ** BigInt(i);
+ }
+ return out.toString();
+ }
+
+ case IdConvertType.SharkeyId: {
+ let input = BigInt(in_id);
+ let outStr = '';
+ while (input > BigInt(0)) {
+ const remainder = Number(input % BigInt(36));
+ outStr = charFromNum(remainder) + outStr;
+ input /= BigInt(36);
+ }
+ const ReversedoutStr = outStr.split('').reduce((acc, char) => char + acc, '');
+ return ReversedoutStr;
+ }
+
+ default:
+ throw new Error('Invalid ID conversion type');
+ }
+}
+
+function numFromChar(character: string): number {
+ for (let i = 0; i < CHAR_COLLECTION.length; i++) {
+ if (CHAR_COLLECTION.charAt(i) === character) {
+ return i;
+ }
+ }
+
+ throw new Error('Invalid character in parsed base36 id');
+}
+
+function charFromNum(number: number): string {
+ if (number >= 0 && number < CHAR_COLLECTION.length) {
+ return CHAR_COLLECTION.charAt(number);
+ } else {
+ throw new Error('Invalid number for base-36 encoding');
+ }
+}
+
+function simpleConvert(data: any) {
+ // copy the object to bypass weird pass by reference bugs
+ const result = Object.assign({}, data);
+ result.id = convertId(data.id, IdConvertType.MastodonId);
+ return result;
+}
+
+export function convertAccount(account: Entity.Account) {
+ return simpleConvert(account);
+}
+export function convertAnnouncement(announcement: Entity.Announcement) {
+ return simpleConvert(announcement);
+}
+export function convertAttachment(attachment: Entity.Attachment) {
+ return simpleConvert(attachment);
+}
+export function convertFilter(filter: Entity.Filter) {
+ return simpleConvert(filter);
+}
+export function convertList(list: Entity.List) {
+ return simpleConvert(list);
+}
+export function convertFeaturedTag(tag: Entity.FeaturedTag) {
+ return simpleConvert(tag);
+}
+
+export function convertNotification(notification: Entity.Notification) {
+ notification.account = convertAccount(notification.account);
+ notification.id = convertId(notification.id, IdConvertType.MastodonId);
+ if (notification.status) notification.status = convertStatus(notification.status);
+ return notification;
+}
+
+export function convertPoll(poll: Entity.Poll) {
+ return simpleConvert(poll);
+}
+export function convertReaction(reaction: Entity.Reaction) {
+ if (reaction.accounts) {
+ reaction.accounts = reaction.accounts.map(convertAccount);
+ }
+ return reaction;
+}
+export function convertRelationship(relationship: Entity.Relationship) {
+ return simpleConvert(relationship);
+}
+
+export function convertStatus(status: Entity.Status) {
+ status.account = convertAccount(status.account);
+ status.id = convertId(status.id, IdConvertType.MastodonId);
+ if (status.in_reply_to_account_id) status.in_reply_to_account_id = convertId(
+ status.in_reply_to_account_id,
+ IdConvertType.MastodonId,
+ );
+ if (status.in_reply_to_id) status.in_reply_to_id = convertId(status.in_reply_to_id, IdConvertType.MastodonId);
+ status.media_attachments = status.media_attachments.map((attachment) =>
+ convertAttachment(attachment),
+ );
+ status.mentions = status.mentions.map((mention) => ({
+ ...mention,
+ id: convertId(mention.id, IdConvertType.MastodonId),
+ }));
+ if (status.poll) status.poll = convertPoll(status.poll);
+ if (status.reblog) status.reblog = convertStatus(status.reblog);
+
+ return status;
+}
+
+export function convertConversation(conversation: Entity.Conversation) {
+ conversation.id = convertId(conversation.id, IdConvertType.MastodonId);
+ conversation.accounts = conversation.accounts.map(convertAccount);
+ if (conversation.last_status) {
+ conversation.last_status = convertStatus(conversation.last_status);
+ }
+
+ return conversation;
+}
diff --git a/packages/backend/src/server/api/mastodon/endpoints.ts b/packages/backend/src/server/api/mastodon/endpoints.ts
new file mode 100644
index 000000000..5a7582389
--- /dev/null
+++ b/packages/backend/src/server/api/mastodon/endpoints.ts
@@ -0,0 +1,17 @@
+import { ApiAuthMastodon } from './endpoints/auth.js';
+import { ApiAccountMastodon } from './endpoints/account.js';
+import { ApiSearchMastodon } from './endpoints/search.js';
+import { ApiNotifyMastodon } from './endpoints/notifications.js';
+import { ApiFilterMastodon } from './endpoints/filter.js';
+import { ApiTimelineMastodon } from './endpoints/timeline.js';
+import { ApiStatusMastodon } from './endpoints/status.js';
+
+export {
+ ApiAccountMastodon,
+ ApiAuthMastodon,
+ ApiSearchMastodon,
+ ApiNotifyMastodon,
+ ApiFilterMastodon,
+ ApiTimelineMastodon,
+ ApiStatusMastodon,
+};
diff --git a/packages/backend/src/server/api/mastodon/endpoints/account.ts b/packages/backend/src/server/api/mastodon/endpoints/account.ts
new file mode 100644
index 000000000..23c96281f
--- /dev/null
+++ b/packages/backend/src/server/api/mastodon/endpoints/account.ts
@@ -0,0 +1,285 @@
+import { convertId, IdConvertType as IdType, convertAccount, convertRelationship, convertStatus } from '../converters.js';
+import { argsToBools, convertTimelinesArgsId, limitToInt } from './timeline.js';
+import type { MegalodonInterface } from 'megalodon';
+import type { FastifyRequest } from 'fastify';
+
+const relationshipModel = {
+ id: '',
+ following: false,
+ followed_by: false,
+ delivery_following: false,
+ blocking: false,
+ blocked_by: false,
+ muting: false,
+ muting_notifications: false,
+ requested: false,
+ domain_blocking: false,
+ showing_reblogs: false,
+ endorsed: false,
+ notifying: false,
+ note: '',
+};
+
+export class ApiAccountMastodon {
+ private request: FastifyRequest;
+ private client: MegalodonInterface;
+ private BASE_URL: string;
+
+ constructor(request: FastifyRequest, client: MegalodonInterface, BASE_URL: string) {
+ this.request = request;
+ this.client = client;
+ this.BASE_URL = BASE_URL;
+ }
+
+ public async verifyCredentials() {
+ try {
+ const data = await this.client.verifyAccountCredentials();
+ const acct = data.data;
+ acct.id = convertId(acct.id, IdType.MastodonId);
+ acct.display_name = acct.display_name || acct.username;
+ acct.url = `${this.BASE_URL}/@${acct.url}`;
+ acct.note = acct.note || '';
+ acct.avatar_static = acct.avatar;
+ acct.header = acct.header || '/static-assets/transparent.png';
+ acct.header_static = acct.header || '/static-assets/transparent.png';
+ acct.source = {
+ note: acct.note,
+ fields: acct.fields,
+ privacy: '',
+ sensitive: false,
+ language: '',
+ };
+ console.log(acct);
+ return acct;
+ } catch (e: any) {
+ console.error(e);
+ console.error(e.response.data);
+ return e.response.data;
+ }
+ }
+
+ public async updateCredentials() {
+ try {
+ const data = await this.client.updateCredentials(this.request.body as any);
+ return convertAccount(data.data);
+ } catch (e: any) {
+ console.error(e);
+ console.error(e.response.data);
+ return e.response.data;
+ }
+ }
+
+ public async lookup() {
+ try {
+ const data = await this.client.search((this.request.query as any).acct, { type: 'accounts' });
+ return convertAccount(data.data.accounts[0]);
+ } catch (e: any) {
+ console.error(e);
+ console.error(e.response.data);
+ return e.response.data;
+ }
+ }
+
+ public async getRelationships(users: [string]) {
+ try {
+ relationshipModel.id = users.toString() || '1';
+
+ if (!(users.length > 0)) {
+ return [relationshipModel];
+ }
+
+ const reqIds = [];
+ for (let i = 0; i < users.length; i++) {
+ reqIds.push(convertId(users[i], IdType.SharkeyId));
+ }
+
+ const data = await this.client.getRelationships(reqIds);
+ return data.data.map((relationship) => convertRelationship(relationship));
+ } catch (e: any) {
+ console.error(e);
+ console.error(e.response.data);
+ return e.response.data;
+ }
+ }
+
+ public async getStatuses() {
+ try {
+ const data = await this.client.getAccountStatuses(
+ convertId((this.request.params as any).id, IdType.SharkeyId),
+ convertTimelinesArgsId(argsToBools(limitToInt(this.request.query as any))),
+ );
+ return data.data.map((status) => convertStatus(status));
+ } catch (e: any) {
+ console.error(e);
+ console.error(e.response.data);
+ return e.response.data;
+ }
+ }
+
+ public async getFollowers() {
+ try {
+ const data = await this.client.getAccountFollowers(
+ convertId((this.request.params as any).id, IdType.SharkeyId),
+ convertTimelinesArgsId(limitToInt(this.request.query as any)),
+ );
+ return data.data.map((account) => convertAccount(account));
+ } catch (e: any) {
+ console.error(e);
+ console.error(e.response.data);
+ return e.response.data;
+ }
+ }
+
+ public async getFollowing() {
+ try {
+ const data = await this.client.getAccountFollowing(
+ convertId((this.request.params as any).id, IdType.SharkeyId),
+ convertTimelinesArgsId(limitToInt(this.request.query as any)),
+ );
+ return data.data.map((account) => convertAccount(account));
+ } catch (e: any) {
+ console.error(e);
+ console.error(e.response.data);
+ return e.response.data;
+ }
+ }
+
+ public async addFollow() {
+ try {
+ const data = await this.client.followAccount( convertId((this.request.params as any).id, IdType.SharkeyId) );
+ const acct = convertRelationship(data.data);
+ acct.following = true;
+ return acct;
+ } catch (e: any) {
+ console.error(e);
+ console.error(e.response.data);
+ return e.response.data;
+ }
+ }
+
+ public async rmFollow() {
+ try {
+ const data = await this.client.unfollowAccount( convertId((this.request.params as any).id, IdType.SharkeyId) );
+ const acct = convertRelationship(data.data);
+ acct.following = false;
+ return acct;
+ } catch (e: any) {
+ console.error(e);
+ console.error(e.response.data);
+ return e.response.data;
+ }
+ }
+
+ public async addBlock() {
+ try {
+ const data = await this.client.blockAccount( convertId((this.request.params as any).id, IdType.SharkeyId) );
+ return convertRelationship(data.data);
+ } catch (e: any) {
+ console.error(e);
+ console.error(e.response.data);
+ return e.response.data;
+ }
+ }
+
+ public async rmBlock() {
+ try {
+ const data = await this.client.unblockAccount( convertId((this.request.params as any).id, IdType.SharkeyId) );
+ return convertRelationship(data.data);
+ } catch (e: any) {
+ console.error(e);
+ console.error(e.response.data);
+ return e.response.data;
+ }
+ }
+
+ public async addMute() {
+ try {
+ const data = await this.client.muteAccount(
+ convertId((this.request.params as any).id, IdType.SharkeyId),
+ this.request.body as any,
+ );
+ return convertRelationship(data.data);
+ } catch (e: any) {
+ console.error(e);
+ console.error(e.response.data);
+ return e.response.data;
+ }
+ }
+
+ public async rmMute() {
+ try {
+ const data = await this.client.unmuteAccount( convertId((this.request.params as any).id, IdType.SharkeyId) );
+ return convertRelationship(data.data);
+ } catch (e: any) {
+ console.error(e);
+ console.error(e.response.data);
+ return e.response.data;
+ }
+ }
+
+ public async getBookmarks() {
+ try {
+ const data = await this.client.getBookmarks( convertTimelinesArgsId(limitToInt(this.request.query as any)) );
+ return data.data.map((status) => convertStatus(status));
+ } catch (e: any) {
+ console.error(e);
+ console.error(e.response.data);
+ return e.response.data;
+ }
+ }
+
+ public async getFavourites() {
+ try {
+ const data = await this.client.getFavourites( convertTimelinesArgsId(limitToInt(this.request.query as any)) );
+ return data.data.map((status) => convertStatus(status));
+ } catch (e: any) {
+ console.error(e);
+ console.error(e.response.data);
+ return e.response.data;
+ }
+ }
+
+ public async getMutes() {
+ try {
+ const data = await this.client.getMutes( convertTimelinesArgsId(limitToInt(this.request.query as any)) );
+ return data.data.map((account) => convertAccount(account));
+ } catch (e: any) {
+ console.error(e);
+ console.error(e.response.data);
+ return e.response.data;
+ }
+ }
+
+ public async getBlocks() {
+ try {
+ const data = await this.client.getBlocks( convertTimelinesArgsId(limitToInt(this.request.query as any)) );
+ return data.data.map((account) => convertAccount(account));
+ } catch (e: any) {
+ console.error(e);
+ console.error(e.response.data);
+ return e.response.data;
+ }
+ }
+
+ public async acceptFollow() {
+ try {
+ const data = await this.client.acceptFollowRequest( convertId((this.request.params as any).id, IdType.SharkeyId) );
+ return convertRelationship(data.data);
+ } catch (e: any) {
+ console.error(e);
+ console.error(e.response.data);
+ return e.response.data;
+ }
+ }
+
+ public async rejectFollow() {
+ try {
+ const data = await this.client.rejectFollowRequest( convertId((this.request.params as any).id, IdType.SharkeyId) );
+ return convertRelationship(data.data);
+ } catch (e: any) {
+ console.error(e);
+ console.error(e.response.data);
+ return e.response.data;
+ }
+ }
+}
diff --git a/packages/backend/src/server/api/mastodon/endpoints/auth.ts b/packages/backend/src/server/api/mastodon/endpoints/auth.ts
new file mode 100644
index 000000000..3eb92644b
--- /dev/null
+++ b/packages/backend/src/server/api/mastodon/endpoints/auth.ts
@@ -0,0 +1,74 @@
+import type { MegalodonInterface } from 'megalodon';
+import type { FastifyRequest } from 'fastify';
+
+const readScope = [
+ 'read:account',
+ 'read:drive',
+ 'read:blocks',
+ 'read:favorites',
+ 'read:following',
+ 'read:messaging',
+ 'read:mutes',
+ 'read:notifications',
+ 'read:reactions',
+ 'read:pages',
+ 'read:page-likes',
+ 'read:user-groups',
+ 'read:channels',
+ 'read:gallery',
+ 'read:gallery-likes',
+];
+
+const writeScope = [
+ 'write:account',
+ 'write:drive',
+ 'write:blocks',
+ 'write:favorites',
+ 'write:following',
+ 'write:messaging',
+ 'write:mutes',
+ 'write:notes',
+ 'write:notifications',
+ 'write:reactions',
+ 'write:votes',
+ 'write:pages',
+ 'write:page-likes',
+ 'write:user-groups',
+ 'write:channels',
+ 'write:gallery',
+ 'write:gallery-likes',
+];
+
+export async function ApiAuthMastodon(request: FastifyRequest, client: MegalodonInterface) {
+ const body: any = request.body || request.query;
+ try {
+ let scope = body.scopes;
+ if (typeof scope === 'string') scope = scope.split(' ');
+ const pushScope = new Set
();
+ for (const s of scope) {
+ if (s.match(/^read/)) for (const r of readScope) pushScope.add(r);
+ if (s.match(/^write/)) for (const r of writeScope) pushScope.add(r);
+ }
+ const scopeArr = Array.from(pushScope);
+
+ const red = body.redirect_uris;
+ const appData = await client.registerApp(body.client_name, {
+ scopes: scopeArr,
+ redirect_uris: red,
+ website: body.website,
+ });
+ const returns = {
+ id: Math.floor(Math.random() * 100).toString(),
+ name: appData.name,
+ website: body.website,
+ redirect_uri: red,
+ client_id: Buffer.from(appData.url || '').toString('base64'),
+ client_secret: appData.clientSecret,
+ };
+
+ return returns;
+ } catch (e: any) {
+ console.error(e);
+ return e.response.data;
+ }
+}
diff --git a/packages/backend/src/server/api/mastodon/endpoints/filter.ts b/packages/backend/src/server/api/mastodon/endpoints/filter.ts
new file mode 100644
index 000000000..e27bc956f
--- /dev/null
+++ b/packages/backend/src/server/api/mastodon/endpoints/filter.ts
@@ -0,0 +1,65 @@
+import { IdConvertType as IdType, convertId, convertFilter } from '../converters.js';
+import type { MegalodonInterface } from 'megalodon';
+import type { FastifyRequest } from 'fastify';
+
+export class ApiFilterMastodon {
+ private request: FastifyRequest;
+ private client: MegalodonInterface;
+
+ constructor(request: FastifyRequest, client: MegalodonInterface) {
+ this.request = request;
+ this.client = client;
+ }
+
+ public async getFilters() {
+ try {
+ const data = await this.client.getFilters();
+ return data.data.map((filter) => convertFilter(filter));
+ } catch (e: any) {
+ console.error(e);
+ return e.response.data;
+ }
+ }
+
+ public async getFilter() {
+ try {
+ const data = await this.client.getFilter( convertId((this.request.params as any).id, IdType.SharkeyId) );
+ return convertFilter(data.data);
+ } catch (e: any) {
+ console.error(e);
+ return e.response.data;
+ }
+ }
+
+ public async createFilter() {
+ try {
+ const body: any = this.request.body;
+ const data = await this.client.createFilter(body.pharse, body.context, body);
+ return convertFilter(data.data);
+ } catch (e: any) {
+ console.error(e);
+ return e.response.data;
+ }
+ }
+
+ public async updateFilter() {
+ try {
+ const body: any = this.request.body;
+ const data = await this.client.updateFilter(convertId((this.request.params as any).id, IdType.SharkeyId), body.pharse, body.context);
+ return convertFilter(data.data);
+ } catch (e: any) {
+ console.error(e);
+ return e.response.data;
+ }
+ }
+
+ public async rmFilter() {
+ try {
+ const data = await this.client.deleteFilter( convertId((this.request.params as any).id, IdType.SharkeyId) );
+ return data.data;
+ } catch (e: any) {
+ console.error(e);
+ return e.response.data;
+ }
+ }
+}
diff --git a/packages/backend/src/server/api/mastodon/endpoints/meta.ts b/packages/backend/src/server/api/mastodon/endpoints/meta.ts
new file mode 100644
index 000000000..77b643ff7
--- /dev/null
+++ b/packages/backend/src/server/api/mastodon/endpoints/meta.ts
@@ -0,0 +1,63 @@
+import { Entity } from 'megalodon';
+import { MAX_NOTE_TEXT_LENGTH, FILE_TYPE_BROWSERSAFE } from '@/const.js';
+import type { Config } from '@/config.js';
+import type { MiMeta } from '@/models/Meta.js';
+
+export async function getInstance(
+ response: Entity.Instance,
+ contact: Entity.Account,
+ config: Config,
+ meta: MiMeta,
+) {
+ return {
+ uri: config.url,
+ title: meta.name || 'Sharkey',
+ short_description:
+ meta.description?.substring(0, 50) || 'See real server website',
+ description:
+ meta.description ||
+ 'This is a vanilla Sharkey Instance. It doesn\'t seem to have a description.',
+ email: response.email || '',
+ version: `3.0.0 (compatible; Sharkey ${config.version})`,
+ urls: response.urls,
+ stats: {
+ user_count: response.stats.user_count,
+ status_count: response.stats.status_count,
+ domain_count: response.stats.domain_count,
+ },
+ thumbnail: meta.backgroundImageUrl || '/static-assets/transparent.png',
+ languages: meta.langs,
+ registrations: !meta.disableRegistration || response.registrations,
+ approval_required: !response.registrations,
+ invites_enabled: response.registrations,
+ configuration: {
+ accounts: {
+ max_featured_tags: 20,
+ },
+ statuses: {
+ max_characters: MAX_NOTE_TEXT_LENGTH,
+ max_media_attachments: 16,
+ characters_reserved_per_url: response.uri.length,
+ },
+ media_attachments: {
+ supported_mime_types: FILE_TYPE_BROWSERSAFE,
+ image_size_limit: 10485760,
+ image_matrix_limit: 16777216,
+ video_size_limit: 41943040,
+ video_frame_rate_limit: 60,
+ video_matrix_limit: 2304000,
+ },
+ polls: {
+ max_options: 10,
+ max_characters_per_option: 50,
+ min_expiration: 50,
+ max_expiration: 2629746,
+ },
+ reactions: {
+ max_reactions: 1,
+ },
+ },
+ contact_account: contact,
+ rules: [],
+ };
+}
diff --git a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts
new file mode 100644
index 000000000..dc801dd05
--- /dev/null
+++ b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts
@@ -0,0 +1,71 @@
+import { IdConvertType as IdType, convertId, convertNotification } from '../converters.js';
+import { convertTimelinesArgsId } from './timeline.js';
+import type { MegalodonInterface, Entity } from 'megalodon';
+import type { FastifyRequest } from 'fastify';
+
+function toLimitToInt(q: any) {
+ if (q.limit) if (typeof q.limit === 'string') q.limit = parseInt(q.limit, 10);
+ return q;
+}
+
+export class ApiNotifyMastodon {
+ private request: FastifyRequest;
+ private client: MegalodonInterface;
+
+ constructor(request: FastifyRequest, client: MegalodonInterface) {
+ this.request = request;
+ this.client = client;
+ }
+
+ public async getNotifications() {
+ try {
+ const data = await this.client.getNotifications( convertTimelinesArgsId(toLimitToInt(this.request.query)) );
+ const notifs = data.data;
+ const processed = notifs.map((n: Entity.Notification) => {
+ const convertedn = convertNotification(n);
+ if (convertedn.type !== 'follow' && convertedn.type !== 'follow_request') {
+ if (convertedn.type === 'reaction') convertedn.type = 'favourite';
+ return convertedn;
+ } else {
+ return convertedn;
+ }
+ });
+ return processed;
+ } catch (e: any) {
+ console.error(e);
+ return e.response.data;
+ }
+ }
+
+ public async getNotification() {
+ try {
+ const data = await this.client.getNotification( convertId((this.request.params as any).id, IdType.SharkeyId) );
+ const notif = convertNotification(data.data);
+ if (notif.type !== 'follow' && notif.type !== 'follow_request' && notif.type === 'reaction') notif.type = 'favourite';
+ return notif;
+ } catch (e: any) {
+ console.error(e);
+ return e.response.data;
+ }
+ }
+
+ public async rmNotification() {
+ try {
+ const data = await this.client.dismissNotification( convertId((this.request.params as any).id, IdType.SharkeyId) );
+ return data.data;
+ } catch (e: any) {
+ console.error(e);
+ return e.response.data;
+ }
+ }
+
+ public async rmNotifications() {
+ try {
+ const data = await this.client.dismissNotifications();
+ return data.data;
+ } catch (e: any) {
+ console.error(e);
+ return e.response.data;
+ }
+ }
+}
diff --git a/packages/backend/src/server/api/mastodon/endpoints/search.ts b/packages/backend/src/server/api/mastodon/endpoints/search.ts
new file mode 100644
index 000000000..5c68402ed
--- /dev/null
+++ b/packages/backend/src/server/api/mastodon/endpoints/search.ts
@@ -0,0 +1,131 @@
+import { Converter } from 'megalodon';
+import { convertAccount, convertStatus } from '../converters.js';
+import { convertTimelinesArgsId, limitToInt } from './timeline.js';
+import type { MegalodonInterface } from 'megalodon';
+import type { FastifyRequest } from 'fastify';
+
+async function getHighlight(
+ BASE_URL: string,
+ domain: string,
+ accessTokens: string | undefined,
+) {
+ const accessTokenArr = accessTokens?.split(' ') ?? [null];
+ const accessToken = accessTokenArr[accessTokenArr.length - 1];
+ try {
+ const apicall = await fetch(`${BASE_URL}/api/notes/featured`,
+ {
+ method: 'POST',
+ headers: {
+ 'Accept': 'application/json, text/plain, */*',
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ i: accessToken }),
+ });
+ const api = await apicall.json();
+ const data: MisskeyEntity.Note[] = api;
+ return data.map((note) => Converter.note(note, domain));
+ } catch (e: any) {
+ console.log(e);
+ console.log(e.response.data);
+ return [];
+ }
+}
+
+async function getFeaturedUser( BASE_URL: string, host: string, accessTokens: string | undefined, limit: number ) {
+ const accessTokenArr = accessTokens?.split(' ') ?? [null];
+ const accessToken = accessTokenArr[accessTokenArr.length - 1];
+ try {
+ const apicall = await fetch(`${BASE_URL}/api/users`,
+ {
+ method: 'POST',
+ headers: {
+ 'Accept': 'application/json, text/plain, */*',
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ i: accessToken, limit, origin: 'local', sort: '+follower', state: 'alive' }),
+ });
+ const api = await apicall.json();
+ const data: MisskeyEntity.UserDetail[] = api;
+ return data.map((u) => {
+ return {
+ source: 'past_interactions',
+ account: Converter.userDetail(u, host),
+ };
+ });
+ } catch (e: any) {
+ console.log(e);
+ console.log(e.response.data);
+ return [];
+ }
+}
+export class ApiSearchMastodon {
+ private request: FastifyRequest;
+ private client: MegalodonInterface;
+ private BASE_URL: string;
+
+ constructor(request: FastifyRequest, client: MegalodonInterface, BASE_URL: string) {
+ this.request = request;
+ this.client = client;
+ this.BASE_URL = BASE_URL;
+ }
+
+ public async SearchV1() {
+ try {
+ const query: any = convertTimelinesArgsId(limitToInt(this.request.query as any));
+ const type = query.type || '';
+ const data = await this.client.search(query.q, { type: type, ...query });
+ return data.data;
+ } catch (e: any) {
+ console.error(e);
+ return e.response.data;
+ }
+ }
+
+ public async SearchV2() {
+ try {
+ const query: any = convertTimelinesArgsId(limitToInt(this.request.query as any));
+ const type = query.type;
+ const acct = !type || type === 'accounts' ? await this.client.search(query.q, { type: 'accounts', ...query }) : null;
+ const stat = !type || type === 'statuses' ? await this.client.search(query.q, { type: 'statuses', ...query }) : null;
+ const tags = !type || type === 'hashtags' ? await this.client.search(query.q, { type: 'hashtags', ...query }) : null;
+ const data = {
+ accounts: acct?.data.accounts.map((account) => convertAccount(account)) ?? [],
+ statuses: stat?.data.statuses.map((status) => convertStatus(status)) ?? [],
+ hashtags: tags?.data.hashtags ?? [],
+ };
+ return data;
+ } catch (e: any) {
+ console.error(e);
+ return e.response.data;
+ }
+ }
+
+ public async getStatusTrends() {
+ try {
+ const data = await getHighlight(
+ this.BASE_URL,
+ this.request.hostname,
+ this.request.headers.authorization,
+ );
+ return data.map((status) => convertStatus(status));
+ } catch (e: any) {
+ console.error(e);
+ return e.response.data;
+ }
+ }
+
+ public async getSuggestions() {
+ try {
+ const data = await getFeaturedUser(
+ this.BASE_URL,
+ this.request.hostname,
+ this.request.headers.authorization,
+ (this.request.query as any).limit || 20,
+ );
+ return data.map((suggestion) => { suggestion.account = convertAccount(suggestion.account); return suggestion; });
+ } catch (e: any) {
+ console.error(e);
+ return e.response.data;
+ }
+ }
+}
diff --git a/packages/backend/src/server/api/mastodon/endpoints/status.ts b/packages/backend/src/server/api/mastodon/endpoints/status.ts
new file mode 100644
index 000000000..5ce0c8941
--- /dev/null
+++ b/packages/backend/src/server/api/mastodon/endpoints/status.ts
@@ -0,0 +1,400 @@
+import querystring from 'querystring';
+import { emojiRegexAtStartToEnd } from '@/misc/emoji-regex.js';
+import { convertId, IdConvertType as IdType, convertAccount, convertAttachment, convertPoll, convertStatus } from '../converters.js';
+import { getClient } from '../MastodonApiServerService.js';
+import { convertTimelinesArgsId, limitToInt } from './timeline.js';
+import type { Entity } from 'megalodon';
+import type { FastifyInstance } from 'fastify';
+
+function normalizeQuery(data: any) {
+ const str = querystring.stringify(data);
+ return querystring.parse(str);
+}
+
+export class ApiStatusMastodon {
+ private fastify: FastifyInstance;
+
+ constructor(fastify: FastifyInstance) {
+ this.fastify = fastify;
+ }
+
+ public async getStatus() {
+ this.fastify.get<{ Params: { id: string } }>('/v1/statuses/:id', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ try {
+ const data = await client.getStatus(convertId(_request.params.id, IdType.SharkeyId));
+ reply.send(convertStatus(data.data));
+ } catch (e: any) {
+ console.error(e);
+ reply.code(_request.is404 ? 404 : 401).send(e.response.data);
+ }
+ });
+ }
+
+ public async getContext() {
+ this.fastify.get<{ Params: { id: string } }>('/v1/statuses/:id/context', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ const query: any = _request.query;
+ try {
+ const data = await client.getStatusContext(
+ convertId(_request.params.id, IdType.SharkeyId),
+ convertTimelinesArgsId(limitToInt(query)),
+ );
+ data.data.ancestors = data.data.ancestors.map((status: Entity.Status) => convertStatus(status));
+ data.data.descendants = data.data.descendants.map((status: Entity.Status) => convertStatus(status));
+ reply.send(data.data);
+ } catch (e: any) {
+ console.error(e);
+ reply.code(_request.is404 ? 404 : 401).send(e.response.data);
+ }
+ });
+ }
+
+ public async getHistory() {
+ this.fastify.get<{ Params: { id: string } }>('/v1/statuses/:id/history', async (_request, reply) => {
+ try {
+ reply.code(401).send({ message: 'Not Implemented' });
+ } catch (e: any) {
+ console.error(e);
+ reply.code(401).send(e.response.data);
+ }
+ });
+ }
+
+ public async getReblogged() {
+ this.fastify.get<{ Params: { id: string } }>('/v1/statuses/:id/reblogged_by', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ try {
+ const data = await client.getStatusRebloggedBy(convertId(_request.params.id, IdType.SharkeyId));
+ reply.send(data.data.map((account: Entity.Account) => convertAccount(account)));
+ } catch (e: any) {
+ console.error(e);
+ reply.code(401).send(e.response.data);
+ }
+ });
+ }
+
+ public async getFavourites() {
+ this.fastify.get<{ Params: { id: string } }>('/v1/statuses/:id/favourited_by', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ try {
+ const data = await client.getStatusFavouritedBy(convertId(_request.params.id, IdType.SharkeyId));
+ reply.send(data.data.map((account: Entity.Account) => convertAccount(account)));
+ } catch (e: any) {
+ console.error(e);
+ reply.code(401).send(e.response.data);
+ }
+ });
+ }
+
+ public async getMedia() {
+ this.fastify.get<{ Params: { id: string } }>('/v1/media/:id', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ try {
+ const data = await client.getMedia(convertId(_request.params.id, IdType.SharkeyId));
+ reply.send(convertAttachment(data.data));
+ } catch (e: any) {
+ console.error(e);
+ reply.code(401).send(e.response.data);
+ }
+ });
+ }
+
+ public async getPoll() {
+ this.fastify.get<{ Params: { id: string } }>('/v1/polls/:id', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ try {
+ const data = await client.getPoll(convertId(_request.params.id, IdType.SharkeyId));
+ reply.send(convertPoll(data.data));
+ } catch (e: any) {
+ console.error(e);
+ reply.code(401).send(e.response.data);
+ }
+ });
+ }
+
+ public async votePoll() {
+ this.fastify.post<{ Params: { id: string } }>('/v1/polls/:id/votes', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ const body: any = _request.body;
+ try {
+ const data = await client.votePoll(convertId(_request.params.id, IdType.SharkeyId), body.choices);
+ reply.send(convertPoll(data.data));
+ } catch (e: any) {
+ console.error(e);
+ reply.code(401).send(e.response.data);
+ }
+ });
+ }
+
+ public async postStatus() {
+ this.fastify.post('/v1/statuses', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ let body: any = _request.body;
+ try {
+ if (body.in_reply_to_id) body.in_reply_to_id = convertId(body.in_reply_to_id, IdType.SharkeyId);
+ if (body.quote_id) body.quote_id = convertId(body.quote_id, IdType.SharkeyId);
+ if (
+ (!body.poll && body['poll[options][]']) ||
+ (!body.media_ids && body['media_ids[]'])
+ ) {
+ body = normalizeQuery(body);
+ }
+ const text = body.status;
+ const removed = text.replace(/@\S+/g, '').replace(/\s|/g, '');
+ const isDefaultEmoji = emojiRegexAtStartToEnd.test(removed);
+ const isCustomEmoji = /^:[a-zA-Z0-9@_]+:$/.test(removed);
+ if ((body.in_reply_to_id && isDefaultEmoji) || isCustomEmoji) {
+ const a = await client.createEmojiReaction(
+ body.in_reply_to_id,
+ removed,
+ );
+ reply.send(a.data);
+ }
+ if (body.in_reply_to_id && removed === '/unreact') {
+ try {
+ const id = body.in_reply_to_id;
+ const post = await client.getStatus(id);
+ const react = post.data.emoji_reactions.filter((e: any) => e.me)[0].name;
+ const data = await client.deleteEmojiReaction(id, react);
+ reply.send(data.data);
+ } catch (e: any) {
+ console.error(e);
+ reply.code(401).send(e.response.data);
+ }
+ }
+ if (!body.media_ids) body.media_ids = undefined;
+ if (body.media_ids && !body.media_ids.length) body.media_ids = undefined;
+ if (body.media_ids) {
+ body.media_ids = (body.media_ids as string[]).map((p) => convertId(p, IdType.SharkeyId));
+ }
+
+ const { sensitive } = body;
+ body.sensitive = typeof sensitive === 'string' ? sensitive === 'true' : sensitive;
+
+ if (body.poll) {
+ if (
+ body.poll.expires_in != null &&
+ typeof body.poll.expires_in === 'string'
+ ) body.poll.expires_in = parseInt(body.poll.expires_in);
+ if (
+ body.poll.multiple != null &&
+ typeof body.poll.multiple === 'string'
+ ) body.poll.multiple = body.poll.multiple === 'true';
+ if (
+ body.poll.hide_totals != null &&
+ typeof body.poll.hide_totals === 'string'
+ ) body.poll.hide_totals = body.poll.hide_totals === 'true';
+ }
+
+ const data = await client.postStatus(text, body);
+ reply.send(convertStatus(data.data as Entity.Status));
+ } catch (e: any) {
+ console.error(e);
+ reply.code(401).send(e.response.data);
+ }
+ });
+ }
+
+ public async addFavourite() {
+ this.fastify.post<{ Params: { id: string } }>('/v1/statuses/:id/favourite', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ try {
+ const data = (await client.createEmojiReaction(
+ convertId(_request.params.id, IdType.SharkeyId),
+ '⭐',
+ )) as any;
+ reply.send(convertStatus(data.data));
+ } catch (e: any) {
+ console.error(e);
+ reply.code(401).send(e.response.data);
+ }
+ });
+ }
+
+ public async rmFavourite() {
+ this.fastify.post<{ Params: { id: string } }>('/v1/statuses/:id/unfavourite', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ try {
+ const data = await client.deleteEmojiReaction(
+ convertId(_request.params.id, IdType.SharkeyId),
+ '⭐',
+ );
+ reply.send(convertStatus(data.data));
+ } catch (e: any) {
+ console.error(e);
+ reply.code(401).send(e.response.data);
+ }
+ });
+ }
+
+ public async reblogStatus() {
+ this.fastify.post<{ Params: { id: string } }>('/v1/statuses/:id/reblog', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ try {
+ const data = await client.reblogStatus(convertId(_request.params.id, IdType.SharkeyId));
+ reply.send(convertStatus(data.data));
+ } catch (e: any) {
+ console.error(e);
+ reply.code(401).send(e.response.data);
+ }
+ });
+ }
+
+ public async unreblogStatus() {
+ this.fastify.post<{ Params: { id: string } }>('/v1/statuses/:id/unreblog', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ try {
+ const data = await client.unreblogStatus(convertId(_request.params.id, IdType.SharkeyId));
+ reply.send(convertStatus(data.data));
+ } catch (e: any) {
+ console.error(e);
+ reply.code(401).send(e.response.data);
+ }
+ });
+ }
+
+ public async bookmarkStatus() {
+ this.fastify.post<{ Params: { id: string } }>('/v1/statuses/:id/bookmark', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ try {
+ const data = await client.bookmarkStatus(convertId(_request.params.id, IdType.SharkeyId));
+ reply.send(convertStatus(data.data));
+ } catch (e: any) {
+ console.error(e);
+ reply.code(401).send(e.response.data);
+ }
+ });
+ }
+
+ public async unbookmarkStatus() {
+ this.fastify.post<{ Params: { id: string } }>('/v1/statuses/:id/unbookmark', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ try {
+ const data = await client.unbookmarkStatus(convertId(_request.params.id, IdType.SharkeyId));
+ reply.send(convertStatus(data.data));
+ } catch (e: any) {
+ console.error(e);
+ reply.code(401).send(e.response.data);
+ }
+ });
+ }
+
+ public async pinStatus() {
+ this.fastify.post<{ Params: { id: string } }>('/v1/statuses/:id/pin', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ try {
+ const data = await client.pinStatus(convertId(_request.params.id, IdType.SharkeyId));
+ reply.send(convertStatus(data.data));
+ } catch (e: any) {
+ console.error(e);
+ reply.code(401).send(e.response.data);
+ }
+ });
+ }
+
+ public async unpinStatus() {
+ this.fastify.post<{ Params: { id: string } }>('/v1/statuses/:id/unpin', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ try {
+ const data = await client.unpinStatus(convertId(_request.params.id, IdType.SharkeyId));
+ reply.send(convertStatus(data.data));
+ } catch (e: any) {
+ console.error(e);
+ reply.code(401).send(e.response.data);
+ }
+ });
+ }
+
+ public async reactStatus() {
+ this.fastify.post<{ Params: { id: string, name: string } }>('/v1/statuses/:id/react/:name', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ try {
+ const data = await client.createEmojiReaction(convertId(_request.params.id, IdType.SharkeyId), _request.params.name);
+ reply.send(convertStatus(data.data));
+ } catch (e: any) {
+ console.error(e);
+ reply.code(401).send(e.response.data);
+ }
+ });
+ }
+
+ public async unreactStatus() {
+ this.fastify.post<{ Params: { id: string, name: string } }>('/v1/statuses/:id/unreact/:name', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ try {
+ const data = await client.deleteEmojiReaction(convertId(_request.params.id, IdType.SharkeyId), _request.params.name);
+ reply.send(convertStatus(data.data));
+ } catch (e: any) {
+ console.error(e);
+ reply.code(401).send(e.response.data);
+ }
+ });
+ }
+
+ public async updateMedia() {
+ this.fastify.put<{ Params: { id: string } }>('/v1/media/:id', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ try {
+ const data = await client.updateMedia(convertId(_request.params.id, IdType.SharkeyId), _request.body as any);
+ reply.send(convertAttachment(data.data));
+ } catch (e: any) {
+ console.error(e);
+ reply.code(401).send(e.response.data);
+ }
+ });
+ }
+
+ public async deleteStatus() {
+ this.fastify.delete<{ Params: { id: string } }>('/v1/statuses/:id', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ try {
+ const data = await client.deleteStatus(convertId(_request.params.id, IdType.SharkeyId));
+ reply.send(data.data);
+ } catch (e: any) {
+ console.error(e);
+ reply.code(401).send(e.response.data);
+ }
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts
new file mode 100644
index 000000000..a17120516
--- /dev/null
+++ b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts
@@ -0,0 +1,282 @@
+import { ParsedUrlQuery } from 'querystring';
+import { convertId, IdConvertType as IdType, convertAccount, convertConversation, convertList, convertStatus } from '../converters.js';
+import { getClient } from '../MastodonApiServerService.js';
+import type { Entity } from 'megalodon';
+import type { FastifyInstance } from 'fastify';
+
+export function limitToInt(q: ParsedUrlQuery) {
+ const object: any = q;
+ if (q.limit) if (typeof q.limit === 'string') object.limit = parseInt(q.limit, 10);
+ if (q.offset) if (typeof q.offset === 'string') object.offset = parseInt(q.offset, 10);
+ return object;
+}
+
+export function argsToBools(q: ParsedUrlQuery) {
+ // Values taken from https://docs.joinmastodon.org/client/intro/#boolean
+ const toBoolean = (value: string) =>
+ !['0', 'f', 'F', 'false', 'FALSE', 'off', 'OFF'].includes(value);
+
+ // Keys taken from:
+ // - https://docs.joinmastodon.org/methods/accounts/#statuses
+ // - https://docs.joinmastodon.org/methods/timelines/#public
+ // - https://docs.joinmastodon.org/methods/timelines/#tag
+ const object: any = q;
+ if (q.only_media) if (typeof q.only_media === 'string') object.only_media = toBoolean(q.only_media);
+ if (q.exclude_replies) if (typeof q.exclude_replies === 'string') object.exclude_replies = toBoolean(q.exclude_replies);
+ if (q.exclude_reblogs) if (typeof q.exclude_reblogs === 'string') object.exclude_reblogs = toBoolean(q.exclude_reblogs);
+ if (q.pinned) if (typeof q.pinned === 'string') object.pinned = toBoolean(q.pinned);
+ if (q.local) if (typeof q.local === 'string') object.local = toBoolean(q.local);
+ return q;
+}
+
+export function convertTimelinesArgsId(q: ParsedUrlQuery) {
+ if (typeof q.min_id === 'string') q.min_id = convertId(q.min_id, IdType.SharkeyId);
+ if (typeof q.max_id === 'string') q.max_id = convertId(q.max_id, IdType.SharkeyId);
+ if (typeof q.since_id === 'string') q.since_id = convertId(q.since_id, IdType.SharkeyId);
+ return q;
+}
+
+export class ApiTimelineMastodon {
+ private fastify: FastifyInstance;
+
+ constructor(fastify: FastifyInstance) {
+ this.fastify = fastify;
+ }
+
+ public async getTL() {
+ this.fastify.get('/v1/timelines/public', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ try {
+ const query: any = _request.query;
+ const data = query.local === 'true'
+ ? await client.getLocalTimeline(convertTimelinesArgsId(argsToBools(limitToInt(query))))
+ : await client.getPublicTimeline(convertTimelinesArgsId(argsToBools(limitToInt(query))));
+ reply.send(data.data.map((status: Entity.Status) => convertStatus(status)));
+ } catch (e: any) {
+ console.error(e);
+ console.error(e.response.data);
+ reply.code(401).send(e.response.data);
+ }
+ });
+ }
+
+ public async getHomeTl() {
+ this.fastify.get('/v1/timelines/home', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ try {
+ const query: any = _request.query;
+ const data = await client.getHomeTimeline(convertTimelinesArgsId(limitToInt(query)));
+ reply.send(data.data.map((status: Entity.Status) => convertStatus(status)));
+ } catch (e: any) {
+ console.error(e);
+ console.error(e.response.data);
+ reply.code(401).send(e.response.data);
+ }
+ });
+ }
+
+ public async getTagTl() {
+ this.fastify.get<{ Params: { hashtag: string } }>('/v1/timelines/tag/:hashtag', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ try {
+ const query: any = _request.query;
+ const params: any = _request.params;
+ const data = await client.getTagTimeline(params.hashtag, convertTimelinesArgsId(limitToInt(query)));
+ reply.send(data.data.map((status: Entity.Status) => convertStatus(status)));
+ } catch (e: any) {
+ console.error(e);
+ console.error(e.response.data);
+ reply.code(401).send(e.response.data);
+ }
+ });
+ }
+
+ public async getListTL() {
+ this.fastify.get<{ Params: { id: string } }>('/v1/timelines/list/:id', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ try {
+ const query: any = _request.query;
+ const params: any = _request.params;
+ const data = await client.getListTimeline(convertId(params.id, IdType.SharkeyId), convertTimelinesArgsId(limitToInt(query)));
+ reply.send(data.data.map((status: Entity.Status) => convertStatus(status)));
+ } catch (e: any) {
+ console.error(e);
+ console.error(e.response.data);
+ reply.code(401).send(e.response.data);
+ }
+ });
+ }
+
+ public async getConversations() {
+ this.fastify.get('/v1/conversations', async (_request, reply) => {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ try {
+ const query: any = _request.query;
+ const data = await client.getConversationTimeline(convertTimelinesArgsId(limitToInt(query)));
+ reply.send(data.data.map((conversation: Entity.Conversation) => convertConversation(conversation)));
+ } catch (e: any) {
+ console.error(e);
+ console.error(e.response.data);
+ reply.code(401).send(e.response.data);
+ }
+ });
+ }
+
+ public async getList() {
+ this.fastify.get<{ Params: { id: string } }>('/v1/lists/:id', async (_request, reply) => {
+ try {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ const params: any = _request.params;
+ const data = await client.getList(convertId(params.id, IdType.SharkeyId));
+ reply.send(convertList(data.data));
+ } catch (e: any) {
+ console.error(e);
+ console.error(e.response.data);
+ reply.code(401).send(e.response.data);
+ }
+ });
+ }
+
+ public async getLists() {
+ this.fastify.get('/v1/lists', async (_request, reply) => {
+ try {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ const account = await client.verifyAccountCredentials();
+ const data = await client.getLists(account.data.id);
+ reply.send(data.data.map((list: Entity.List) => convertList(list)));
+ } catch (e: any) {
+ console.error(e);
+ return e.response.data;
+ }
+ });
+ }
+
+ public async getListAccounts() {
+ this.fastify.get<{ Params: { id: string } }>('/v1/lists/:id/accounts', async (_request, reply) => {
+ try {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ const params: any = _request.params;
+ const query: any = _request.query;
+ const data = await client.getAccountsInList(
+ convertId(params.id, IdType.SharkeyId),
+ convertTimelinesArgsId(query),
+ );
+ reply.send(data.data.map((account: Entity.Account) => convertAccount(account)));
+ } catch (e: any) {
+ console.error(e);
+ console.error(e.response.data);
+ reply.code(401).send(e.response.data);
+ }
+ });
+ }
+
+ public async addListAccount() {
+ this.fastify.post<{ Params: { id: string } }>('/v1/lists/:id/accounts', async (_request, reply) => {
+ try {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ const params: any = _request.params;
+ const query: any = _request.query;
+ const data = await client.addAccountsToList(
+ convertId(params.id, IdType.SharkeyId),
+ (query.accounts_id as string[]).map((id) => convertId(id, IdType.SharkeyId)),
+ );
+ reply.send(data.data);
+ } catch (e: any) {
+ console.error(e);
+ console.error(e.response.data);
+ reply.code(401).send(e.response.data);
+ }
+ });
+ }
+
+ public async rmListAccount() {
+ this.fastify.delete<{ Params: { id: string } }>('/v1/lists/:id/accounts', async (_request, reply) => {
+ try {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ const params: any = _request.params;
+ const query: any = _request.query;
+ const data = await client.deleteAccountsFromList(
+ convertId(params.id, IdType.SharkeyId),
+ (query.accounts_id as string[]).map((id) => convertId(id, IdType.SharkeyId)),
+ );
+ reply.send(data.data);
+ } catch (e: any) {
+ console.error(e);
+ console.error(e.response.data);
+ reply.code(401).send(e.response.data);
+ }
+ });
+ }
+
+ public async createList() {
+ this.fastify.post('/v1/lists', async (_request, reply) => {
+ try {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ const body: any = _request.body;
+ const data = await client.createList(body.title);
+ reply.send(convertList(data.data));
+ } catch (e: any) {
+ console.error(e);
+ console.error(e.response.data);
+ reply.code(401).send(e.response.data);
+ }
+ });
+ }
+
+ public async updateList() {
+ this.fastify.put<{ Params: { id: string } }>('/v1/lists/:id', async (_request, reply) => {
+ try {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ const body: any = _request.body;
+ const params: any = _request.params;
+ const data = await client.updateList(convertId(params.id, IdType.SharkeyId), body.title);
+ reply.send(convertList(data.data));
+ } catch (e: any) {
+ console.error(e);
+ console.error(e.response.data);
+ reply.code(401).send(e.response.data);
+ }
+ });
+ }
+
+ public async deleteList() {
+ this.fastify.delete<{ Params: { id: string } }>('/v1/lists/:id', async (_request, reply) => {
+ try {
+ const BASE_URL = `${_request.protocol}://${_request.hostname}`;
+ const accessTokens = _request.headers.authorization;
+ const client = getClient(BASE_URL, accessTokens);
+ const params: any = _request.params;
+ const data = await client.deleteList(convertId(params.id, IdType.SharkeyId));
+ reply.send(data.data);
+ } catch (e: any) {
+ console.error(e);
+ console.error(e.response.data);
+ reply.code(401).send(e.response.data);
+ }
+ });
+ }
+}
diff --git a/packages/megalodon/.npmignore b/packages/megalodon/.npmignore
new file mode 100644
index 000000000..fd54d1deb
--- /dev/null
+++ b/packages/megalodon/.npmignore
@@ -0,0 +1,3 @@
+node_modules
+./src
+tsconfig.json
diff --git a/packages/megalodon/package.json b/packages/megalodon/package.json
new file mode 100644
index 000000000..ebd958834
--- /dev/null
+++ b/packages/megalodon/package.json
@@ -0,0 +1,87 @@
+{
+ "name": "megalodon",
+ "version": "7.0.1",
+ "description": "Mastodon API client for node.js and browser",
+ "main": "./lib/src/index.js",
+ "typings": "./lib/src/index.d.ts",
+ "scripts": {
+ "build": "tsc -p ./",
+ "lint": "eslint --ext .js,.ts src",
+ "doc": "typedoc --out ../docs ./src",
+ "test": "NODE_ENV=test jest -u --maxWorkers=3"
+ },
+ "engines": {
+ "node": ">=15.0.0"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/h3poteto/megalodon.git"
+ },
+ "keywords": [
+ "mastodon",
+ "client",
+ "api",
+ "streaming",
+ "rest",
+ "proxy"
+ ],
+ "author": "h3poteto",
+ "license": "MIT",
+ "bugs": {
+ "url": "https://github.com/h3poteto/megalodon/issues"
+ },
+ "jest": {
+ "moduleFileExtensions": [
+ "ts",
+ "js"
+ ],
+ "moduleNameMapper": {
+ "^@/(.+)": "/src/$1",
+ "^~/(.+)": "/$1"
+ },
+ "testMatch": [
+ "**/test/**/*.spec.ts"
+ ],
+ "preset": "ts-jest/presets/default",
+ "transform": {
+ "^.+\\.(ts|tsx)$": ["ts-jest", {
+ "tsconfig": "tsconfig.json"
+ }]
+ },
+ "testEnvironment": "node"
+ },
+ "homepage": "https://github.com/h3poteto/megalodon#readme",
+ "dependencies": {
+ "@types/oauth": "^0.9.2",
+ "@types/ws": "^8.5.5",
+ "axios": "1.5.0",
+ "dayjs": "^1.11.9",
+ "form-data": "^4.0.0",
+ "https-proxy-agent": "^7.0.2",
+ "oauth": "^0.10.0",
+ "object-assign-deep": "^0.4.0",
+ "parse-link-header": "^2.0.0",
+ "socks-proxy-agent": "^8.0.2",
+ "typescript": "5.1.6",
+ "uuid": "^9.0.1",
+ "ws": "8.14.2"
+ },
+ "devDependencies": {
+ "@types/core-js": "^2.5.6",
+ "@types/form-data": "^2.5.0",
+ "@types/jest": "^29.5.5",
+ "@types/object-assign-deep": "^0.4.1",
+ "@types/parse-link-header": "^2.0.1",
+ "@types/uuid": "^9.0.4",
+ "@typescript-eslint/eslint-plugin": "^6.7.2",
+ "@typescript-eslint/parser": "^6.7.2",
+ "eslint": "^8.49.0",
+ "eslint-config-prettier": "^9.0.0",
+ "jest": "^29.7.0",
+ "jest-worker": "^29.7.0",
+ "lodash": "^4.17.14",
+ "prettier": "^3.0.3",
+ "ts-jest": "^29.1.1",
+ "typedoc": "^0.25.1"
+ }
+}
diff --git a/packages/megalodon/src/axios.d.ts b/packages/megalodon/src/axios.d.ts
new file mode 100644
index 000000000..114cb06aa
--- /dev/null
+++ b/packages/megalodon/src/axios.d.ts
@@ -0,0 +1 @@
+declare module 'axios/lib/adapters/http'
diff --git a/packages/megalodon/src/cancel.ts b/packages/megalodon/src/cancel.ts
new file mode 100644
index 000000000..3b905a492
--- /dev/null
+++ b/packages/megalodon/src/cancel.ts
@@ -0,0 +1,13 @@
+export class RequestCanceledError extends Error {
+ public isCancel: boolean
+
+ constructor(msg: string) {
+ super(msg)
+ this.isCancel = true
+ Object.setPrototypeOf(this, RequestCanceledError)
+ }
+}
+
+export const isCancel = (value: any): boolean => {
+ return value && value.isCancel
+}
diff --git a/packages/megalodon/src/converter.ts b/packages/megalodon/src/converter.ts
new file mode 100644
index 000000000..f768fc930
--- /dev/null
+++ b/packages/megalodon/src/converter.ts
@@ -0,0 +1,3 @@
+import MisskeyAPI from "./misskey/api_client";
+
+export default MisskeyAPI.Converter;
\ No newline at end of file
diff --git a/packages/megalodon/src/default.ts b/packages/megalodon/src/default.ts
new file mode 100644
index 000000000..0194b3dcc
--- /dev/null
+++ b/packages/megalodon/src/default.ts
@@ -0,0 +1,3 @@
+export const NO_REDIRECT = 'urn:ietf:wg:oauth:2.0:oob'
+export const DEFAULT_SCOPE = ['read', 'write', 'follow']
+export const DEFAULT_UA = 'megalodon'
diff --git a/packages/megalodon/src/detector.ts b/packages/megalodon/src/detector.ts
new file mode 100644
index 000000000..31f34d72f
--- /dev/null
+++ b/packages/megalodon/src/detector.ts
@@ -0,0 +1,137 @@
+import axios, { AxiosRequestConfig } from 'axios'
+import proxyAgent, { ProxyConfig } from './proxy_config'
+import { NodeinfoError } from './megalodon'
+
+const NODEINFO_10 = 'http://nodeinfo.diaspora.software/ns/schema/1.0'
+const NODEINFO_20 = 'http://nodeinfo.diaspora.software/ns/schema/2.0'
+const NODEINFO_21 = 'http://nodeinfo.diaspora.software/ns/schema/2.1'
+
+type Links = {
+ links: Array
+}
+
+type Link = {
+ href: string
+ rel: string
+}
+
+type Nodeinfo10 = {
+ software: Software
+ metadata: Metadata
+}
+
+type Nodeinfo20 = {
+ software: Software
+ metadata: Metadata
+}
+
+type Nodeinfo21 = {
+ software: Software
+ metadata: Metadata
+}
+
+type Software = {
+ name: string
+}
+
+type Metadata = {
+ upstream?: {
+ name: string
+ }
+}
+
+/**
+ * Detect SNS type.
+ * Now support Mastodon, Pleroma and Pixelfed. Throws an error when no known platform can be detected.
+ *
+ * @param url Base URL of SNS.
+ * @param proxyConfig Proxy setting, or set false if don't use proxy.
+ * @return SNS name.
+ */
+export const detector = async (
+ url: string,
+ proxyConfig: ProxyConfig | false = false
+): Promise<'mastodon' | 'pleroma' | 'misskey' | 'friendica'> => {
+ let options: AxiosRequestConfig = {
+ timeout: 20000
+ }
+ if (proxyConfig) {
+ options = Object.assign(options, {
+ httpsAgent: proxyAgent(proxyConfig)
+ })
+ }
+
+ const res = await axios.get(url + '/.well-known/nodeinfo', options)
+ const link = res.data.links.find(l => l.rel === NODEINFO_20 || l.rel === NODEINFO_21)
+ if (!link) throw new NodeinfoError('Could not find nodeinfo')
+ switch (link.rel) {
+ case NODEINFO_10: {
+ const res = await axios.get(link.href, options)
+ switch (res.data.software.name) {
+ case 'pleroma':
+ return 'pleroma'
+ case 'akkoma':
+ return 'pleroma'
+ case 'mastodon':
+ return 'mastodon'
+ case "wildebeest":
+ return "mastodon"
+ case 'misskey':
+ return 'misskey'
+ case 'friendica':
+ return 'friendica'
+ default:
+ if (res.data.metadata.upstream?.name && res.data.metadata.upstream.name === 'mastodon') {
+ return 'mastodon'
+ }
+ throw new NodeinfoError('Unknown SNS')
+ }
+ }
+ case NODEINFO_20: {
+ const res = await axios.get(link.href, options)
+ switch (res.data.software.name) {
+ case 'pleroma':
+ return 'pleroma'
+ case 'akkoma':
+ return 'pleroma'
+ case 'mastodon':
+ return 'mastodon'
+ case "wildebeest":
+ return "mastodon"
+ case 'misskey':
+ return 'misskey'
+ case 'friendica':
+ return 'friendica'
+ default:
+ if (res.data.metadata.upstream?.name && res.data.metadata.upstream.name === 'mastodon') {
+ return 'mastodon'
+ }
+ throw new NodeinfoError('Unknown SNS')
+ }
+ }
+ case NODEINFO_21: {
+ const res = await axios.get(link.href, options)
+ switch (res.data.software.name) {
+ case 'pleroma':
+ return 'pleroma'
+ case 'akkoma':
+ return 'pleroma'
+ case 'mastodon':
+ return 'mastodon'
+ case "wildebeest":
+ return "mastodon"
+ case 'misskey':
+ return 'misskey'
+ case 'friendica':
+ return 'friendica'
+ default:
+ if (res.data.metadata.upstream?.name && res.data.metadata.upstream.name === 'mastodon') {
+ return 'mastodon'
+ }
+ throw new NodeinfoError('Unknown SNS')
+ }
+ }
+ default:
+ throw new NodeinfoError('Could not find nodeinfo')
+ }
+}
diff --git a/packages/megalodon/src/entities/account.ts b/packages/megalodon/src/entities/account.ts
new file mode 100644
index 000000000..89c0f17c4
--- /dev/null
+++ b/packages/megalodon/src/entities/account.ts
@@ -0,0 +1,35 @@
+///
+///
+///
+///
+namespace Entity {
+ export type Account = {
+ id: string
+ username: string
+ acct: string
+ display_name: string
+ locked: boolean
+ discoverable?: boolean
+ group: boolean | null
+ noindex: boolean | null
+ suspended: boolean | null
+ limited: boolean | null
+ created_at: string
+ followers_count: number
+ following_count: number
+ statuses_count: number
+ note: string
+ url: string
+ avatar: string
+ avatar_static: string
+ header: string
+ header_static: string
+ emojis: Array
+ moved: Account | null
+ fields: Array
+ bot: boolean | null
+ source?: Source
+ role?: Role
+ mute_expires_at?: string
+ }
+}
diff --git a/packages/megalodon/src/entities/activity.ts b/packages/megalodon/src/entities/activity.ts
new file mode 100644
index 000000000..2494916a9
--- /dev/null
+++ b/packages/megalodon/src/entities/activity.ts
@@ -0,0 +1,8 @@
+namespace Entity {
+ export type Activity = {
+ week: string
+ statuses: string
+ logins: string
+ registrations: string
+ }
+}
diff --git a/packages/megalodon/src/entities/announcement.ts b/packages/megalodon/src/entities/announcement.ts
new file mode 100644
index 000000000..0db9c23bb
--- /dev/null
+++ b/packages/megalodon/src/entities/announcement.ts
@@ -0,0 +1,40 @@
+///
+
+namespace Entity {
+ export type Announcement = {
+ id: string
+ content: string
+ starts_at: string | null
+ ends_at: string | null
+ published: boolean
+ all_day: boolean
+ published_at: string
+ updated_at: string | null
+ read: boolean | null
+ mentions: Array
+ statuses: Array
+ tags: Array
+ emojis: Array
+ reactions: Array
+ }
+
+ export type AnnouncementAccount = {
+ id: string
+ username: string
+ url: string
+ acct: string
+ }
+
+ export type AnnouncementStatus = {
+ id: string
+ url: string
+ }
+
+ export type AnnouncementReaction = {
+ name: string
+ count: number
+ me: boolean | null
+ url: string | null
+ static_url: string | null
+ }
+}
diff --git a/packages/megalodon/src/entities/application.ts b/packages/megalodon/src/entities/application.ts
new file mode 100644
index 000000000..3af64fcf9
--- /dev/null
+++ b/packages/megalodon/src/entities/application.ts
@@ -0,0 +1,7 @@
+namespace Entity {
+ export type Application = {
+ name: string
+ website?: string | null
+ vapid_key?: string | null
+ }
+}
diff --git a/packages/megalodon/src/entities/async_attachment.ts b/packages/megalodon/src/entities/async_attachment.ts
new file mode 100644
index 000000000..b383f90c5
--- /dev/null
+++ b/packages/megalodon/src/entities/async_attachment.ts
@@ -0,0 +1,14 @@
+///
+namespace Entity {
+ export type AsyncAttachment = {
+ id: string
+ type: 'unknown' | 'image' | 'gifv' | 'video' | 'audio'
+ url: string | null
+ remote_url: string | null
+ preview_url: string
+ text_url: string | null
+ meta: Meta | null
+ description: string | null
+ blurhash: string | null
+ }
+}
diff --git a/packages/megalodon/src/entities/attachment.ts b/packages/megalodon/src/entities/attachment.ts
new file mode 100644
index 000000000..aab1deade
--- /dev/null
+++ b/packages/megalodon/src/entities/attachment.ts
@@ -0,0 +1,49 @@
+namespace Entity {
+ export type Sub = {
+ // For Image, Gifv, and Video
+ width?: number
+ height?: number
+ size?: string
+ aspect?: number
+
+ // For Gifv and Video
+ frame_rate?: string
+
+ // For Audio, Gifv, and Video
+ duration?: number
+ bitrate?: number
+ }
+
+ export type Focus = {
+ x: number
+ y: number
+ }
+
+ export type Meta = {
+ original?: Sub
+ small?: Sub
+ focus?: Focus
+ length?: string
+ duration?: number
+ fps?: number
+ size?: string
+ width?: number
+ height?: number
+ aspect?: number
+ audio_encode?: string
+ audio_bitrate?: string
+ audio_channel?: string
+ }
+
+ export type Attachment = {
+ id: string
+ type: 'unknown' | 'image' | 'gifv' | 'video' | 'audio'
+ url: string
+ remote_url: string | null
+ preview_url: string | null
+ text_url: string | null
+ meta: Meta | null
+ description: string | null
+ blurhash: string | null
+ }
+}
diff --git a/packages/megalodon/src/entities/card.ts b/packages/megalodon/src/entities/card.ts
new file mode 100644
index 000000000..1ef6f5e4d
--- /dev/null
+++ b/packages/megalodon/src/entities/card.ts
@@ -0,0 +1,18 @@
+namespace Entity {
+ export type Card = {
+ url: string
+ title: string
+ description: string
+ type: 'link' | 'photo' | 'video' | 'rich'
+ image: string | null
+ author_name: string | null
+ author_url: string | null
+ provider_name: string | null
+ provider_url: string | null
+ html: string | null
+ width: number | null
+ height: number | null
+ embed_url: string | null
+ blurhash: string | null
+ }
+}
diff --git a/packages/megalodon/src/entities/context.ts b/packages/megalodon/src/entities/context.ts
new file mode 100644
index 000000000..3f2eda58f
--- /dev/null
+++ b/packages/megalodon/src/entities/context.ts
@@ -0,0 +1,8 @@
+///
+
+namespace Entity {
+ export type Context = {
+ ancestors: Array
+ descendants: Array
+ }
+}
diff --git a/packages/megalodon/src/entities/conversation.ts b/packages/megalodon/src/entities/conversation.ts
new file mode 100644
index 000000000..cdadf1e0f
--- /dev/null
+++ b/packages/megalodon/src/entities/conversation.ts
@@ -0,0 +1,11 @@
+///
+///
+
+namespace Entity {
+ export type Conversation = {
+ id: string
+ accounts: Array
+ last_status: Status | null
+ unread: boolean
+ }
+}
diff --git a/packages/megalodon/src/entities/emoji.ts b/packages/megalodon/src/entities/emoji.ts
new file mode 100644
index 000000000..546ef818f
--- /dev/null
+++ b/packages/megalodon/src/entities/emoji.ts
@@ -0,0 +1,9 @@
+namespace Entity {
+ export type Emoji = {
+ shortcode: string
+ static_url: string
+ url: string
+ visible_in_picker: boolean
+ category?: string
+ }
+}
diff --git a/packages/megalodon/src/entities/featured_tag.ts b/packages/megalodon/src/entities/featured_tag.ts
new file mode 100644
index 000000000..06ae6d7a9
--- /dev/null
+++ b/packages/megalodon/src/entities/featured_tag.ts
@@ -0,0 +1,8 @@
+namespace Entity {
+ export type FeaturedTag = {
+ id: string
+ name: string
+ statuses_count: number
+ last_status_at: string
+ }
+}
diff --git a/packages/megalodon/src/entities/field.ts b/packages/megalodon/src/entities/field.ts
new file mode 100644
index 000000000..03e4604b0
--- /dev/null
+++ b/packages/megalodon/src/entities/field.ts
@@ -0,0 +1,7 @@
+namespace Entity {
+ export type Field = {
+ name: string
+ value: string
+ verified_at: string | null
+ }
+}
diff --git a/packages/megalodon/src/entities/filter.ts b/packages/megalodon/src/entities/filter.ts
new file mode 100644
index 000000000..ffbacb728
--- /dev/null
+++ b/packages/megalodon/src/entities/filter.ts
@@ -0,0 +1,12 @@
+namespace Entity {
+ export type Filter = {
+ id: string
+ phrase: string
+ context: Array
+ expires_at: string | null
+ irreversible: boolean
+ whole_word: boolean
+ }
+
+ export type FilterContext = string
+}
diff --git a/packages/megalodon/src/entities/follow_request.ts b/packages/megalodon/src/entities/follow_request.ts
new file mode 100644
index 000000000..84ea4d02c
--- /dev/null
+++ b/packages/megalodon/src/entities/follow_request.ts
@@ -0,0 +1,27 @@
+///
+///
+
+namespace Entity {
+ export type FollowRequest = {
+ id: number
+ username: string
+ acct: string
+ display_name: string
+ locked: boolean
+ bot: boolean
+ discoverable?: boolean
+ group: boolean
+ created_at: string
+ note: string
+ url: string
+ avatar: string
+ avatar_static: string
+ header: string
+ header_static: string
+ followers_count: number
+ following_count: number
+ statuses_count: number
+ emojis: Array
+ fields: Array
+ }
+}
diff --git a/packages/megalodon/src/entities/history.ts b/packages/megalodon/src/entities/history.ts
new file mode 100644
index 000000000..070969426
--- /dev/null
+++ b/packages/megalodon/src/entities/history.ts
@@ -0,0 +1,7 @@
+namespace Entity {
+ export type History = {
+ day: string
+ uses: number
+ accounts: number
+ }
+}
diff --git a/packages/megalodon/src/entities/identity_proof.ts b/packages/megalodon/src/entities/identity_proof.ts
new file mode 100644
index 000000000..ff857addb
--- /dev/null
+++ b/packages/megalodon/src/entities/identity_proof.ts
@@ -0,0 +1,9 @@
+namespace Entity {
+ export type IdentityProof = {
+ provider: string
+ provider_username: string
+ updated_at: string
+ proof_url: string
+ profile_url: string
+ }
+}
diff --git a/packages/megalodon/src/entities/instance.ts b/packages/megalodon/src/entities/instance.ts
new file mode 100644
index 000000000..8f4808be8
--- /dev/null
+++ b/packages/megalodon/src/entities/instance.ts
@@ -0,0 +1,40 @@
+///
+///
+///
+
+namespace Entity {
+ export type Instance = {
+ uri: string
+ title: string
+ description: string
+ email: string
+ version: string
+ thumbnail: string | null
+ urls: URLs | null
+ stats: Stats
+ languages: Array
+ registrations: boolean
+ approval_required: boolean
+ invites_enabled?: boolean
+ configuration: {
+ statuses: {
+ max_characters: number
+ max_media_attachments?: number
+ characters_reserved_per_url?: number
+ }
+ polls?: {
+ max_options: number
+ max_characters_per_option: number
+ min_expiration: number
+ max_expiration: number
+ }
+ }
+ contact_account?: Account
+ rules?: Array
+ }
+
+ export type InstanceRule = {
+ id: string
+ text: string
+ }
+}
diff --git a/packages/megalodon/src/entities/list.ts b/packages/megalodon/src/entities/list.ts
new file mode 100644
index 000000000..58c264aba
--- /dev/null
+++ b/packages/megalodon/src/entities/list.ts
@@ -0,0 +1,9 @@
+namespace Entity {
+ export type List = {
+ id: string
+ title: string
+ replies_policy: RepliesPolicy | null
+ }
+
+ export type RepliesPolicy = 'followed' | 'list' | 'none'
+}
diff --git a/packages/megalodon/src/entities/marker.ts b/packages/megalodon/src/entities/marker.ts
new file mode 100644
index 000000000..33cb98a10
--- /dev/null
+++ b/packages/megalodon/src/entities/marker.ts
@@ -0,0 +1,15 @@
+namespace Entity {
+ export type Marker = {
+ home?: {
+ last_read_id: string
+ version: number
+ updated_at: string
+ }
+ notifications?: {
+ last_read_id: string
+ version: number
+ updated_at: string
+ unread_count?: number
+ }
+ }
+}
diff --git a/packages/megalodon/src/entities/mention.ts b/packages/megalodon/src/entities/mention.ts
new file mode 100644
index 000000000..046912971
--- /dev/null
+++ b/packages/megalodon/src/entities/mention.ts
@@ -0,0 +1,8 @@
+namespace Entity {
+ export type Mention = {
+ id: string
+ username: string
+ url: string
+ acct: string
+ }
+}
diff --git a/packages/megalodon/src/entities/notification.ts b/packages/megalodon/src/entities/notification.ts
new file mode 100644
index 000000000..653d235d9
--- /dev/null
+++ b/packages/megalodon/src/entities/notification.ts
@@ -0,0 +1,16 @@
+///
+///
+
+namespace Entity {
+ export type Notification = {
+ account: Account
+ created_at: string
+ id: string
+ status?: Status
+ emoji?: string
+ type: NotificationType
+ target?: Account
+ }
+
+ export type NotificationType = string
+}
diff --git a/packages/megalodon/src/entities/poll.ts b/packages/megalodon/src/entities/poll.ts
new file mode 100644
index 000000000..69706e8ae
--- /dev/null
+++ b/packages/megalodon/src/entities/poll.ts
@@ -0,0 +1,13 @@
+///
+
+namespace Entity {
+ export type Poll = {
+ id: string
+ expires_at: string | null
+ expired: boolean
+ multiple: boolean
+ votes_count: number
+ options: Array
+ voted: boolean
+ }
+}
diff --git a/packages/megalodon/src/entities/poll_option.ts b/packages/megalodon/src/entities/poll_option.ts
new file mode 100644
index 000000000..ae4c63849
--- /dev/null
+++ b/packages/megalodon/src/entities/poll_option.ts
@@ -0,0 +1,6 @@
+namespace Entity {
+ export type PollOption = {
+ title: string
+ votes_count: number | null
+ }
+}
diff --git a/packages/megalodon/src/entities/preferences.ts b/packages/megalodon/src/entities/preferences.ts
new file mode 100644
index 000000000..cb5797c4c
--- /dev/null
+++ b/packages/megalodon/src/entities/preferences.ts
@@ -0,0 +1,9 @@
+namespace Entity {
+ export type Preferences = {
+ 'posting:default:visibility': 'public' | 'unlisted' | 'private' | 'direct'
+ 'posting:default:sensitive': boolean
+ 'posting:default:language': string | null
+ 'reading:expand:media': 'default' | 'show_all' | 'hide_all'
+ 'reading:expand:spoilers': boolean
+ }
+}
diff --git a/packages/megalodon/src/entities/push_subscription.ts b/packages/megalodon/src/entities/push_subscription.ts
new file mode 100644
index 000000000..fe7464e8e
--- /dev/null
+++ b/packages/megalodon/src/entities/push_subscription.ts
@@ -0,0 +1,16 @@
+namespace Entity {
+ export type Alerts = {
+ follow: boolean
+ favourite: boolean
+ mention: boolean
+ reblog: boolean
+ poll: boolean
+ }
+
+ export type PushSubscription = {
+ id: string
+ endpoint: string
+ server_key: string
+ alerts: Alerts
+ }
+}
diff --git a/packages/megalodon/src/entities/reaction.ts b/packages/megalodon/src/entities/reaction.ts
new file mode 100644
index 000000000..8c626f9e8
--- /dev/null
+++ b/packages/megalodon/src/entities/reaction.ts
@@ -0,0 +1,10 @@
+///
+
+namespace Entity {
+ export type Reaction = {
+ count: number
+ me: boolean
+ name: string
+ accounts?: Array
+ }
+}
diff --git a/packages/megalodon/src/entities/relationship.ts b/packages/megalodon/src/entities/relationship.ts
new file mode 100644
index 000000000..283a1158c
--- /dev/null
+++ b/packages/megalodon/src/entities/relationship.ts
@@ -0,0 +1,17 @@
+namespace Entity {
+ export type Relationship = {
+ id: string
+ following: boolean
+ followed_by: boolean
+ blocking: boolean
+ blocked_by: boolean
+ muting: boolean
+ muting_notifications: boolean
+ requested: boolean
+ domain_blocking: boolean
+ showing_reblogs: boolean
+ endorsed: boolean
+ notifying: boolean
+ note: string | null
+ }
+}
diff --git a/packages/megalodon/src/entities/report.ts b/packages/megalodon/src/entities/report.ts
new file mode 100644
index 000000000..353886a34
--- /dev/null
+++ b/packages/megalodon/src/entities/report.ts
@@ -0,0 +1,18 @@
+///
+
+namespace Entity {
+ export type Report = {
+ id: string
+ action_taken: boolean
+ action_taken_at: string | null
+ status_ids: Array | null
+ rule_ids: Array | null
+ // These parameters don't exist in Pleroma
+ category: Category | null
+ comment: string | null
+ forwarded: boolean | null
+ target_account?: Account | null
+ }
+
+ export type Category = 'spam' | 'violation' | 'other'
+}
diff --git a/packages/megalodon/src/entities/results.ts b/packages/megalodon/src/entities/results.ts
new file mode 100644
index 000000000..fe168de67
--- /dev/null
+++ b/packages/megalodon/src/entities/results.ts
@@ -0,0 +1,11 @@
+///
+///
+///
+
+namespace Entity {
+ export type Results = {
+ accounts: Array
+ statuses: Array
+ hashtags: Array
+ }
+}
diff --git a/packages/megalodon/src/entities/role.ts b/packages/megalodon/src/entities/role.ts
new file mode 100644
index 000000000..caaae9ea1
--- /dev/null
+++ b/packages/megalodon/src/entities/role.ts
@@ -0,0 +1,5 @@
+namespace Entity {
+ export type Role = {
+ name: string
+ }
+}
diff --git a/packages/megalodon/src/entities/scheduled_status.ts b/packages/megalodon/src/entities/scheduled_status.ts
new file mode 100644
index 000000000..561a5b9f2
--- /dev/null
+++ b/packages/megalodon/src/entities/scheduled_status.ts
@@ -0,0 +1,10 @@
+///
+///
+namespace Entity {
+ export type ScheduledStatus = {
+ id: string
+ scheduled_at: string
+ params: StatusParams
+ media_attachments: Array | null
+ }
+}
diff --git a/packages/megalodon/src/entities/source.ts b/packages/megalodon/src/entities/source.ts
new file mode 100644
index 000000000..d87cf55d8
--- /dev/null
+++ b/packages/megalodon/src/entities/source.ts
@@ -0,0 +1,10 @@
+///
+namespace Entity {
+ export type Source = {
+ privacy: string | null
+ sensitive: boolean | null
+ language: string | null
+ note: string
+ fields: Array
+ }
+}
diff --git a/packages/megalodon/src/entities/stats.ts b/packages/megalodon/src/entities/stats.ts
new file mode 100644
index 000000000..76f0bad34
--- /dev/null
+++ b/packages/megalodon/src/entities/stats.ts
@@ -0,0 +1,7 @@
+namespace Entity {
+ export type Stats = {
+ user_count: number
+ status_count: number
+ domain_count: number
+ }
+}
diff --git a/packages/megalodon/src/entities/status.ts b/packages/megalodon/src/entities/status.ts
new file mode 100644
index 000000000..295703e57
--- /dev/null
+++ b/packages/megalodon/src/entities/status.ts
@@ -0,0 +1,49 @@
+///
+///
+///
+///
+///
+///
+///
+///
+
+namespace Entity {
+ export type Status = {
+ id: string
+ uri: string
+ url: string
+ account: Account
+ in_reply_to_id: string | null
+ in_reply_to_account_id: string | null
+ reblog: Status | null
+ content: string
+ plain_content: string | null
+ created_at: string
+ emojis: Emoji[]
+ replies_count: number
+ reblogs_count: number
+ favourites_count: number
+ reblogged: boolean | null
+ favourited: boolean | null
+ muted: boolean | null
+ sensitive: boolean
+ spoiler_text: string
+ visibility: 'public' | 'unlisted' | 'private' | 'direct'
+ media_attachments: Array
+ mentions: Array
+ tags: Array
+ card: Card | null
+ poll: Poll | null
+ application: Application | null
+ language: string | null
+ pinned: boolean | null
+ emoji_reactions: Array
+ quote: boolean
+ bookmarked: boolean
+ }
+
+ export type StatusTag = {
+ name: string
+ url: string
+ }
+}
diff --git a/packages/megalodon/src/entities/status_edit.ts b/packages/megalodon/src/entities/status_edit.ts
new file mode 100644
index 000000000..4040b4ff9
--- /dev/null
+++ b/packages/megalodon/src/entities/status_edit.ts
@@ -0,0 +1,23 @@
+///
+///
+///
+///
+///
+///
+///
+///
+///
+
+namespace Entity {
+ export type StatusEdit = {
+ account: Account;
+ content: string;
+ plain_content: string | null;
+ created_at: string;
+ emojis: Emoji[];
+ sensitive: boolean;
+ spoiler_text: string;
+ media_attachments: Array;
+ poll: Poll | null;
+ };
+}
diff --git a/packages/megalodon/src/entities/status_params.ts b/packages/megalodon/src/entities/status_params.ts
new file mode 100644
index 000000000..82d789086
--- /dev/null
+++ b/packages/megalodon/src/entities/status_params.ts
@@ -0,0 +1,12 @@
+namespace Entity {
+ export type StatusParams = {
+ text: string
+ in_reply_to_id: string | null
+ media_ids: Array | null
+ sensitive: boolean | null
+ spoiler_text: string | null
+ visibility: 'public' | 'unlisted' | 'private' | 'direct' | null
+ scheduled_at: string | null
+ application_id: number | null
+ }
+}
diff --git a/packages/megalodon/src/entities/status_source.ts b/packages/megalodon/src/entities/status_source.ts
new file mode 100644
index 000000000..0de7030ed
--- /dev/null
+++ b/packages/megalodon/src/entities/status_source.ts
@@ -0,0 +1,7 @@
+namespace Entity {
+ export type StatusSource = {
+ id: string
+ text: string
+ spoiler_text: string
+ }
+}
diff --git a/packages/megalodon/src/entities/tag.ts b/packages/megalodon/src/entities/tag.ts
new file mode 100644
index 000000000..ddc5fe92b
--- /dev/null
+++ b/packages/megalodon/src/entities/tag.ts
@@ -0,0 +1,10 @@
+///
+
+namespace Entity {
+ export type Tag = {
+ name: string
+ url: string
+ history: Array
+ following?: boolean
+ }
+}
diff --git a/packages/megalodon/src/entities/token.ts b/packages/megalodon/src/entities/token.ts
new file mode 100644
index 000000000..6fa28e39b
--- /dev/null
+++ b/packages/megalodon/src/entities/token.ts
@@ -0,0 +1,8 @@
+namespace Entity {
+ export type Token = {
+ access_token: string
+ token_type: string
+ scope: string
+ created_at: number
+ }
+}
diff --git a/packages/megalodon/src/entities/urls.ts b/packages/megalodon/src/entities/urls.ts
new file mode 100644
index 000000000..4a980d589
--- /dev/null
+++ b/packages/megalodon/src/entities/urls.ts
@@ -0,0 +1,5 @@
+namespace Entity {
+ export type URLs = {
+ streaming_api: string
+ }
+}
diff --git a/packages/megalodon/src/entity.ts b/packages/megalodon/src/entity.ts
new file mode 100644
index 000000000..387981cec
--- /dev/null
+++ b/packages/megalodon/src/entity.ts
@@ -0,0 +1,40 @@
+///
+///
+///
+///
+///
+///
+///
+///
+///
+///
+///
+///
+///
+///
+///
+///
+///
+///
+///
+///
+///
+///
+///
+///
+///
+///
+///
+///
+///
+///
+///
+///
+///
+///
+///
+///
+///
+///
+
+export default Entity
diff --git a/packages/megalodon/src/filter_context.ts b/packages/megalodon/src/filter_context.ts
new file mode 100644
index 000000000..c69be98cd
--- /dev/null
+++ b/packages/megalodon/src/filter_context.ts
@@ -0,0 +1,11 @@
+import Entity from './entity'
+
+namespace FilterContext {
+ export const Home: Entity.FilterContext = 'home'
+ export const Notifications: Entity.FilterContext = 'notifications'
+ export const Public: Entity.FilterContext = 'public'
+ export const Thread: Entity.FilterContext = 'thread'
+ export const Account: Entity.FilterContext = 'account'
+}
+
+export default FilterContext
diff --git a/packages/megalodon/src/friendica.ts b/packages/megalodon/src/friendica.ts
new file mode 100644
index 000000000..c5ee9d59c
--- /dev/null
+++ b/packages/megalodon/src/friendica.ts
@@ -0,0 +1,2868 @@
+import { OAuth2 } from 'oauth'
+import FormData from 'form-data'
+import parseLinkHeader from 'parse-link-header'
+
+import FriendicaAPI from './friendica/api_client'
+import WebSocket from './friendica/web_socket'
+import { MegalodonInterface, NoImplementedError } from './megalodon'
+import Response from './response'
+import Entity from './entity'
+import { NO_REDIRECT, DEFAULT_SCOPE, DEFAULT_UA } from './default'
+import { ProxyConfig } from './proxy_config'
+import OAuth from './oauth'
+import { UnknownNotificationTypeError } from './notification'
+
+export default class Friendica implements MegalodonInterface {
+ public client: FriendicaAPI.Interface
+ public baseUrl: string
+
+ /**
+ * @param baseUrl hostname or base URL
+ * @param accessToken access token from OAuth2 authorization
+ * @param userAgent UserAgent is specified in header on request.
+ * @param proxyConfig Proxy setting, or set false if don't use proxy.
+ */
+ constructor(
+ baseUrl: string,
+ accessToken: string | null = null,
+ userAgent: string | null = DEFAULT_UA,
+ proxyConfig: ProxyConfig | false = false
+ ) {
+ let token = ''
+ if (accessToken) {
+ token = accessToken
+ }
+ let agent: string = DEFAULT_UA
+ if (userAgent) {
+ agent = userAgent
+ }
+ this.client = new FriendicaAPI.Client(baseUrl, token, agent, proxyConfig)
+ this.baseUrl = baseUrl
+ }
+
+ public cancel(): void {
+ return this.client.cancel()
+ }
+
+ /**
+ * First, call createApp to get client_id and client_secret.
+ * Next, call generateAuthUrl to get authorization url.
+ * @param client_name Form Data, which is sent to /api/v1/apps
+ * @param options Form Data, which is sent to /api/v1/apps. and properties should be **snake_case**
+ */
+ public async registerApp(
+ client_name: string,
+ options: Partial<{ scopes: Array; redirect_uris: string; website: string }>
+ ): Promise {
+ const scopes = options.scopes || DEFAULT_SCOPE
+ return this.createApp(client_name, options).then(async appData => {
+ return this.generateAuthUrl(appData.client_id, appData.client_secret, {
+ scope: scopes,
+ redirect_uri: appData.redirect_uri
+ }).then(url => {
+ appData.url = url
+ return appData
+ })
+ })
+ }
+
+ /**
+ * Call /api/v1/apps
+ *
+ * Create an application.
+ * @param client_name your application's name
+ * @param options Form Data
+ */
+ public async createApp(
+ client_name: string,
+ options: Partial<{ scopes: Array; redirect_uris: string; website: string }>
+ ): Promise {
+ const scopes = options.scopes || DEFAULT_SCOPE
+ const redirect_uris = options.redirect_uris || NO_REDIRECT
+
+ const params: {
+ client_name: string
+ redirect_uris: string
+ scopes: string
+ website?: string
+ } = {
+ client_name: client_name,
+ redirect_uris: redirect_uris,
+ scopes: scopes.join(' ')
+ }
+ if (options.website) params.website = options.website
+
+ return this.client
+ .post('/api/v1/apps', params)
+ .then((res: Response) => OAuth.AppData.from(res.data))
+ }
+
+ /**
+ * Generate authorization url using OAuth2.
+ *
+ * @param clientId your OAuth app's client ID
+ * @param clientSecret your OAuth app's client Secret
+ * @param options as property, redirect_uri and scope are available, and must be the same as when you register your app
+ */
+ public generateAuthUrl(
+ clientId: string,
+ clientSecret: string,
+ options: Partial<{ scope: Array; redirect_uri: string }>
+ ): Promise {
+ const scope = options.scope || DEFAULT_SCOPE
+ const redirect_uri = options.redirect_uri || NO_REDIRECT
+ return new Promise(resolve => {
+ const oauth = new OAuth2(clientId, clientSecret, this.baseUrl, undefined, '/oauth/token')
+ const url = oauth.getAuthorizeUrl({
+ redirect_uri: redirect_uri,
+ response_type: 'code',
+ client_id: clientId,
+ scope: scope.join(' ')
+ })
+ resolve(url)
+ })
+ }
+
+ // ======================================
+ // apps
+ // ======================================
+ /**
+ * GET /api/v1/apps/verify_credentials
+ *
+ * @return An Application
+ */
+ public verifyAppCredentials(): Promise> {
+ return this.client.get('/api/v1/apps/verify_credentials')
+ }
+
+ // ======================================
+ // apps/oauth
+ // ======================================
+ /**
+ * POST /oauth/token
+ *
+ * Fetch OAuth access token.
+ * Get an access token based client_id and client_secret and authorization code.
+ * @param client_id will be generated by #createApp or #registerApp
+ * @param client_secret will be generated by #createApp or #registerApp
+ * @param code will be generated by the link of #generateAuthUrl or #registerApp
+ * @param redirect_uri must be the same uri as the time when you register your OAuth application
+ */
+ public async fetchAccessToken(
+ client_id: string | null,
+ client_secret: string,
+ code: string,
+ redirect_uri: string = NO_REDIRECT
+ ): Promise {
+ if (!client_id) {
+ throw new Error('client_id is required')
+ }
+ return this.client
+ .post('/oauth/token', {
+ client_id,
+ client_secret,
+ code,
+ redirect_uri,
+ grant_type: 'authorization_code'
+ })
+ .then((res: Response) => OAuth.TokenData.from(res.data))
+ }
+
+ /**
+ * POST /oauth/token
+ *
+ * Refresh OAuth access token.
+ * Send refresh token and get new access token.
+ * @param client_id will be generated by #createApp or #registerApp
+ * @param client_secret will be generated by #createApp or #registerApp
+ * @param refresh_token will be get #fetchAccessToken
+ */
+ public async refreshToken(client_id: string, client_secret: string, refresh_token: string): Promise {
+ return this.client
+ .post('/oauth/token', {
+ client_id,
+ client_secret,
+ refresh_token,
+ grant_type: 'refresh_token'
+ })
+ .then((res: Response) => OAuth.TokenData.from(res.data))
+ }
+
+ /**
+ * POST /oauth/revoke
+ *
+ * Revoke an OAuth token.
+ * @param client_id will be generated by #createApp or #registerApp
+ * @param client_secret will be generated by #createApp or #registerApp
+ * @param token will be get #fetchAccessToken
+ */
+ public async revokeToken(client_id: string, client_secret: string, token: string): Promise>> {
+ return this.client.post>('/oauth/revoke', {
+ client_id,
+ client_secret,
+ token
+ })
+ }
+
+ // ======================================
+ // accounts
+ // ======================================
+ public async registerAccount(
+ _username: string,
+ _email: string,
+ _password: string,
+ _agreement: boolean,
+ _locale: string,
+ _reason?: string | null
+ ): Promise> {
+ return new Promise((_, reject) => {
+ const err = new NoImplementedError('friendica does not support')
+ reject(err)
+ })
+ }
+
+ /**
+ * GET /api/v1/accounts/verify_credentials
+ *
+ * @return Account.
+ */
+ public async verifyAccountCredentials(): Promise> {
+ return this.client.get('/api/v1/accounts/verify_credentials').then(res => {
+ return Object.assign(res, {
+ data: FriendicaAPI.Converter.account(res.data)
+ })
+ })
+ }
+
+ public async updateCredentials(_options?: {
+ discoverable?: boolean
+ bot?: boolean
+ display_name?: string
+ note?: string
+ avatar?: string
+ header?: string
+ locked?: boolean
+ source?: {
+ privacy?: string
+ sensitive?: boolean
+ language?: string
+ }
+ fields_attributes?: Array<{ name: string; value: string }>
+ }): Promise> {
+ return new Promise((_, reject) => {
+ const err = new NoImplementedError('friendica does not support')
+ reject(err)
+ })
+ }
+
+ /**
+ * GET /api/v1/accounts/:id
+ *
+ * @param id The account ID.
+ * @return An account.
+ */
+ public async getAccount(id: string): Promise> {
+ return this.client.get(`/api/v1/accounts/${id}`).then(res => {
+ return Object.assign(res, {
+ data: FriendicaAPI.Converter.account(res.data)
+ })
+ })
+ }
+
+ /**
+ * GET /api/v1/accounts/:id/statuses
+ *
+ * @param id The account ID.
+
+ * @param options.limit Max number of results to return. Defaults to 20.
+ * @param options.max_id Return results older than ID.
+ * @param options.since_id Return results newer than ID but starting with most recent.
+ * @param options.min_id Return results newer than ID.
+ * @param options.pinned Return statuses which include pinned statuses.
+ * @param options.exclude_replies Return statuses which exclude replies.
+ * @param options.exclude_reblogs Return statuses which exclude reblogs.
+ * @param options.only_media Show only statuses with media attached? Defaults to false.
+ * @return Account's statuses.
+ */
+ public async getAccountStatuses(
+ id: string,
+ options?: {
+ limit?: number
+ max_id?: string
+ since_id?: string
+ min_id?: string
+ pinned?: boolean
+ exclude_replies?: boolean
+ exclude_reblogs?: boolean
+ only_media: boolean
+ }
+ ): Promise>> {
+ let params = {}
+ if (options) {
+ if (options.limit) {
+ params = Object.assign(params, {
+ limit: options.limit
+ })
+ }
+ if (options.max_id) {
+ params = Object.assign(params, {
+ max_id: options.max_id
+ })
+ }
+ if (options.since_id) {
+ params = Object.assign(params, {
+ since_id: options.since_id
+ })
+ }
+ if (options.min_id) {
+ params = Object.assign(params, {
+ min_id: options.min_id
+ })
+ }
+ if (options.pinned) {
+ params = Object.assign(params, {
+ pinned: options.pinned
+ })
+ }
+ if (options.exclude_replies) {
+ params = Object.assign(params, {
+ exclude_replies: options.exclude_replies
+ })
+ }
+ if (options.exclude_reblogs) {
+ params = Object.assign(params, {
+ exclude_reblogs: options.exclude_reblogs
+ })
+ }
+ if (options.only_media) {
+ params = Object.assign(params, {
+ only_media: options.only_media
+ })
+ }
+ }
+
+ return this.client.get>(`/api/v1/accounts/${id}/statuses`, params).then(res => {
+ return Object.assign(res, {
+ data: res.data.map(s => FriendicaAPI.Converter.status(s))
+ })
+ })
+ }
+
+ /**
+ * POST /api/v1/accounts/:id/follow
+ *
+ * @param id Target account ID.
+ * @return Relationship.
+ */
+ public async subscribeAccount(id: string): Promise> {
+ const params = {
+ notify: true
+ }
+ return this.client.post(`/api/v1/accounts/${id}/follow`, params).then(res => {
+ return Object.assign(res, {
+ data: FriendicaAPI.Converter.relationship(res.data)
+ })
+ })
+ }
+
+ /**
+ * POST /api/v1/accounts/:id/follow
+ *
+ * @param id Target account ID.
+ * @return Relationship.
+ */
+ public async unsubscribeAccount(id: string): Promise> {
+ const params = {
+ notify: false
+ }
+ return this.client.post(`/api/v1/accounts/${id}/follow`, params).then(res => {
+ return Object.assign(res, {
+ data: FriendicaAPI.Converter.relationship(res.data)
+ })
+ })
+ }
+
+ public getAccountFavourites(
+ _id: string,
+ _options?: {
+ limit?: number
+ max_id?: string
+ since_id?: string
+ }
+ ): Promise>> {
+ return new Promise((_, reject) => {
+ const err = new NoImplementedError('friendica does not support')
+ reject(err)
+ })
+ }
+
+ /**
+ * GET /api/v1/accounts/:id/followers
+ *
+ * @param id The account ID.
+ * @param options.limit Max number of results to return. Defaults to 40.
+ * @param options.max_id Return results older than ID.
+ * @param options.since_id Return results newer than ID.
+ * @return The array of accounts.
+ */
+ public async getAccountFollowers(
+ id: string,
+ options?: {
+ limit?: number
+ max_id?: string
+ since_id?: string
+ get_all?: boolean
+ sleep_ms?: number
+ }
+ ): Promise>> {
+ let params = {}
+ if (options) {
+ if (options.max_id) {
+ params = Object.assign(params, {
+ max_id: options.max_id
+ })
+ }
+ if (options.since_id) {
+ params = Object.assign(params, {
+ since_id: options.since_id
+ })
+ }
+ if (options.limit) {
+ params = Object.assign(params, {
+ limit: options.limit
+ })
+ }
+ }
+ return this.urlToAccounts(`/api/v1/accounts/${id}/followers`, params, options?.get_all || false, options?.sleep_ms || 0)
+ }
+
+ /**
+ * GET /api/v1/accounts/:id/following
+ *
+ * @param id The account ID.
+ * @param options.limit Max number of results to return. Defaults to 40.
+ * @param options.max_id Return results older than ID.
+ * @param options.since_id Return results newer than ID.
+ * @return The array of accounts.
+ */
+ public async getAccountFollowing(
+ id: string,
+ options?: {
+ limit?: number
+ max_id?: string
+ since_id?: string
+ get_all?: boolean
+ sleep_ms?: number
+ }
+ ): Promise>> {
+ let params = {}
+ if (options) {
+ if (options.max_id) {
+ params = Object.assign(params, {
+ max_id: options.max_id
+ })
+ }
+ if (options.since_id) {
+ params = Object.assign(params, {
+ since_id: options.since_id
+ })
+ }
+ if (options.limit) {
+ params = Object.assign(params, {
+ limit: options.limit
+ })
+ }
+ }
+ return this.urlToAccounts(`/api/v1/accounts/${id}/following`, params, options?.get_all || false, options?.sleep_ms || 0)
+ }
+
+ /** Helper function to optionally follow Link headers as pagination */
+ private async urlToAccounts(url: string, params: Record, get_all: boolean, sleep_ms: number) {
+ const res = await this.client.get>(url, params)
+ let converted = Object.assign({}, res, {
+ data: res.data.map(a => FriendicaAPI.Converter.account(a))
+ })
+ if (get_all && converted.headers.link) {
+ let parsed = parseLinkHeader(converted.headers.link)
+ while (parsed?.next) {
+ const nextRes = await this.client.get>(parsed?.next.url, undefined, undefined, true)
+ converted = Object.assign({}, converted, {
+ data: [...converted.data, ...nextRes.data.map(a => FriendicaAPI.Converter.account(a))]
+ })
+ parsed = parseLinkHeader(nextRes.headers.link)
+ if (sleep_ms) {
+ await new Promise(converted => setTimeout(converted, sleep_ms))
+ }
+ }
+ }
+ return converted
+ }
+
+ /**
+ * GET /api/v1/accounts/:id/lists
+ *
+ * @param id The account ID.
+ * @return The array of lists.
+ */
+ public async getAccountLists(id: string): Promise>> {
+ return this.client.get>(`/api/v1/accounts/${id}/lists`).then(res => {
+ return Object.assign(res, {
+ data: res.data.map(l => FriendicaAPI.Converter.list(l))
+ })
+ })
+ }
+
+ /**
+ * GET /api/v1/accounts/:id/identity_proofs
+ *
+ * @param id The account ID.
+ * @return Array of IdentityProof
+ */
+ public async getIdentityProof(id: string): Promise>> {
+ return this.client.get>(`/api/v1/accounts/${id}/identity_proofs`).then(res => {
+ return Object.assign(res, {
+ data: res.data.map(i => FriendicaAPI.Converter.identity_proof(i))
+ })
+ })
+ }
+
+ /**
+ * POST /api/v1/accounts/:id/follow
+ *
+ * @param id The account ID.
+ * @param reblog Receive this account's reblogs in home timeline.
+ * @return Relationship
+ */
+ public async followAccount(id: string, options?: { reblog?: boolean }): Promise> {
+ let params = {}
+ if (options) {
+ if (options.reblog !== undefined) {
+ params = Object.assign(params, {
+ reblog: options.reblog
+ })
+ }
+ }
+ return this.client.post(`/api/v1/accounts/${id}/follow`, params).then(res => {
+ return Object.assign(res, {
+ data: FriendicaAPI.Converter.relationship(res.data)
+ })
+ })
+ }
+
+ /**
+ * POST /api/v1/accounts/:id/unfollow
+ *
+ * @param id The account ID.
+ * @return Relationship
+ */
+ public async unfollowAccount(id: string): Promise> {
+ return this.client.post(`/api/v1/accounts/${id}/unfollow`).then(res => {
+ return Object.assign(res, {
+ data: FriendicaAPI.Converter.relationship(res.data)
+ })
+ })
+ }
+
+ /**
+ * POST /api/v1/accounts/:id/block
+ *
+ * @param id The account ID.
+ * @return Relationship
+ */
+ public async blockAccount(id: string): Promise> {
+ return this.client.post(`/api/v1/accounts/${id}/block`).then(res => {
+ return Object.assign(res, {
+ data: FriendicaAPI.Converter.relationship(res.data)
+ })
+ })
+ }
+
+ /**
+ * POST /api/v1/accounts/:id/unblock
+ *
+ * @param id The account ID.
+ * @return RElationship
+ */
+ public async unblockAccount(id: string): Promise> {
+ return this.client.post(`/api/v1/accounts/${id}/unblock`).then(res => {
+ return Object.assign(res, {
+ data: FriendicaAPI.Converter.relationship(res.data)
+ })
+ })
+ }
+
+ /**
+ * POST /api/v1/accounts/:id/mute
+ *
+ * @param id The account ID.
+ * @param notifications Mute notifications in addition to statuses.
+ * @return Relationship
+ */
+ public async muteAccount(id: string, notifications = true): Promise> {
+ return this.client
+ .post(`/api/v1/accounts/${id}/mute`, {
+ notifications: notifications
+ })
+ .then(res => {
+ return Object.assign(res, {
+ data: FriendicaAPI.Converter.relationship(res.data)
+ })
+ })
+ }
+
+ /**
+ * POST /api/v1/accounts/:id/unmute
+ *
+ * @param id The account ID.
+ * @return Relationship
+ */
+ public async unmuteAccount(id: string): Promise> {
+ return this.client.post(`/api/v1/accounts/${id}/unmute`).then(res => {
+ return Object.assign(res, {
+ data: FriendicaAPI.Converter.relationship(res.data)
+ })
+ })
+ }
+
+ /**
+ * POST /api/v1/accounts/:id/pin
+ *
+ * @param id The account ID.
+ * @return Relationship
+ */
+ public async pinAccount(_id: string): Promise> {
+ return new Promise((_, reject) => {
+ const err = new NoImplementedError('friendica does not support')
+ reject(err)
+ })
+ }
+
+ /**
+ * POST /api/v1/accounts/:id/unpin
+ *
+ * @param id The account ID.
+ * @return Relationship
+ */
+ public async unpinAccount(_id: string): Promise> {
+ return new Promise((_, reject) => {
+ const err = new NoImplementedError('friendica does not support')
+ reject(err)
+ })
+ }
+
+ /**
+ * GET /api/v1/accounts/relationships
+ *
+ * @param id The account ID.
+ * @return Relationship
+ */
+ public async getRelationship(id: string): Promise> {
+ return this.client
+ .get>('/api/v1/accounts/relationships', {
+ id: [id]
+ })
+ .then(res => {
+ return Object.assign(res, {
+ data: FriendicaAPI.Converter.relationship(res.data[0])
+ })
+ })
+ }
+
+ /**
+ * Get multiple relationships in one method
+ *
+ * @param ids Array of account IDs.
+ * @return Array of Relationship.
+ */
+ public async getRelationships(ids: Array): Promise>> {
+ return this.client
+ .get>('/api/v1/accounts/relationships', {
+ id: ids
+ })
+ .then(res => {
+ return Object.assign(res, {
+ data: res.data.map(r => FriendicaAPI.Converter.relationship(r))
+ })
+ })
+ }
+
+ /**
+ * GET /api/v1/accounts/search
+ *
+ * @param q Search query.
+ * @param options.limit Max number of results to return. Defaults to 40.
+ * @param options.max_id Return results older than ID.
+ * @param options.since_id Return results newer than ID.
+ * @return The array of accounts.
+ */
+ public async searchAccount(
+ q: string,
+ options?: {
+ following?: boolean
+ resolve?: boolean
+ limit?: number
+ max_id?: string
+ since_id?: string
+ }
+ ): Promise>> {
+ let params = { q: q }
+ if (options) {
+ if (options.following !== undefined && options.following !== null) {
+ params = Object.assign(params, {
+ following: options.following
+ })
+ }
+ if (options.resolve !== undefined && options.resolve !== null) {
+ params = Object.assign(params, {
+ resolve: options.resolve
+ })
+ }
+ if (options.max_id) {
+ params = Object.assign(params, {
+ max_id: options.max_id
+ })
+ }
+ if (options.since_id) {
+ params = Object.assign(params, {
+ since_id: options.since_id
+ })
+ }
+ if (options.limit) {
+ params = Object.assign(params, {
+ limit: options.limit
+ })
+ }
+ }
+ return this.client.get>('/api/v1/accounts/search', params).then(res => {
+ return Object.assign(res, {
+ data: res.data.map(a => FriendicaAPI.Converter.account(a))
+ })
+ })
+ }
+
+ // ======================================
+ // accounts/bookmarks
+ // ======================================
+ /**
+ * GET /api/v1/bookmarks
+ *
+ * @param options.limit Max number of results to return. Defaults to 40.
+ * @param options.max_id Return results older than ID.
+ * @param options.since_id Return results newer than ID.
+ * @param options.min_id Return results immediately newer than ID.
+ * @return Array of statuses.
+ */
+ public async getBookmarks(options?: {
+ limit?: number
+ max_id?: string
+ since_id?: string
+ min_id?: string
+ }): Promise>> {
+ let params = {}
+ if (options) {
+ if (options.max_id) {
+ params = Object.assign(params, {
+ max_id: options.max_id
+ })
+ }
+ if (options.since_id) {
+ params = Object.assign(params, {
+ since_id: options.since_id
+ })
+ }
+ if (options.limit) {
+ params = Object.assign(params, {
+ limit: options.limit
+ })
+ }
+ if (options.min_id) {
+ params = Object.assign(params, {
+ min_id: options.min_id
+ })
+ }
+ }
+ return this.client.get>('/api/v1/bookmarks', params).then(res => {
+ return Object.assign(res, {
+ data: res.data.map(s => FriendicaAPI.Converter.status(s))
+ })
+ })
+ }
+
+ // ======================================
+ // accounts/favourites
+ // ======================================
+ /**
+ * GET /api/v1/favourites
+ *
+ * @param options.limit Max number of results to return. Defaults to 40.
+ * @param options.max_id Return results older than ID.
+ * @param options.min_id Return results immediately newer than ID.
+ * @return Array of statuses.
+ */
+ public async getFavourites(options?: { limit?: number; max_id?: string; min_id?: string }): Promise>> {
+ let params = {}
+ if (options) {
+ if (options.min_id) {
+ params = Object.assign(params, {
+ min_id: options.min_id
+ })
+ }
+ if (options.max_id) {
+ params = Object.assign(params, {
+ max_id: options.max_id
+ })
+ }
+ if (options.limit) {
+ params = Object.assign(params, {
+ limit: options.limit
+ })
+ }
+ }
+ return this.client.get>('/api/v1/favourites', params).then(res => {
+ return Object.assign(res, {
+ data: res.data.map(s => FriendicaAPI.Converter.status(s))
+ })
+ })
+ }
+
+ // ======================================
+ // accounts/mutes
+ // ======================================
+ /**
+ * GET /api/v1/mutes
+ *
+ * @param options.limit Max number of results to return. Defaults to 40.
+ * @param options.max_id Return results older than ID.
+ * @param options.min_id Return results immediately newer than ID.
+ * @return Array of accounts.
+ */
+ public async getMutes(options?: { limit?: number; max_id?: string; min_id?: string }): Promise>> {
+ let params = {}
+ if (options) {
+ if (options.min_id) {
+ params = Object.assign(params, {
+ min_id: options.min_id
+ })
+ }
+ if (options.max_id) {
+ params = Object.assign(params, {
+ max_id: options.max_id
+ })
+ }
+ if (options.limit) {
+ params = Object.assign(params, {
+ limit: options.limit
+ })
+ }
+ }
+ return this.client.get>('/api/v1/mutes', params).then(res => {
+ return Object.assign(res, {
+ data: res.data.map(a => FriendicaAPI.Converter.account(a))
+ })
+ })
+ }
+
+ // ======================================
+ // accounts/blocks
+ // ======================================
+ /**
+ * GET /api/v1/blocks
+ *
+ * @param options.limit Max number of results to return. Defaults to 40.
+ * @param options.max_id Return results older than ID.
+ * @param options.min_id Return results immediately newer than ID.
+ * @return Array of accounts.
+ */
+ public async getBlocks(options?: { limit?: number; max_id?: string; min_id?: string }): Promise>> {
+ let params = {}
+ if (options) {
+ if (options.min_id) {
+ params = Object.assign(params, {
+ min_id: options.min_id
+ })
+ }
+ if (options.max_id) {
+ params = Object.assign(params, {
+ max_id: options.max_id
+ })
+ }
+ if (options.limit) {
+ params = Object.assign(params, {
+ limit: options.limit
+ })
+ }
+ }
+ return this.client.get>('/api/v1/blocks', params).then(res => {
+ return Object.assign(res, {
+ data: res.data.map(a => FriendicaAPI.Converter.account(a))
+ })
+ })
+ }
+
+ // ======================================
+ // accounts/domain_blocks
+ // ======================================
+ public async getDomainBlocks(_options?: { limit?: number; max_id?: string; min_id?: string }): Promise>> {
+ return new Promise((_, reject) => {
+ const err = new NoImplementedError('friendica does not support')
+ reject(err)
+ })
+ }
+
+ public blockDomain(_domain: string): Promise>> {
+ return new Promise((_, reject) => {
+ const err = new NoImplementedError('friendica does not support')
+ reject(err)
+ })
+ }
+
+ public unblockDomain(_domain: string): Promise>> {
+ return new Promise((_, reject) => {
+ const err = new NoImplementedError('friendica does not support')
+ reject(err)
+ })
+ }
+
+ // ======================================
+ // accounts/filters
+ // ======================================
+ /**
+ * GET /api/v1/filters
+ *
+ * @return Array of filters.
+ */
+ public async getFilters(): Promise>> {
+ return this.client.get>('/api/v1/filters').then(res => {
+ return Object.assign(res, {
+ data: res.data.map(f => FriendicaAPI.Converter.filter(f))
+ })
+ })
+ }
+
+ public async getFilter(_id: string): Promise> {
+ return new Promise((_, reject) => {
+ const err = new NoImplementedError('friendica does not support')
+ reject(err)
+ })
+ }
+
+ public async createFilter(
+ _phrase: string,
+ _context: Array,
+ _options?: {
+ irreversible?: boolean
+ whole_word?: boolean
+ expires_in?: string
+ }
+ ): Promise> {
+ return new Promise((_, reject) => {
+ const err = new NoImplementedError('friendica does not support')
+ reject(err)
+ })
+ }
+
+ public async updateFilter(
+ _id: string,
+ _phrase: string,
+ _context: Array,
+ _options?: {
+ irreversible?: boolean
+ whole_word?: boolean
+ expires_in?: string
+ }
+ ): Promise> {
+ return new Promise((_, reject) => {
+ const err = new NoImplementedError('friendica does not support')
+ reject(err)
+ })
+ }
+
+ public async deleteFilter(_id: string): Promise> {
+ return new Promise((_, reject) => {
+ const err = new NoImplementedError('friendica does not support')
+ reject(err)
+ })
+ }
+
+ // ======================================
+ // accounts/reports
+ // ======================================
+ public async report(
+ _account_id: string,
+ _options?: {
+ status_ids?: Array
+ comment: string
+ forward?: boolean
+ category?: Entity.Category
+ rule_ids?: Array
+ }
+ ): Promise> {
+ return new Promise((_, reject) => {
+ const err = new NoImplementedError('friendica does not support')
+ reject(err)
+ })
+ }
+
+ // ======================================
+ // accounts/follow_requests
+ // ======================================
+ /**
+ * GET /api/v1/follow_requests
+ *
+ * @param limit Maximum number of results.
+ * @return Array of FollowRequest.
+ */
+ public async getFollowRequests(limit?: number): Promise>> {
+ if (limit) {
+ return this.client
+ .get>('/api/v1/follow_requests', {
+ limit: limit
+ })
+ .then(res => {
+ return Object.assign(res, {
+ data: res.data.map(a => FriendicaAPI.Converter.follow_request(a))
+ })
+ })
+ } else {
+ return this.client.get>('/api/v1/follow_requests').then(res => {
+ return Object.assign(res, {
+ data: res.data.map(a => FriendicaAPI.Converter.follow_request(a))
+ })
+ })
+ }
+ }
+
+ /**
+ * POST /api/v1/follow_requests/:id/authorize
+ *
+ * @param id The FollowRequest ID.
+ * @return Relationship.
+ */
+ public async acceptFollowRequest(id: string): Promise> {
+ return this.client.post(`/api/v1/follow_requests/${id}/authorize`).then(res => {
+ return Object.assign(res, {
+ data: FriendicaAPI.Converter.relationship(res.data)
+ })
+ })
+ }
+
+ /**
+ * POST /api/v1/follow_requests/:id/reject
+ *
+ * @param id The FollowRequest ID.
+ * @return Relationship.
+ */
+ public async rejectFollowRequest(id: string): Promise> {
+ return this.client.post(`/api/v1/follow_requests/${id}/reject`).then(res => {
+ return Object.assign(res, {
+ data: FriendicaAPI.Converter.relationship(res.data)
+ })
+ })
+ }
+
+ // ======================================
+ // accounts/endorsements
+ // ======================================
+ /**
+ * GET /api/v1/endorsements
+ *
+ * @param options.limit Max number of results to return. Defaults to 40.
+ * @param options.max_id Return results older than ID.
+ * @param options.since_id Return results newer than ID.
+ * @return Array of accounts.
+ */
+ public async getEndorsements(options?: { limit?: number; max_id?: string; since_id?: string }): Promise>> {
+ let params = {}
+ if (options) {
+ if (options.limit) {
+ params = Object.assign(params, {
+ limit: options.limit
+ })
+ }
+ if (options.max_id) {
+ params = Object.assign(params, {
+ max_id: options.max_id
+ })
+ }
+ if (options.since_id) {
+ params = Object.assign(params, {
+ since_id: options.since_id
+ })
+ }
+ }
+ return this.client.get>('/api/v1/endorsements', params).then(res => {
+ return Object.assign(res, {
+ data: res.data.map(a => FriendicaAPI.Converter.account(a))
+ })
+ })
+ }
+
+ // ======================================
+ // accounts/featured_tags
+ // ======================================
+ public async getFeaturedTags(): Promise>> {
+ return new Promise((_, reject) => {
+ const err = new NoImplementedError('friendica does not support')
+ reject(err)
+ })
+ }
+
+ public async createFeaturedTag(_name: string): Promise> {
+ return new Promise((_, reject) => {
+ const err = new NoImplementedError('friendica does not support')
+ reject(err)
+ })
+ }
+
+ public deleteFeaturedTag(_id: string): Promise>> {
+ return new Promise((_, reject) => {
+ const err = new NoImplementedError('friendica does not support')
+ reject(err)
+ })
+ }
+
+ public async getSuggestedTags(): Promise>> {
+ return new Promise((_, reject) => {
+ const err = new NoImplementedError('friendica does not support')
+ reject(err)
+ })
+ }
+
+ // ======================================
+ // accounts/preferences
+ // ======================================
+ /**
+ * GET /api/v1/preferences
+ *
+ * @return Preferences.
+ */
+ public async getPreferences(): Promise> {
+ return this.client.get('/api/v1/preferences').then(res => {
+ return Object.assign(res, {
+ data: FriendicaAPI.Converter.preferences(res.data)
+ })
+ })
+ }
+
+ // ======================================
+ // accounts/followed_tags
+ // ======================================
+ public async getFollowedTags(): Promise>> {
+ return new Promise((_, reject) => {
+ const err = new NoImplementedError('friendica does not support')
+ reject(err)
+ })
+ }
+
+ // ======================================
+ // accounts/suggestions
+ // ======================================
+ /**
+ * GET /api/v1/suggestions
+ *
+ * @param limit Maximum number of results.
+ * @return Array of accounts.
+ */
+ public async getSuggestions(limit?: number): Promise>> {
+ if (limit) {
+ return this.client
+ .get>('/api/v1/suggestions', {
+ limit: limit
+ })
+ .then(res => {
+ return Object.assign(res, {
+ data: res.data.map(a => FriendicaAPI.Converter.account(a))
+ })
+ })
+ } else {
+ return this.client.get>('/api/v1/suggestions').then(res => {
+ return Object.assign(res, {
+ data: res.data.map(a => FriendicaAPI.Converter.account(a))
+ })
+ })
+ }
+ }
+
+ // ======================================
+ // accounts/tags
+ // ======================================
+ /**
+ * GET /api/v1/tags/:id
+ *
+ * @param id Target hashtag id.
+ * @return Tag
+ */
+ public async getTag(id: string): Promise> {
+ return this.client.get(`/api/v1/tags/${id}`).then(res => {
+ return Object.assign(res, {
+ data: FriendicaAPI.Converter.tag(res.data)
+ })
+ })
+ }
+
+ /**
+ * POST /api/v1/tags/:id/follow
+ *
+ * @param id Target hashtag id.
+ * @return Tag
+ */
+ public async followTag(id: string): Promise> {
+ return this.client.post(`/api/v1/tags/${id}/follow`).then(res => {
+ return Object.assign(res, {
+ data: FriendicaAPI.Converter.tag(res.data)
+ })
+ })
+ }
+
+ /**
+ * POST /api/v1/tags/:id/unfollow
+ *
+ * @param id Target hashtag id.
+ * @return Tag
+ */
+ public async unfollowTag(id: string): Promise> {
+ return this.client.post(`/api/v1/tags/${id}/unfollow`).then(res => {
+ return Object.assign(res, {
+ data: FriendicaAPI.Converter.tag(res.data)
+ })
+ })
+ }
+
+ // ======================================
+ // statuses
+ // ======================================
+ /**
+ * POST /api/v1/statuses
+ *
+ * @param status Text content of status.
+ * @param options.media_ids Array of Attachment ids.
+ * @param options.poll Poll object.
+ * @param options.in_reply_to_id ID of the status being replied to, if status is a reply.
+ * @param options.sensitive Mark status and attached media as sensitive?
+ * @param options.spoiler_text Text to be shown as a warning or subject before the actual content.
+ * @param options.visibility Visibility of the posted status.
+ * @param options.scheduled_at ISO 8601 Datetime at which to schedule a status.
+ * @param options.language ISO 639 language code for this status.
+ * @param options.quote_id ID of the status being quoted to, if status is a quote.
+ * @return Status. When options.scheduled_at is present, ScheduledStatus is returned instead.
+ */
+ public async postStatus(
+ status: string,
+ options: {
+ media_ids?: Array
+ poll?: { options: Array; expires_in: number; multiple?: boolean; hide_totals?: boolean }
+ in_reply_to_id?: string
+ sensitive?: boolean
+ spoiler_text?: string
+ visibility?: 'public' | 'unlisted' | 'private' | 'direct'
+ scheduled_at?: string
+ language?: string
+ quote_id?: string
+ }
+ ): Promise> {
+ let params = {
+ status: status
+ }
+ if (options) {
+ if (options.media_ids) {
+ params = Object.assign(params, {
+ media_ids: options.media_ids
+ })
+ }
+ if (options.poll) {
+ let pollParam = {
+ options: options.poll.options,
+ expires_in: options.poll.expires_in
+ }
+ if (options.poll.multiple !== undefined) {
+ pollParam = Object.assign(pollParam, {
+ multiple: options.poll.multiple
+ })
+ }
+ if (options.poll.hide_totals !== undefined) {
+ pollParam = Object.assign(pollParam, {
+ hide_totals: options.poll.hide_totals
+ })
+ }
+ params = Object.assign(params, {
+ poll: pollParam
+ })
+ }
+ if (options.in_reply_to_id) {
+ params = Object.assign(params, {
+ in_reply_to_id: options.in_reply_to_id
+ })
+ }
+ if (options.sensitive !== undefined) {
+ params = Object.assign(params, {
+ sensitive: options.sensitive
+ })
+ }
+ if (options.spoiler_text) {
+ params = Object.assign(params, {
+ spoiler_text: options.spoiler_text
+ })
+ }
+ if (options.visibility) {
+ params = Object.assign(params, {
+ visibility: options.visibility
+ })
+ }
+ if (options.scheduled_at) {
+ params = Object.assign(params, {
+ scheduled_at: options.scheduled_at
+ })
+ }
+ if (options.language) {
+ params = Object.assign(params, {
+ language: options.language
+ })
+ }
+ if (options.quote_id) {
+ params = Object.assign(params, {
+ quote_id: options.quote_id
+ })
+ }
+ }
+ if (options.scheduled_at) {
+ return this.client.post('/api/v1/statuses', params).then(res => {
+ return Object.assign(res, {
+ data: FriendicaAPI.Converter.scheduled_status(res.data)
+ })
+ })
+ }
+ return this.client.post('/api/v1/statuses', params).then(res => {
+ return Object.assign(res, {
+ data: FriendicaAPI.Converter.status(res.data)
+ })
+ })
+ }
+ /**
+ * GET /api/v1/statuses/:id
+ *
+ * @param id The target status id.
+ * @return Status
+ */
+ public async getStatus(id: string): Promise> {
+ return this.client.get(`/api/v1/statuses/${id}`).then(res => {
+ return Object.assign(res, {
+ data: FriendicaAPI.Converter.status(res.data)
+ })
+ })
+ }
+
+ /**
+ PUT /api/v1/statuses/:id
+ *
+ * @param id The target status id.
+ * @return Status
+ */
+ public async editStatus(
+ id: string,
+ options: {
+ status?: string
+ spoiler_text?: string
+ sensitive?: boolean
+ media_ids?: Array
+ poll?: { options?: Array; expires_in?: number; multiple?: boolean; hide_totals?: boolean }
+ }
+ ): Promise> {
+ let params = {}
+ if (options.status) {
+ params = Object.assign(params, {
+ status: options.status
+ })
+ }
+ if (options.spoiler_text) {
+ params = Object.assign(params, {
+ spoiler_text: options.spoiler_text
+ })
+ }
+ if (options.sensitive) {
+ params = Object.assign(params, {
+ sensitive: options.sensitive
+ })
+ }
+ if (options.media_ids) {
+ params = Object.assign(params, {
+ media_ids: options.media_ids
+ })
+ }
+ if (options.poll) {
+ let pollParam = {}
+ if (options.poll.options !== undefined) {
+ pollParam = Object.assign(pollParam, {
+ options: options.poll.options
+ })
+ }
+ if (options.poll.expires_in !== undefined) {
+ pollParam = Object.assign(pollParam, {
+ expires_in: options.poll.expires_in
+ })
+ }
+ if (options.poll.multiple !== undefined) {
+ pollParam = Object.assign(pollParam, {
+ multiple: options.poll.multiple
+ })
+ }
+ if (options.poll.hide_totals !== undefined) {
+ pollParam = Object.assign(pollParam, {
+ hide_totals: options.poll.hide_totals
+ })
+ }
+ params = Object.assign(params, {
+ poll: pollParam
+ })
+ }
+ return this.client.put(`/api/v1/statuses/${id}`, params).then(res => {
+ return Object.assign(res, {
+ data: FriendicaAPI.Converter.status(res.data)
+ })
+ })
+ }
+
+ /**
+ * DELETE /api/v1/statuses/:id
+ *
+ * @param id The target status id.
+ * @return Status
+ */
+ public async deleteStatus(id: string): Promise> {
+ return this.client.del(`/api/v1/statuses/${id}`).then(res => {
+ return Object.assign(res, {
+ data: FriendicaAPI.Converter.status(res.data)
+ })
+ })
+ }
+
+ /**
+ * GET /api/v1/statuses/:id/context
+ *
+ * Get parent and child statuses.
+ * @param id The target status id.
+ * @return Context
+ */
+ public async getStatusContext(
+ id: string,
+ options?: { limit?: number; max_id?: string; since_id?: string }
+ ): Promise> {
+ let params = {}
+ if (options) {
+ if (options.limit) {
+ params = Object.assign(params, {
+ limit: options.limit
+ })
+ }
+ if (options.max_id) {
+ params = Object.assign(params, {
+ max_id: options.max_id
+ })
+ }
+ if (options.since_id) {
+ params = Object.assign(params, {
+ since_id: options.since_id
+ })
+ }
+ }
+ return this.client.get(`/api/v1/statuses/${id}/context`, params).then(res => {
+ return Object.assign(res, {
+ data: FriendicaAPI.Converter.context(res.data)
+ })
+ })
+ }
+
+ /**
+ * GET /api/v1/statuses/:id/source
+ *
+ * Obtain the source properties for a status so that it can be edited.
+ * @param id The target status id.
+ * @return StatusSource
+ */
+ public async getStatusSource(id: string): Promise> {
+ return this.client.get(`/api/v1/statuses/${id}/source`).then(res => {
+ return Object.assign(res, {
+ data: FriendicaAPI.Converter.status_source(res.data)
+ })
+ })
+ }
+
+ /**
+ * GET /api/v1/statuses/:id/reblogged_by
+ *
+ * @param id The target status id.
+ * @return Array of accounts.
+ */
+ public async getStatusRebloggedBy(id: string): Promise>> {
+ return this.client.get>(`/api/v1/statuses/${id}/reblogged_by`).then(res => {
+ return Object.assign(res, {
+ data: res.data.map(a => FriendicaAPI.Converter.account(a))
+ })
+ })
+ }
+
+ /**
+ * GET /api/v1/statuses/:id/favourited_by
+ *
+ * @param id The target status id.
+ * @return Array of accounts.
+ */
+ public async getStatusFavouritedBy(id: string): Promise>> {
+ return this.client.get>(`/api/v1/statuses/${id}/favourited_by`).then(res => {
+ return Object.assign(res, {
+ data: res.data.map(a => FriendicaAPI.Converter.account(a))
+ })
+ })
+ }
+
+ /**
+ * POST /api/v1/statuses/:id/favourite
+ *
+ * @param id The target status id.
+ * @return Status.
+ */
+ public async favouriteStatus(id: string): Promise> {
+ return this.client.post(`/api/v1/statuses/${id}/favourite`).then(res => {
+ return Object.assign(res, {
+ data: FriendicaAPI.Converter.status(res.data)
+ })
+ })
+ }
+
+ /**
+ * POST /api/v1/statuses/:id/unfavourite
+ *
+ * @param id The target status id.
+ * @return Status.
+ */
+ public async unfavouriteStatus(id: string): Promise> {
+ return this.client.post(`/api/v1/statuses/${id}/unfavourite`).then(res => {
+ return Object.assign(res, {
+ data: FriendicaAPI.Converter.status(res.data)
+ })
+ })
+ }
+
+ /**
+ * POST /api/v1/statuses/:id/reblog
+ *
+ * @param id The target status id.
+ * @return Status.
+ */
+ public async reblogStatus(id: string): Promise> {
+ return this.client.post(`/api/v1/statuses/${id}/reblog`).then(res => {
+ return Object.assign(res, {
+ data: FriendicaAPI.Converter.status(res.data)
+ })
+ })
+ }
+
+ /**
+ * POST /api/v1/statuses/:id/unreblog
+ *
+ * @param id The target status id.
+ * @return Status.
+ */
+ public async unreblogStatus(id: string): Promise> {
+ return this.client.post(`/api/v1/statuses/${id}/unreblog`).then(res => {
+ return Object.assign(res, {
+ data: FriendicaAPI.Converter.status(res.data)
+ })
+ })
+ }
+
+ /**
+ * POST /api/v1/statuses/:id/bookmark
+ *
+ * @param id The target status id.
+ * @return Status.
+ */
+ public async bookmarkStatus(id: string): Promise> {
+ return this.client.post(`/api/v1/statuses/${id}/bookmark`).then(res => {
+ return Object.assign(res, {
+ data: FriendicaAPI.Converter.status(res.data)
+ })
+ })
+ }
+
+ /**
+ * POST /api/v1/statuses/:id/unbookmark
+ *
+ * @param id The target status id.
+ * @return Status.
+ */
+ public async unbookmarkStatus(id: string): Promise> {
+ return this.client.post(`/api/v1/statuses/${id}/unbookmark`).then(res => {
+ return Object.assign(res, {
+ data: FriendicaAPI.Converter.status(res.data)
+ })
+ })
+ }
+
+ /**
+ * POST /api/v1/statuses/:id/mute
+ *
+ * @param id The target status id.
+ * @return Status
+ */
+ public async muteStatus(id: string): Promise> {
+ return this.client.post(`/api/v1/statuses/${id}/mute`).then(res => {
+ return Object.assign(res, {
+ data: FriendicaAPI.Converter.status(res.data)
+ })
+ })
+ }
+
+ /**
+ * POST /api/v1/statuses/:id/unmute
+ *
+ * @param id The target status id.
+ * @return Status
+ */
+ public async unmuteStatus(id: string): Promise> {
+ return this.client.post(`/api/v1/statuses/${id}/unmute`).then(res => {
+ return Object.assign(res, {
+ data: FriendicaAPI.Converter.status(res.data)
+ })
+ })
+ }
+
+ /**
+ * POST /api/v1/statuses/:id/pin
+ * @param id The target status id.
+ * @return Status
+ */
+ public async pinStatus(id: string): Promise> {
+ return this.client.post(`/api/v1/statuses/${id}/pin`).then(res => {
+ return Object.assign(res, {
+ data: FriendicaAPI.Converter.status(res.data)
+ })
+ })
+ }
+
+ /**
+ * POST /api/v1/statuses/:id/unpin
+ *
+ * @param id The target status id.
+ * @return Status
+ */
+ public async unpinStatus(id: string): Promise> {
+ return this.client.post(`/api/v1/statuses/${id}/unpin`).then(res => {
+ return Object.assign(res, {
+ data: FriendicaAPI.Converter.status(res.data)
+ })
+ })
+ }
+
+ // ======================================
+ // statuses/media
+ // ======================================
+ /**
+ * POST /api/v2/media
+ *
+ * @param file The file to be attached, using multipart form data.
+ * @param options.description A plain-text description of the media.
+ * @param options.focus Two floating points (x,y), comma-delimited, ranging from -1.0 to 1.0.
+ * @return Attachment
+ */
+ public async uploadMedia(
+ file: any,
+ options?: { description?: string; focus?: string }
+ ): Promise> {
+ const formData = new FormData()
+ formData.append('file', file)
+ if (options) {
+ if (options.description) {
+ formData.append('description', options.description)
+ }
+ if (options.focus) {
+ formData.append('focus', options.focus)
+ }
+ }
+ return this.client.postForm('/api/v2/media', formData).then(res => {
+ return Object.assign(res, {
+ data: FriendicaAPI.Converter.async_attachment(res.data)
+ })
+ })
+ }
+
+ /**
+ * GET /api/v1/media/:id
+ *
+ * @param id Target media ID.
+ * @return Attachment
+ */
+ public async getMedia(id: string): Promise> {
+ const res = await this.client.get(`/api/v1/media/${id}`)
+
+ return Object.assign(res, {
+ data: FriendicaAPI.Converter.attachment(res.data)
+ })
+ }
+
+ /**
+ * PUT /api/v1/media/:id
+ *
+ * @param id Target media ID.
+ * @param options.file The file to be attached, using multipart form data.
+ * @param options.description A plain-text description of the media.
+ * @param options.focus Two floating points (x,y), comma-delimited, ranging from -1.0 to 1.0.
+ * @param options.is_sensitive Whether the media is sensitive.
+ * @return Attachment
+ */
+ public async updateMedia(
+ id: string,
+ options?: {
+ file?: any
+ description?: string
+ focus?: string
+ }
+ ): Promise> {
+ const formData = new FormData()
+ if (options) {
+ if (options.file) {
+ formData.append('file', options.file)
+ }
+ if (options.description) {
+ formData.append('description', options.description)
+ }
+ if (options.focus) {
+ formData.append('focus', options.focus)
+ }
+ }
+ return this.client.putForm(`/api/v1/media/${id}`, formData).then(res => {
+ return Object.assign(res, {
+ data: FriendicaAPI.Converter.attachment(res.data)
+ })
+ })
+ }
+
+ // ======================================
+ // statuses/polls
+ // ======================================
+ /**
+ * GET /api/v1/polls/:id
+ *
+ * @param id Target poll ID.
+ * @return Poll
+ */
+ public async getPoll(id: string): Promise> {
+ return this.client.get(`/api/v1/polls/${id}`).then(res => {
+ return Object.assign(res, {
+ data: FriendicaAPI.Converter.poll(res.data)
+ })
+ })
+ }
+
+ public async votePoll(_id: string, _choices: Array): Promise> {
+ return new Promise((_, reject) => {
+ const err = new NoImplementedError('friendica does not support')
+ reject(err)
+ })
+ }
+
+ // ======================================
+ // statuses/scheduled_statuses
+ // ======================================
+ /**
+ * GET /api/v1/scheduled_statuses
+ *
+ * @param options.limit Max number of results to return. Defaults to 20.
+ * @param options.max_id Return results older than ID.
+ * @param options.since_id Return results newer than ID.
+ * @param options.min_id Return results immediately newer than ID.
+ * @return Array of scheduled statuses.
+ */
+ public async getScheduledStatuses(options?: {
+ limit?: number | null
+ max_id?: string | null
+ since_id?: string | null
+ min_id?: string | null
+ }): Promise>> {
+ let params = {}
+ if (options) {
+ if (options.limit) {
+ params = Object.assign(params, {
+ limit: options.limit
+ })
+ }
+ if (options.max_id) {
+ params = Object.assign(params, {
+ max_id: options.max_id
+ })
+ }
+ if (options.since_id) {
+ params = Object.assign(params, {
+ since_id: options.since_id
+ })
+ }
+ if (options.min_id) {
+ params = Object.assign(params, {
+ min_id: options.min_id
+ })
+ }
+ }
+ return this.client.get>('/api/v1/scheduled_statuses', params).then(res => {
+ return Object.assign(res, {
+ data: res.data.map(s => FriendicaAPI.Converter.scheduled_status(s))
+ })
+ })
+ }
+
+ /**
+ * GET /api/v1/scheduled_statuses/:id
+ *
+ * @param id Target status ID.
+ * @return ScheduledStatus.
+ */
+ public async getScheduledStatus(id: string): Promise> {
+ return this.client.get(`/api/v1/scheduled_statuses/${id}`).then(res => {
+ return Object.assign(res, {
+ data: FriendicaAPI.Converter.scheduled_status(res.data)
+ })
+ })
+ }
+
+ public async scheduleStatus(_id: string, _scheduled_at?: string | null): Promise> {
+ return new Promise((_, reject) => {
+ const err = new NoImplementedError('friendica does not support')
+ reject(err)
+ })
+ }
+
+ /**
+ * DELETE /api/v1/scheduled_statuses/:id
+ *
+ * @param id Target scheduled status ID.
+ */
+ public cancelScheduledStatus(id: string): Promise>> {
+ return this.client.del>(`/api/v1/scheduled_statuses/${id}`)
+ }
+
+ // ======================================
+ // timelines
+ // ======================================
+ /**
+ * GET /api/v1/timelines/public
+ *
+ * @param options.only_media Show only statuses with media attached? Defaults to false.
+ * @param options.limit Max number of results to return. Defaults to 20.
+ * @param options.max_id Return results older than ID.
+ * @param options.since_id Return results newer than ID.
+ * @param options.min_id Return results immediately newer than ID.
+ * @return Array of statuses.
+ */
+ public async getPublicTimeline(options?: {
+ only_media?: boolean
+ limit?: number
+ max_id?: string
+ since_id?: string
+ min_id?: string
+ }): Promise>> {
+ let params = {
+ local: false
+ }
+ if (options) {
+ if (options.only_media !== undefined) {
+ params = Object.assign(params, {
+ only_media: options.only_media
+ })
+ }
+ if (options.max_id) {
+ params = Object.assign(params, {
+ max_id: options.max_id
+ })
+ }
+ if (options.since_id) {
+ params = Object.assign(params, {
+ since_id: options.since_id
+ })
+ }
+ if (options.min_id) {
+ params = Object.assign(params, {
+ min_id: options.min_id
+ })
+ }
+ if (options.limit) {
+ params = Object.assign(params, {
+ limit: options.limit
+ })
+ }
+ }
+ return this.client.get>('/api/v1/timelines/public', params).then(res => {
+ return Object.assign(res, {
+ data: res.data.map(s => FriendicaAPI.Converter.status(s))
+ })
+ })
+ }
+
+ /**
+ * GET /api/v1/timelines/public
+ *
+ * @param options.only_media Show only statuses with media attached? Defaults to false.
+ * @param options.limit Max number of results to return. Defaults to 20.
+ * @param options.max_id Return results older than ID.
+ * @param options.since_id Return results newer than ID.
+ * @param options.min_id Return results immediately newer than ID.
+ * @return Array of statuses.
+ */
+ public async getLocalTimeline(options?: {
+ only_media?: boolean
+ limit?: number
+ max_id?: string
+ since_id?: string
+ min_id?: string
+ }): Promise>> {
+ let params = {
+ local: true
+ }
+ if (options) {
+ if (options.only_media !== undefined) {
+ params = Object.assign(params, {
+ only_media: options.only_media
+ })
+ }
+ if (options.max_id) {
+ params = Object.assign(params, {
+ max_id: options.max_id
+ })
+ }
+ if (options.since_id) {
+ params = Object.assign(params, {
+ since_id: options.since_id
+ })
+ }
+ if (options.min_id) {
+ params = Object.assign(params, {
+ min_id: options.min_id
+ })
+ }
+ if (options.limit) {
+ params = Object.assign(params, {
+ limit: options.limit
+ })
+ }
+ }
+ return this.client.get>('/api/v1/timelines/public', params).then(res => {
+ return Object.assign(res, {
+ data: res.data.map(s => FriendicaAPI.Converter.status(s))
+ })
+ })
+ }
+
+ /**
+ * GET /api/v1/timelines/tag/:hashtag
+ *
+ * @param hashtag Content of a #hashtag, not including # symbol.
+ * @param options.local Show only local statuses? Defaults to false.
+ * @param options.only_media Show only statuses with media attached? Defaults to false.
+ * @param options.limit Max number of results to return. Defaults to 20.
+ * @param options.max_id Return results older than ID.
+ * @param options.since_id Return results newer than ID.
+ * @param options.min_id Return results immediately newer than ID.
+ * @return Array of statuses.
+ */
+ public async getTagTimeline(
+ hashtag: string,
+ options?: {
+ local?: boolean
+ only_media?: boolean
+ limit?: number
+ max_id?: string
+ since_id?: string
+ min_id?: string
+ }
+ ): Promise>> {
+ let params = {}
+ if (options) {
+ if (options.local !== undefined) {
+ params = Object.assign(params, {
+ local: options.local
+ })
+ }
+ if (options.only_media !== undefined) {
+ params = Object.assign(params, {
+ only_media: options.only_media
+ })
+ }
+ if (options.max_id) {
+ params = Object.assign(params, {
+ max_id: options.max_id
+ })
+ }
+ if (options.since_id) {
+ params = Object.assign(params, {
+ since_id: options.since_id
+ })
+ }
+ if (options.min_id) {
+ params = Object.assign(params, {
+ min_id: options.min_id
+ })
+ }
+ if (options.limit) {
+ params = Object.assign(params, {
+ limit: options.limit
+ })
+ }
+ }
+ return this.client.get>(`/api/v1/timelines/tag/${hashtag}`, params).then(res => {
+ return Object.assign(res, {
+ data: res.data.map(s => FriendicaAPI.Converter.status(s))
+ })
+ })
+ }
+
+ /**
+ * GET /api/v1/timelines/home
+ *
+ * @param options.local Show only local statuses? Defaults to false.
+ * @param options.limit Max number of results to return. Defaults to 20.
+ * @param options.max_id Return results older than ID.
+ * @param options.since_id Return results newer than ID.
+ * @param options.min_id Return results immediately newer than ID.
+ * @return Array of statuses.
+ */
+ public async getHomeTimeline(options?: {
+ local?: boolean
+ limit?: number
+ max_id?: string
+ since_id?: string
+ min_id?: string
+ }): Promise>> {
+ let params = {}
+ if (options) {
+ if (options.local !== undefined) {
+ params = Object.assign(params, {
+ local: options.local
+ })
+ }
+ if (options.max_id) {
+ params = Object.assign(params, {
+ max_id: options.max_id
+ })
+ }
+ if (options.since_id) {
+ params = Object.assign(params, {
+ since_id: options.since_id
+ })
+ }
+ if (options.min_id) {
+ params = Object.assign(params, {
+ min_id: options.min_id
+ })
+ }
+ if (options.limit) {
+ params = Object.assign(params, {
+ limit: options.limit
+ })
+ }
+ }
+ return this.client.get>('/api/v1/timelines/home', params).then(res => {
+ return Object.assign(res, {
+ data: res.data.map(s => FriendicaAPI.Converter.status(s))
+ })
+ })
+ }
+
+ /**
+ * GET /api/v1/timelines/list/:list_id
+ *
+ * @param list_id Local ID of the list in the database.
+ * @param options.limit Max number of results to return. Defaults to 20.
+ * @param options.max_id Return results older than ID.
+ * @param options.since_id Return results newer than ID.
+ * @param options.min_id Return results immediately newer than ID.
+ * @return Array of statuses.
+ */
+ public async getListTimeline(
+ list_id: string,
+ options?: {
+ limit?: number
+ max_id?: string
+ since_id?: string
+ min_id?: string
+ }
+ ): Promise>> {
+ let params = {}
+ if (options) {
+ if (options.max_id) {
+ params = Object.assign(params, {
+ max_id: options.max_id
+ })
+ }
+ if (options.since_id) {
+ params = Object.assign(params, {
+ since_id: options.since_id
+ })
+ }
+ if (options.min_id) {
+ params = Object.assign(params, {
+ min_id: options.min_id
+ })
+ }
+ if (options.limit) {
+ params = Object.assign(params, {
+ limit: options.limit
+ })
+ }
+ }
+ return this.client.get>(`/api/v1/timelines/list/${list_id}`, params).then(res => {
+ return Object.assign(res, {
+ data: res.data.map(s => FriendicaAPI.Converter.status(s))
+ })
+ })
+ }
+
+ // ======================================
+ // timelines/conversations
+ // ======================================
+ /**
+ * GET /api/v1/conversations
+ *
+ * @param options.limit Max number of results to return. Defaults to 20.
+ * @param options.max_id Return results older than ID.
+ * @param options.since_id Return results newer than ID.
+ * @param options.min_id Return results immediately newer than ID.
+ * @return Array of statuses.
+ */
+ public async getConversationTimeline(options?: {
+ limit?: number
+ max_id?: string
+ since_id?: string
+ min_id?: string
+ }): Promise>> {
+ let params = {}
+ if (options) {
+ if (options.max_id) {
+ params = Object.assign(params, {
+ max_id: options.max_id
+ })
+ }
+ if (options.since_id) {
+ params = Object.assign(params, {
+ since_id: options.since_id
+ })
+ }
+ if (options.min_id) {
+ params = Object.assign(params, {
+ min_id: options.min_id
+ })
+ }
+ if (options.limit) {
+ params = Object.assign(params, {
+ limit: options.limit
+ })
+ }
+ }
+ return this.client.get>('/api/v1/conversations', params).then(res => {
+ return Object.assign(res, {
+ data: res.data.map(c => FriendicaAPI.Converter.conversation(c))
+ })
+ })
+ }
+
+ /**
+ * DELETE /api/v1/conversations/:id
+ *
+ * @param id Target conversation ID.
+ */
+ public deleteConversation(id: string): Promise>> {
+ return this.client.del>(`/api/v1/conversations/${id}`)
+ }
+
+ /**
+ * POST /api/v1/conversations/:id/read
+ *
+ * @param id Target conversation ID.
+ * @return Conversation.
+ */
+ public async readConversation(id: string): Promise> {
+ return this.client.post(`/api/v1/conversations/${id}/read`).then(res => {
+ return Object.assign(res, {
+ data: FriendicaAPI.Converter.conversation(res.data)
+ })
+ })
+ }
+
+ // ======================================
+ // timelines/lists
+ // ======================================
+ /**
+ * GET /api/v1/lists
+ *
+ * @return Array of lists.
+ */
+ public async getLists(): Promise>> {
+ return this.client.get>('/api/v1/lists').then(res => {
+ return Object.assign(res, {
+ data: res.data.map(l => FriendicaAPI.Converter.list(l))
+ })
+ })
+ }
+
+ /**
+ * GET /api/v1/lists/:id
+ *
+ * @param id Target list ID.
+ * @return List.
+ */
+ public async getList(id: string): Promise> {
+ return this.client.get(`/api/v1/lists/${id}`).then(res => {
+ return Object.assign(res, {
+ data: FriendicaAPI.Converter.list(res.data)
+ })
+ })
+ }
+
+ /**
+ * POST /api/v1/lists
+ *
+ * @param title List name.
+ * @return List.
+ */
+ public async createList(title: string): Promise> {
+ return this.client
+ .post('/api/v1/lists', {
+ title: title
+ })
+ .then(res => {
+ return Object.assign(res, {
+ data: FriendicaAPI.Converter.list(res.data)
+ })
+ })
+ }
+
+ /**
+ * PUT /api/v1/lists/:id
+ *
+ * @param id Target list ID.
+ * @param title New list name.
+ * @return List.
+ */
+ public async updateList(id: string, title: string): Promise> {
+ return this.client
+ .put(`/api/v1/lists/${id}`, {
+ title: title
+ })
+ .then(res => {
+ return Object.assign(res, {
+ data: FriendicaAPI.Converter.list(res.data)
+ })
+ })
+ }
+
+ /**
+ * DELETE /api/v1/lists/:id
+ *
+ * @param id Target list ID.
+ */
+ public deleteList(id: string): Promise>> {
+ return this.client.del>(`/api/v1/lists/${id}`)
+ }
+
+ /**
+ * GET /api/v1/lists/:id/accounts
+ *
+ * @param id Target list ID.
+ * @param options.limit Max number of results to return.
+ * @param options.max_id Return results older than ID.
+ * @param options.since_id Return results newer than ID.
+ * @param options.min_id Return results immediately newer than ID.
+ * @return Array of accounts.
+ */
+ public async getAccountsInList(
+ id: string,
+ options?: {
+ limit?: number
+ max_id?: string
+ since_id?: string
+ }
+ ): Promise>> {
+ let params = {}
+ if (options) {
+ if (options.limit) {
+ params = Object.assign(params, {
+ limit: options.limit
+ })
+ }
+ if (options.max_id) {
+ params = Object.assign(params, {
+ max_id: options.max_id
+ })
+ }
+ if (options.since_id) {
+ params = Object.assign(params, {
+ since_id: options.since_id
+ })
+ }
+ }
+ return this.client.get>(`/api/v1/lists/${id}/accounts`, params).then(res => {
+ return Object.assign(res, {
+ data: res.data.map(a => FriendicaAPI.Converter.account(a))
+ })
+ })
+ }
+
+ /**
+ * POST /api/v1/lists/:id/accounts
+ *
+ * @param id Target list ID.
+ * @param account_ids Array of account IDs to add to the list.
+ */
+ public addAccountsToList(id: string, account_ids: Array): Promise>> {
+ return this.client.post>(`/api/v1/lists/${id}/accounts`, {
+ account_ids: account_ids
+ })
+ }
+
+ /**
+ * DELETE /api/v1/lists/:id/accounts
+ *
+ * @param id Target list ID.
+ * @param account_ids Array of account IDs to add to the list.
+ */
+ public deleteAccountsFromList(id: string, account_ids: Array): Promise>> {
+ return this.client.del>(`/api/v1/lists/${id}/accounts`, {
+ account_ids: account_ids
+ })
+ }
+
+ // ======================================
+ // timelines/markers
+ // ======================================
+ public async getMarkers(_timeline: Array): Promise>> {
+ return new Promise(resolve => {
+ const res: Response = {
+ data: {},
+ status: 200,
+ statusText: '200',
+ headers: {}
+ }
+ resolve(res)
+ })
+ }
+
+ public async saveMarkers(_options?: {
+ home?: { last_read_id: string }
+ notifications?: { last_read_id: string }
+ }): Promise> {
+ return new Promise(resolve => {
+ const res: Response = {
+ data: {},
+ status: 200,
+ statusText: '200',
+ headers: {}
+ }
+ resolve(res)
+ })
+ }
+
+ // ======================================
+ // notifications
+ // ======================================
+ /**
+ * GET /api/v1/notifications
+ *
+ * @param options.limit Max number of results to return. Defaults to 20.
+ * @param options.max_id Return results older than ID.
+ * @param options.since_id Return results newer than ID.
+ * @param options.min_id Return results immediately newer than ID.
+ * @param options.exclude_types Array of types to exclude.
+ * @param options.account_id Return only notifications received from this account.
+ * @return Array of notifications.
+ */
+ public async getNotifications(options?: {
+ limit?: number
+ max_id?: string
+ since_id?: string
+ min_id?: string
+ exclude_types?: Array
+ account_id?: string
+ }): Promise>> {
+ let params = {}
+ if (options) {
+ if (options.limit) {
+ params = Object.assign(params, {
+ limit: options.limit
+ })
+ }
+ if (options.max_id) {
+ params = Object.assign(params, {
+ max_id: options.max_id
+ })
+ }
+ if (options.since_id) {
+ params = Object.assign(params, {
+ since_id: options.since_id
+ })
+ }
+ if (options.min_id) {
+ params = Object.assign(params, {
+ min_id: options.min_id
+ })
+ }
+ if (options.exclude_types) {
+ params = Object.assign(params, {
+ exclude_types: options.exclude_types.map(e => FriendicaAPI.Converter.encodeNotificationType(e))
+ })
+ }
+ if (options.account_id) {
+ params = Object.assign(params, {
+ account_id: options.account_id
+ })
+ }
+ }
+ return this.client.get>('/api/v1/notifications', params).then(res => {
+ return Object.assign(res, {
+ data: res.data.flatMap(n => {
+ const notify = FriendicaAPI.Converter.notification(n)
+ if (notify instanceof UnknownNotificationTypeError) return []
+ return notify
+ })
+ })
+ })
+ }
+
+ /**
+ * GET /api/v1/notifications/:id
+ *
+ * @param id Target notification ID.
+ * @return Notification.
+ */
+ public async getNotification(id: string): Promise> {
+ const res = await this.client.get(`/api/v1/notifications/${id}`)
+ const notify = FriendicaAPI.Converter.notification(res.data)
+ if (notify instanceof UnknownNotificationTypeError) {
+ throw new UnknownNotificationTypeError()
+ }
+ return { ...res, data: notify }
+ }
+
+ /**
+ * POST /api/v1/notifications/clear
+ */
+ public dismissNotifications(): Promise>> {
+ return this.client.post>('/api/v1/notifications/clear')
+ }
+
+ /**
+ * POST /api/v1/notifications/:id/dismiss
+ *
+ * @param id Target notification ID.
+ */
+ public dismissNotification(id: string): Promise>> {
+ return this.client.post>(`/api/v1/notifications/${id}/dismiss`)
+ }
+
+ public readNotifications(_options: {
+ id?: string
+ max_id?: string
+ }): Promise