fedipod 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +123 -0
- package/architecture.svg +89 -0
- package/bin/fedipod.mjs +2303 -0
- package/cli.md +243 -0
- package/groups.md +85 -0
- package/gui.md +99 -0
- package/lib/account.mjs +87 -0
- package/lib/admin.mjs +1606 -0
- package/lib/atproto.mjs +310 -0
- package/lib/bskyfeed.mjs +166 -0
- package/lib/bskygroup.mjs +162 -0
- package/lib/c2s.mjs +305 -0
- package/lib/certs.mjs +265 -0
- package/lib/deliver.mjs +249 -0
- package/lib/directory.mjs +90 -0
- package/lib/embed.mjs +214 -0
- package/lib/export-collections.mjs +161 -0
- package/lib/front-core.mjs +322 -0
- package/lib/gateway-core.mjs +115 -0
- package/lib/guard.mjs +213 -0
- package/lib/home.mjs +161 -0
- package/lib/httpsig.mjs +109 -0
- package/lib/import.mjs +357 -0
- package/lib/intake.mjs +1710 -0
- package/lib/keys.mjs +139 -0
- package/lib/lease.mjs +190 -0
- package/lib/localapi.mjs +87 -0
- package/lib/mastoapi.mjs +1588 -0
- package/lib/migrate.mjs +127 -0
- package/lib/oidc-auth.mjs +53 -0
- package/lib/podrdf.mjs +132 -0
- package/lib/ports.mjs +27 -0
- package/lib/publisher.mjs +1003 -0
- package/lib/remote.mjs +303 -0
- package/lib/safefetch.mjs +235 -0
- package/lib/setup.mjs +262 -0
- package/lib/social.mjs +505 -0
- package/lib/storage.mjs +170 -0
- package/lib/store.mjs +470 -0
- package/lib/streaming.mjs +154 -0
- package/lib/tagfeed.mjs +158 -0
- package/lib/ua.mjs +29 -0
- package/lib/update.mjs +91 -0
- package/lib/webpush.mjs +98 -0
- package/lib/wire.mjs +762 -0
- package/package.json +60 -0
- package/phanpy/dist/404.html +40 -0
- package/phanpy/dist/_headers +2 -0
- package/phanpy/dist/apple-touch-icon.png +0 -0
- package/phanpy/dist/assets/ICONS-DIPJeU0C.js +2 -0
- package/phanpy/dist/assets/ICONS-DIPJeU0C.js.map +1 -0
- package/phanpy/dist/assets/ScheduledAtField-D-Abfo4J.js +183 -0
- package/phanpy/dist/assets/ScheduledAtField-D-Abfo4J.js.map +1 -0
- package/phanpy/dist/assets/Temml-CCjoacWt.woff2 +0 -0
- package/phanpy/dist/assets/__vite-browser-external-C7iut881.js +2 -0
- package/phanpy/dist/assets/__vite-browser-external-C7iut881.js.map +1 -0
- package/phanpy/dist/assets/boosts-carousel-BiOaNdMT.jpg +0 -0
- package/phanpy/dist/assets/catch-up-O5q5C75Z.png +0 -0
- package/phanpy/dist/assets/chunk-aKtaBQYM.js +1 -0
- package/phanpy/dist/assets/compose-CFEx7wb4.js +2 -0
- package/phanpy/dist/assets/compose-CFEx7wb4.js.map +1 -0
- package/phanpy/dist/assets/compose-ZJN5xSb4.js +35 -0
- package/phanpy/dist/assets/compose-ZJN5xSb4.js.map +1 -0
- package/phanpy/dist/assets/debug.module-YFQgwnIr.js +52 -0
- package/phanpy/dist/assets/debug.module-YFQgwnIr.js.map +1 -0
- package/phanpy/dist/assets/dom-p1YcJ5Rw.js +2 -0
- package/phanpy/dist/assets/dom-p1YcJ5Rw.js.map +1 -0
- package/phanpy/dist/assets/home-mobile-dark@2x-Dc-J8-SM.png +0 -0
- package/phanpy/dist/assets/home-mobile-light@2x-Bd3fod8m.png +0 -0
- package/phanpy/dist/assets/home-tablet-dark@2x-BgcNI8le.png +0 -0
- package/phanpy/dist/assets/home-tablet-light@2x-BW2RV9T6.png +0 -0
- package/phanpy/dist/assets/icons/add-circle-line-WD0mwORD.js +1 -0
- package/phanpy/dist/assets/icons/alert-line-CpFUMf9s.js +1 -0
- package/phanpy/dist/assets/icons/android-2-line-Bzxnrd3S.js +1 -0
- package/phanpy/dist/assets/icons/announcement-line-5nwaJFJm.js +1 -0
- package/phanpy/dist/assets/icons/arrow-down-circle-line-a7Id9-Iv.js +1 -0
- package/phanpy/dist/assets/icons/arrow-down-line-B8uzXzq6.js +1 -0
- package/phanpy/dist/assets/icons/arrow-left-line-CGrGT9C-.js +1 -0
- package/phanpy/dist/assets/icons/arrow-right-line-CE-HERfX.js +1 -0
- package/phanpy/dist/assets/icons/arrow-to-up-line-CvdQjmcy.js +1 -0
- package/phanpy/dist/assets/icons/arrow-up-circle-line-DEparSNF.js +1 -0
- package/phanpy/dist/assets/icons/arrow-up-line-C7yBeugc.js +1 -0
- package/phanpy/dist/assets/icons/arrows-right-line-BtbXmJGP.js +1 -0
- package/phanpy/dist/assets/icons/at-line-CF66Bznk.js +1 -0
- package/phanpy/dist/assets/icons/attachment-line-BY8It-l9.js +1 -0
- package/phanpy/dist/assets/icons/blockquote-line-BS6pU_hm.js +1 -0
- package/phanpy/dist/assets/icons/board-line-C0UWXyA6.js +1 -0
- package/phanpy/dist/assets/icons/bookmark-line-1CtX5JLF.js +1 -0
- package/phanpy/dist/assets/icons/building-5-line-Dd59MzbO.js +1 -0
- package/phanpy/dist/assets/icons/bus-2-line-6_dvA0GG.js +1 -0
- package/phanpy/dist/assets/icons/calendar-day-line-Crpl_fmu.js +1 -0
- package/phanpy/dist/assets/icons/calendar-month-line-vommts2C.js +1 -0
- package/phanpy/dist/assets/icons/calendar-time-add-line-BU_DTlQk.js +1 -0
- package/phanpy/dist/assets/icons/camera-line-Cv69MEei.js +1 -0
- package/phanpy/dist/assets/icons/celebrate-line-B8DvIoi0.js +1 -0
- package/phanpy/dist/assets/icons/chart-bar-line-FK-QOmtF.js +1 -0
- package/phanpy/dist/assets/icons/chart-line-line-BGcJAFWw.js +1 -0
- package/phanpy/dist/assets/icons/chat-3-line-YFK2-hwz.js +1 -0
- package/phanpy/dist/assets/icons/check-circle-line-DsAxFr5-.js +1 -0
- package/phanpy/dist/assets/icons/clipboard-line-BBp364gJ.js +1 -0
- package/phanpy/dist/assets/icons/close-circle-line-CICdiuGz.js +1 -0
- package/phanpy/dist/assets/icons/close-line-DYrFWcjv.js +1 -0
- package/phanpy/dist/assets/icons/cloud-line-tiQAbah8.js +1 -0
- package/phanpy/dist/assets/icons/code-line-fAl8iiV3.js +1 -0
- package/phanpy/dist/assets/icons/comment-2-line-CR3g7WeB.js +1 -0
- package/phanpy/dist/assets/icons/copy-2-line-CLW69yF5.js +1 -0
- package/phanpy/dist/assets/icons/delete-2-line-B8xmbr4n.js +1 -0
- package/phanpy/dist/assets/icons/document-3-line-DBGNhAg2.js +1 -0
- package/phanpy/dist/assets/icons/document-line-C7mb9sb7.js +1 -0
- package/phanpy/dist/assets/icons/down-line-BGqSSThd.js +1 -0
- package/phanpy/dist/assets/icons/edit-4-line-DcgGaEhx.js +1 -0
- package/phanpy/dist/assets/icons/emoji-2-line-D8YgnM-y.js +1 -0
- package/phanpy/dist/assets/icons/exit-line-Dc07BS-x.js +1 -0
- package/phanpy/dist/assets/icons/external-link-line-BvpSkmjO.js +1 -0
- package/phanpy/dist/assets/icons/eye-2-line-CiWXx-f_.js +1 -0
- package/phanpy/dist/assets/icons/eye-close-line-BgTaSJ4P.js +1 -0
- package/phanpy/dist/assets/icons/filter-2-line-B_mR2usy.js +1 -0
- package/phanpy/dist/assets/icons/filter-line-C_nm-3cU.js +1 -0
- package/phanpy/dist/assets/icons/flag-1-line-C2HB05I0.js +1 -0
- package/phanpy/dist/assets/icons/forbid-circle-line-D3LTT9yd.js +1 -0
- package/phanpy/dist/assets/icons/formula-line-DLrt2d6j.js +1 -0
- package/phanpy/dist/assets/icons/grid-line-CgUmEikf.js +1 -0
- package/phanpy/dist/assets/icons/group-line-C5fnX0wU.js +1 -0
- package/phanpy/dist/assets/icons/hand-finger-2-line-BGgxbuL9.js +1 -0
- package/phanpy/dist/assets/icons/hashtag-line-CyTEEXBb.js +1 -0
- package/phanpy/dist/assets/icons/heart-crack-line-BZddbO2C.js +1 -0
- package/phanpy/dist/assets/icons/heart-line-CJ7K47J4.js +1 -0
- package/phanpy/dist/assets/icons/history-2-line-kWWvFzqv.js +1 -0
- package/phanpy/dist/assets/icons/history-line-bGQiwbTH.js +1 -0
- package/phanpy/dist/assets/icons/home-3-line-fRU1zeLG.js +1 -0
- package/phanpy/dist/assets/icons/information-line-iNk6-sOY.js +1 -0
- package/phanpy/dist/assets/icons/keyboard-line-DsCyuQNz.js +1 -0
- package/phanpy/dist/assets/icons/layout-4-line-CZkOX52Z.js +1 -0
- package/phanpy/dist/assets/icons/layout-5-line-B_znt5wO.js +1 -0
- package/phanpy/dist/assets/icons/left-line-eUw0tya4.js +1 -0
- package/phanpy/dist/assets/icons/lightning-line-DaKoDdix.js +1 -0
- package/phanpy/dist/assets/icons/link-2-line-DCqLmMA7.js +1 -0
- package/phanpy/dist/assets/icons/list-check-line-BAxosBh9.js +1 -0
- package/phanpy/dist/assets/icons/lock-line-CPoaXeUW.js +1 -0
- package/phanpy/dist/assets/icons/mail-line-B0P8aa1L.js +1 -0
- package/phanpy/dist/assets/icons/moon-line-CEqNAToP.js +1 -0
- package/phanpy/dist/assets/icons/more-1-fill-Dw2e5xab.js +1 -0
- package/phanpy/dist/assets/icons/more-3-line-BIiVjYTz.js +1 -0
- package/phanpy/dist/assets/icons/notification-line-BZZq2Gtu.js +1 -0
- package/phanpy/dist/assets/icons/pencil-line-CKh8-A1v.js +1 -0
- package/phanpy/dist/assets/icons/photo-album-line-Bnfg2u40.js +1 -0
- package/phanpy/dist/assets/icons/pin-line-DpTolyhs.js +1 -0
- package/phanpy/dist/assets/icons/play-fill-B0-G_8lX.js +1 -0
- package/phanpy/dist/assets/icons/qrcode-2-line-B6SPCaHn.js +1 -0
- package/phanpy/dist/assets/icons/quill-pen-line-B6LUrnCP.js +1 -0
- package/phanpy/dist/assets/icons/quote-left-fill-hQZ1cj_6.js +1 -0
- package/phanpy/dist/assets/icons/radar-line-CKyroTth.js +1 -0
- package/phanpy/dist/assets/icons/react-line-CoIKhhtK.js +1 -0
- package/phanpy/dist/assets/icons/refresh-2-line-D0mTF97S.js +1 -0
- package/phanpy/dist/assets/icons/right-line-B3KWJaqc.js +1 -0
- package/phanpy/dist/assets/icons/rocket-line-4KZl537D.js +1 -0
- package/phanpy/dist/assets/icons/round-fill-C69NTrZ0.js +1 -0
- package/phanpy/dist/assets/icons/round-line-B9k_1uo-.js +1 -0
- package/phanpy/dist/assets/icons/route-line-B28ReHcE.js +1 -0
- package/phanpy/dist/assets/icons/rows-4-line-CrBniagM.js +1 -0
- package/phanpy/dist/assets/icons/scan-line-DSf74YdP.js +1 -0
- package/phanpy/dist/assets/icons/search-2-line-B6geIU1z.js +1 -0
- package/phanpy/dist/assets/icons/settings-3-line-azJwK47w.js +1 -0
- package/phanpy/dist/assets/icons/settings-6-line-C_5zc5YR.js +1 -0
- package/phanpy/dist/assets/icons/share-2-line-D5MxWjrm.js +1 -0
- package/phanpy/dist/assets/icons/share-forward-line-Cp61fZzZ.js +1 -0
- package/phanpy/dist/assets/icons/sparkles-2-line-DYYyXTaW.js +1 -0
- package/phanpy/dist/assets/icons/sparkles-line-C2hY1s6I.js +1 -0
- package/phanpy/dist/assets/icons/time-line-BF3eet1v.js +1 -0
- package/phanpy/dist/assets/icons/transfer-4-line-oisfvTBw.js +1 -0
- package/phanpy/dist/assets/icons/translate-line-BFWol2Ce.js +1 -0
- package/phanpy/dist/assets/icons/unlock-line-X_x5vwv3.js +1 -0
- package/phanpy/dist/assets/icons/upload-3-line-DgzwUJeW.js +1 -0
- package/phanpy/dist/assets/icons/user-4-line-BVSynZCp.js +1 -0
- package/phanpy/dist/assets/icons/user-add-2-line-BlGS5Q8q.js +1 -0
- package/phanpy/dist/assets/icons/user-add-line-BbXdHG4z.js +1 -0
- package/phanpy/dist/assets/icons/user-edit-line-BFAHOV0X.js +1 -0
- package/phanpy/dist/assets/icons/user-follow-line-BXux0wSd.js +1 -0
- package/phanpy/dist/assets/icons/user-star-line-DdYlpHLU.js +1 -0
- package/phanpy/dist/assets/icons/user-warning-line-Cj43j0_E.js +1 -0
- package/phanpy/dist/assets/icons/user-x-line-DIDgNlPH.js +1 -0
- package/phanpy/dist/assets/icons/volume-line-Bs390KQt.js +1 -0
- package/phanpy/dist/assets/icons/volume-mute-line-DzlzIqFZ.js +1 -0
- package/phanpy/dist/assets/icons/walk-line-yNXfB-md.js +1 -0
- package/phanpy/dist/assets/icons/world-2-line-CenkGIwj.js +1 -0
- package/phanpy/dist/assets/icons/zoom-in-line-CaENUphf.js +1 -0
- package/phanpy/dist/assets/icons/zoom-out-line-BDNk0EWo.js +1 -0
- package/phanpy/dist/assets/instances-BNPptnn-.json +333 -0
- package/phanpy/dist/assets/locales/ar-SA-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/locales/ca-ES-DHRW86Tz.js +1 -0
- package/phanpy/dist/assets/locales/cs-CZ-CKoqEuA1.js +1 -0
- package/phanpy/dist/assets/locales/de-DE-ByRPMRFy.js +1 -0
- package/phanpy/dist/assets/locales/eo-UY-CPC7wLCS.js +1 -0
- package/phanpy/dist/assets/locales/es-ES-dJa0Fd9t.js +1 -0
- package/phanpy/dist/assets/locales/eu-ES-C3djlPR2.js +1 -0
- package/phanpy/dist/assets/locales/fa-IR-DTT490Ck.js +1 -0
- package/phanpy/dist/assets/locales/fi-FI-B23MCPuz.js +1 -0
- package/phanpy/dist/assets/locales/fr-FR-BcaQsO5C.js +1 -0
- package/phanpy/dist/assets/locales/gl-ES-BR-CVKBN.js +1 -0
- package/phanpy/dist/assets/locales/he-IL-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/locales/hu-HU-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/locales/it-IT-C1136UFV.js +1 -0
- package/phanpy/dist/assets/locales/ja-JP-CRZmRAhD.js +1 -0
- package/phanpy/dist/assets/locales/kab-Cz8xH_TT.js +1 -0
- package/phanpy/dist/assets/locales/ko-KR-CG9UiY4o.js +1 -0
- package/phanpy/dist/assets/locales/lt-LT-CJcI6sHZ.js +1 -0
- package/phanpy/dist/assets/locales/nb-NO-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/locales/nl-NL-DxN4Yc1t.js +1 -0
- package/phanpy/dist/assets/locales/oc-FR-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/locales/pl-PL-DkwGXf6P.js +1 -0
- package/phanpy/dist/assets/locales/pseudo-LOCALE-CQ4XxkQ0.js +1 -0
- package/phanpy/dist/assets/locales/pt-BR-nGL0HdFS.js +1 -0
- package/phanpy/dist/assets/locales/pt-PT-Cp17aj2z.js +1 -0
- package/phanpy/dist/assets/locales/ru-RU-0Y8cIoaK.js +1 -0
- package/phanpy/dist/assets/locales/th-TH-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/locales/tok-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/locales/tr-TR-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/locales/uk-UA-C5E3dFBf.js +1 -0
- package/phanpy/dist/assets/locales/zh-CN-BHJi7_et.js +1 -0
- package/phanpy/dist/assets/locales/zh-TW-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/main-BdqNbG-a.js +26 -0
- package/phanpy/dist/assets/main-BdqNbG-a.js.map +1 -0
- package/phanpy/dist/assets/mock-home-DGhR__He.js +2 -0
- package/phanpy/dist/assets/mock-home-DGhR__He.js.map +1 -0
- package/phanpy/dist/assets/multi-column-ROck0NVt.jpg +0 -0
- package/phanpy/dist/assets/multi-hashtag-timeline-DH5INVHi.jpg +0 -0
- package/phanpy/dist/assets/nested-comments-thread-hv59kZLc.jpg +0 -0
- package/phanpy/dist/assets/polyfill-force-H8sUkLGp.js +2 -0
- package/phanpy/dist/assets/polyfill-force-H8sUkLGp.js.map +1 -0
- package/phanpy/dist/assets/pwa-viewport-ogKbF51K.js +20 -0
- package/phanpy/dist/assets/pwa-viewport-ogKbF51K.js.map +1 -0
- package/phanpy/dist/assets/style-B1iLPTPR.css +2 -0
- package/phanpy/dist/assets/temml-DuyIQMei.js +97 -0
- package/phanpy/dist/assets/temml-DuyIQMei.js.map +1 -0
- package/phanpy/dist/assets/tinyld.light.node-DtbBhLYC.js +2 -0
- package/phanpy/dist/assets/tinyld.light.node-DtbBhLYC.js.map +1 -0
- package/phanpy/dist/assets/year-in-posts-B2lywqdx.png +0 -0
- package/phanpy/dist/assets/year-in-posts-DJv_star.js +3 -0
- package/phanpy/dist/assets/year-in-posts-DJv_star.js.map +1 -0
- package/phanpy/dist/compose/index.html +20 -0
- package/phanpy/dist/favicon.ico +0 -0
- package/phanpy/dist/index.html +73 -0
- package/phanpy/dist/logo-192.png +0 -0
- package/phanpy/dist/logo-512.png +0 -0
- package/phanpy/dist/logo-badge-72.png +0 -0
- package/phanpy/dist/logo-maskable-512.png +0 -0
- package/phanpy/dist/logo-monochrome-512.png +0 -0
- package/phanpy/dist/logo-monochrome-maskable-512.png +0 -0
- package/phanpy/dist/manifest.webmanifest +1 -0
- package/phanpy/dist/og-image-2.jpg +0 -0
- package/phanpy/dist/og-image.png +0 -0
- package/phanpy/dist/robots.txt +2 -0
- package/phanpy/dist/sw.js +25 -0
- package/phanpy/dist/version.json +1 -0
- package/run-agent.mjs +805 -0
- package/scripts/build-dist.mjs +37 -0
- package/scripts/verify-ui.mjs +60 -0
- package/ui/README.md +23 -0
- package/vendor/gate.cjs +132 -0
- package/vendor/idp-grant.cjs +394 -0
- package/vendor/jfetch.cjs +16 -0
- package/web/admin/admin.js +958 -0
- package/web/admin/bar.css +64 -0
- package/web/admin/bar.js +61 -0
- package/web/admin/client/client.js +66 -0
- package/web/admin/client/index.html +42 -0
- package/web/admin/index.html +493 -0
- package/web/admin/setup/index.html +172 -0
- package/web/admin/setup/setup.js +268 -0
- package/web/admin/window.css +55 -0
- package/web/admin/window.js +136 -0
- package/web/front/install.sh +69 -0
- package/web/front/new-account.html +424 -0
- package/web/front/run.html +138 -0
- package/web/front/solid-client-authn.bundle.js +2 -0
package/lib/atproto.mjs
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
// atproto.mjs — the agent's connection to an ATProto (Bluesky) account. The
|
|
2
|
+
// agent is an API CLIENT of an existing account on bsky.social or any PDS —
|
|
3
|
+
// the same relationship a Mastodon app has to this agent. Nothing here hosts
|
|
4
|
+
// ATProto; the pod stays the only public surface.
|
|
5
|
+
//
|
|
6
|
+
// Credential at AP_HOME/atproto.json (0600, atomic, stamped with the actor it
|
|
7
|
+
// was connected for — the keys.json rules). The app password is kept so an
|
|
8
|
+
// expired refresh token re-logins instead of demanding a reconnect.
|
|
9
|
+
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { writeJsonAtomic } from './home.mjs';
|
|
13
|
+
import { safeFetch, retryAfterMs } from './safefetch.mjs';
|
|
14
|
+
|
|
15
|
+
const FILE = 'atproto.json';
|
|
16
|
+
const POST_NSID = 'app.bsky.feed.post';
|
|
17
|
+
const MAX_GRAPHEMES = 300;
|
|
18
|
+
const MAX_IMAGES = 4;
|
|
19
|
+
const MAX_BLOB_BYTES = 950_000;
|
|
20
|
+
|
|
21
|
+
const enc = new TextEncoder();
|
|
22
|
+
const seg = new Intl.Segmenter();
|
|
23
|
+
const graphemes = (s) => { let n = 0; for (const _ of seg.segment(s)) n++; return n; };
|
|
24
|
+
const byteLen = (s) => enc.encode(s).length;
|
|
25
|
+
|
|
26
|
+
// Facet byte offsets are UTF-8 BYTE indexes into the text, not code units.
|
|
27
|
+
function detectFacets(text) {
|
|
28
|
+
const facets = [];
|
|
29
|
+
for (const m of text.matchAll(/https?:\/\/[^\s<>"'\])]+/g)) {
|
|
30
|
+
facets.push({
|
|
31
|
+
index: { byteStart: byteLen(text.slice(0, m.index)), byteEnd: byteLen(text.slice(0, m.index + m[0].length)) },
|
|
32
|
+
features: [{ $type: 'app.bsky.richtext.facet#link', uri: m[0] }],
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
for (const m of text.matchAll(/(^|\s)#([\p{L}\p{N}_]+)/gu)) {
|
|
36
|
+
const start = m.index + m[1].length;
|
|
37
|
+
const tagText = '#' + m[2];
|
|
38
|
+
facets.push({
|
|
39
|
+
index: { byteStart: byteLen(text.slice(0, start)), byteEnd: byteLen(text.slice(0, start + tagText.length)) },
|
|
40
|
+
features: [{ $type: 'app.bsky.richtext.facet#tag', tag: m[2] }],
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
return facets;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// The post text Bluesky gets: the note's own text when it fits, otherwise a
|
|
47
|
+
// grapheme-safe cut with a link to the pod note, which holds the whole thing.
|
|
48
|
+
export function bskyText(text, noteUrl) {
|
|
49
|
+
const clean = String(text || '').trim();
|
|
50
|
+
if (graphemes(clean) <= MAX_GRAPHEMES) {
|
|
51
|
+
return { text: clean, facets: detectFacets(clean), truncated: false };
|
|
52
|
+
}
|
|
53
|
+
const tail = '… ' + noteUrl;
|
|
54
|
+
const budget = MAX_GRAPHEMES - graphemes(tail);
|
|
55
|
+
let cut = '';
|
|
56
|
+
let used = 0;
|
|
57
|
+
for (const s of seg.segment(clean)) {
|
|
58
|
+
if (used >= budget) break;
|
|
59
|
+
cut += s.segment;
|
|
60
|
+
used++;
|
|
61
|
+
}
|
|
62
|
+
const out = cut.trimEnd() + tail;
|
|
63
|
+
const facets = detectFacets(out);
|
|
64
|
+
return { text: out, facets, truncated: true };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const filePath = (dir) => path.join(dir, FILE);
|
|
68
|
+
|
|
69
|
+
export class Atproto {
|
|
70
|
+
constructor({ localDir, actorId = null, log = console.log, fetcher = null }) {
|
|
71
|
+
this.localDir = localDir;
|
|
72
|
+
this.actorId = actorId;
|
|
73
|
+
this.log = log;
|
|
74
|
+
// Injectable for tests; the default is the politeness stack.
|
|
75
|
+
this.fetcher = fetcher || ((url, init) => safeFetch(url, init));
|
|
76
|
+
this.pausedUntil = 0;
|
|
77
|
+
this.lastError = null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// The stamped-record guard: a credential minted for another actor is treated
|
|
81
|
+
// as absent, never silently adopted across identities.
|
|
82
|
+
read() {
|
|
83
|
+
let rec;
|
|
84
|
+
try { rec = JSON.parse(fs.readFileSync(filePath(this.localDir), 'utf8')); } catch { return null; }
|
|
85
|
+
if (rec?.mintedFor && this.actorId && rec.mintedFor !== this.actorId) {
|
|
86
|
+
this.log(`atproto.json belongs to ${rec.mintedFor} — not reusing it for ${this.actorId}`);
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
return rec;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
write(rec) {
|
|
93
|
+
fs.mkdirSync(this.localDir, { recursive: true, mode: 0o700 });
|
|
94
|
+
writeJsonAtomic(filePath(this.localDir), rec);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
connected() { return !!this.read()?.did; }
|
|
98
|
+
|
|
99
|
+
status() {
|
|
100
|
+
const rec = this.read();
|
|
101
|
+
return {
|
|
102
|
+
connected: !!rec?.did,
|
|
103
|
+
service: rec?.service || null,
|
|
104
|
+
handle: rec?.handle || null,
|
|
105
|
+
did: rec?.did || null,
|
|
106
|
+
lastError: this.lastError,
|
|
107
|
+
cooldownFor: Math.max(0, Math.round((this.pausedUntil - Date.now()) / 1000)),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async _fetch(url, init) {
|
|
112
|
+
const left = this.pausedUntil - Date.now();
|
|
113
|
+
if (left > 0) throw new Error(`bluesky asked us to back off — ${Math.ceil(left / 1000)}s left`);
|
|
114
|
+
const res = await this.fetcher(url, init);
|
|
115
|
+
if (res.status === 429 || res.status === 503) {
|
|
116
|
+
const ms = retryAfterMs(res) ?? 60_000;
|
|
117
|
+
this.pausedUntil = Date.now() + ms;
|
|
118
|
+
this.log(`bluesky answered ${res.status} — pausing for ${Math.round(ms / 1000)}s`);
|
|
119
|
+
}
|
|
120
|
+
return res;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async _json(res) {
|
|
124
|
+
const body = await res.json().catch(() => ({}));
|
|
125
|
+
if (res.status >= 400) {
|
|
126
|
+
const err = new Error(body.message || body.error || `bluesky answered ${res.status}`);
|
|
127
|
+
err.status = res.status;
|
|
128
|
+
err.code = body.error || null;
|
|
129
|
+
throw err;
|
|
130
|
+
}
|
|
131
|
+
return body;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// One session mint, straight from service + identifier + app password.
|
|
135
|
+
// The identifier reaches the server bare: a fediverse-habit leading @ makes
|
|
136
|
+
// Bluesky parse the handle as an email and refuse it confusingly.
|
|
137
|
+
async connect({ service, identifier, appPassword }) {
|
|
138
|
+
const base = String(service || 'https://bsky.social').replace(/\/+$/, '');
|
|
139
|
+
const id = String(identifier || '').trim().replace(/^@/, '');
|
|
140
|
+
const res = await this._fetch(`${base}/xrpc/com.atproto.server.createSession`, {
|
|
141
|
+
method: 'POST',
|
|
142
|
+
headers: { 'content-type': 'application/json' },
|
|
143
|
+
body: JSON.stringify({ identifier: id, password: appPassword }),
|
|
144
|
+
});
|
|
145
|
+
const s = await this._json(res);
|
|
146
|
+
this.write({
|
|
147
|
+
service: base, identifier: id, appPassword,
|
|
148
|
+
did: s.did, handle: s.handle,
|
|
149
|
+
accessJwt: s.accessJwt, refreshJwt: s.refreshJwt,
|
|
150
|
+
...(this.actorId ? { mintedFor: this.actorId } : {}),
|
|
151
|
+
});
|
|
152
|
+
this.lastError = null;
|
|
153
|
+
this.log(`bluesky connected: @${s.handle} (${s.did})`);
|
|
154
|
+
return { did: s.did, handle: s.handle, service: base };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Best-effort session teardown, then the local record is gone regardless.
|
|
158
|
+
async disconnect() {
|
|
159
|
+
const rec = this.read();
|
|
160
|
+
if (rec?.refreshJwt) {
|
|
161
|
+
await this._fetch(`${rec.service}/xrpc/com.atproto.server.deleteSession`, {
|
|
162
|
+
method: 'POST', headers: { authorization: `Bearer ${rec.refreshJwt}` },
|
|
163
|
+
}).catch(() => {});
|
|
164
|
+
}
|
|
165
|
+
try { fs.rmSync(filePath(this.localDir)); } catch {}
|
|
166
|
+
this.log('bluesky disconnected');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async _refresh(rec) {
|
|
170
|
+
const res = await this._fetch(`${rec.service}/xrpc/com.atproto.server.refreshSession`, {
|
|
171
|
+
method: 'POST', headers: { authorization: `Bearer ${rec.refreshJwt}` },
|
|
172
|
+
});
|
|
173
|
+
if (res.status >= 400) {
|
|
174
|
+
// The refresh token died; the stored app password re-mints the session.
|
|
175
|
+
await this._json(res).catch(() => {});
|
|
176
|
+
return this.connect({ service: rec.service, identifier: rec.identifier, appPassword: rec.appPassword })
|
|
177
|
+
.then(() => this.read());
|
|
178
|
+
}
|
|
179
|
+
const s = await this._json(res);
|
|
180
|
+
const next = { ...rec, accessJwt: s.accessJwt, refreshJwt: s.refreshJwt };
|
|
181
|
+
this.write(next);
|
|
182
|
+
return next;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Mirror one public note. Returns { uri, cid } — the caller records it on
|
|
186
|
+
// the status so a later delete can find the mirror.
|
|
187
|
+
async crossPost({ text, published, attachments = [] }, { noteUrl }) {
|
|
188
|
+
const rec = this.read();
|
|
189
|
+
if (!rec?.did) throw new Error('no bluesky account connected');
|
|
190
|
+
const body = bskyText(text, noteUrl);
|
|
191
|
+
const images = [];
|
|
192
|
+
for (const a of attachments.filter(x => /^image\//.test(x.mediaType || '')).slice(0, MAX_IMAGES)) {
|
|
193
|
+
const res = await this._fetch(a.url, {});
|
|
194
|
+
if (res.status >= 400) throw new Error(`attachment fetch failed (${res.status}): ${a.url}`);
|
|
195
|
+
const bytes = Buffer.from(await res.arrayBuffer());
|
|
196
|
+
if (bytes.length > MAX_BLOB_BYTES) { this.log(`bluesky: image too large, skipped (${a.url})`); continue; }
|
|
197
|
+
const up = await this.xrpc('com.atproto.repo.uploadBlob', {
|
|
198
|
+
body: bytes, contentType: a.mediaType,
|
|
199
|
+
});
|
|
200
|
+
images.push({ image: up.blob, alt: a.description || '' });
|
|
201
|
+
}
|
|
202
|
+
const record = {
|
|
203
|
+
$type: POST_NSID,
|
|
204
|
+
text: body.text,
|
|
205
|
+
...(body.facets.length ? { facets: body.facets } : {}),
|
|
206
|
+
...(images.length ? { embed: { $type: 'app.bsky.embed.images', images } } : {}),
|
|
207
|
+
createdAt: published || new Date().toISOString(),
|
|
208
|
+
};
|
|
209
|
+
const out = await this.xrpc('com.atproto.repo.createRecord', {
|
|
210
|
+
body: { repo: rec.did, collection: POST_NSID, record },
|
|
211
|
+
});
|
|
212
|
+
return { uri: out.uri, cid: out.cid, ...(body.truncated ? { truncated: true } : {}) };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// at://did/collection/rkey → deleteRecord. Absence on the far side is fine.
|
|
216
|
+
async deleteCrossPost(uri) {
|
|
217
|
+
const m = String(uri).match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
|
|
218
|
+
if (!m) throw new Error(`not an at:// uri: ${uri}`);
|
|
219
|
+
await this.xrpc('com.atproto.repo.deleteRecord', {
|
|
220
|
+
body: { repo: m[1], collection: m[2], rkey: m[3] },
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// The post as Bluesky holds it — for a cid an old mirror never stored, and
|
|
225
|
+
// for the thread root a reply must carry.
|
|
226
|
+
async _post(uri) {
|
|
227
|
+
const out = await this.xrpc('app.bsky.feed.getPosts', { params: { uris: uri } });
|
|
228
|
+
const p = (out.posts || [])[0];
|
|
229
|
+
if (!p) throw new Error('that post is gone on Bluesky');
|
|
230
|
+
return p;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// A native like or repost of someone's post — the record every Bluesky app
|
|
234
|
+
// writes. Returns the minted record's uri, the only handle an undo has.
|
|
235
|
+
async like(uri, cid = null) { return this._subjectRecord('app.bsky.feed.like', uri, cid); }
|
|
236
|
+
async repost(uri, cid = null) { return this._subjectRecord('app.bsky.feed.repost', uri, cid); }
|
|
237
|
+
|
|
238
|
+
async _subjectRecord(collection, uri, cid) {
|
|
239
|
+
const rec = this.read();
|
|
240
|
+
if (!rec?.did) throw new Error('no bluesky account connected');
|
|
241
|
+
const subject = cid ? { uri, cid } : await this._post(uri).then(p => ({ uri: p.uri, cid: p.cid }));
|
|
242
|
+
const out = await this.xrpc('com.atproto.repo.createRecord', {
|
|
243
|
+
body: {
|
|
244
|
+
repo: rec.did, collection,
|
|
245
|
+
record: { $type: collection, subject, createdAt: new Date().toISOString() },
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
return { uri: out.uri };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// A native reply under someone's post, threaded at the root their post
|
|
252
|
+
// hangs from. A reply exists only on Bluesky — the fediverse cannot see the
|
|
253
|
+
// parent — so there is no AP object and nothing to cross-post.
|
|
254
|
+
async reply(text, parentUri) {
|
|
255
|
+
const rec = this.read();
|
|
256
|
+
if (!rec?.did) throw new Error('no bluesky account connected');
|
|
257
|
+
const clean = String(text || '').trim();
|
|
258
|
+
const n = graphemes(clean);
|
|
259
|
+
if (n > MAX_GRAPHEMES) throw new Error(`a Bluesky reply fits ${MAX_GRAPHEMES} characters — this one is ${n}`);
|
|
260
|
+
const p = await this._post(parentUri);
|
|
261
|
+
const parent = { uri: p.uri, cid: p.cid };
|
|
262
|
+
const facets = detectFacets(clean);
|
|
263
|
+
const out = await this.xrpc('com.atproto.repo.createRecord', {
|
|
264
|
+
body: {
|
|
265
|
+
repo: rec.did, collection: POST_NSID,
|
|
266
|
+
record: {
|
|
267
|
+
$type: POST_NSID, text: clean,
|
|
268
|
+
...(facets.length ? { facets } : {}),
|
|
269
|
+
reply: { root: p.record?.reply?.root || parent, parent },
|
|
270
|
+
createdAt: new Date().toISOString(),
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
return { uri: out.uri, cid: out.cid };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// The one door every call goes through. GET when there is no body.
|
|
278
|
+
// Retries exactly once after an expired-token refresh.
|
|
279
|
+
async xrpc(nsid, { params = null, body = null, contentType = 'application/json', method = null } = {}) {
|
|
280
|
+
let rec = this.read();
|
|
281
|
+
if (!rec?.did) throw new Error('no bluesky account connected');
|
|
282
|
+
const qs = params
|
|
283
|
+
? '?' + new URLSearchParams(Object.entries(params).filter(([, v]) => v != null)).toString()
|
|
284
|
+
: '';
|
|
285
|
+
const doCall = (r) => this._fetch(`${r.service}/xrpc/${nsid}${qs}`, {
|
|
286
|
+
method: method || (body != null ? 'POST' : 'GET'),
|
|
287
|
+
headers: {
|
|
288
|
+
authorization: `Bearer ${r.accessJwt}`,
|
|
289
|
+
...(body != null ? { 'content-type': contentType } : {}),
|
|
290
|
+
},
|
|
291
|
+
...(body != null ? { body: contentType === 'application/json' ? JSON.stringify(body) : body } : {}),
|
|
292
|
+
});
|
|
293
|
+
let res = await doCall(rec);
|
|
294
|
+
if (res.status === 400 || res.status === 401) {
|
|
295
|
+
const peek = await res.clone().json().catch(() => ({}));
|
|
296
|
+
if (peek.error === 'ExpiredToken' || peek.error === 'InvalidToken') {
|
|
297
|
+
rec = await this._refresh(rec);
|
|
298
|
+
res = await doCall(rec);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
try {
|
|
302
|
+
const out = await this._json(res);
|
|
303
|
+
this.lastError = null;
|
|
304
|
+
return out;
|
|
305
|
+
} catch (e) {
|
|
306
|
+
this.lastError = e.message;
|
|
307
|
+
throw e;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
package/lib/bskyfeed.mjs
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// bskyfeed.mjs — mirrors the connected Bluesky account's home timeline and
|
|
2
|
+
// notifications into the statuses index as kind 'bsky'. View cache only — none
|
|
3
|
+
// of it is written to the pod; the pod holds fediverse content.
|
|
4
|
+
//
|
|
5
|
+
// Config in bskyfeed.json: { intervalMin }. The poll is authed XRPC against
|
|
6
|
+
// the account's own PDS, so the politeness dial is the account's own rate
|
|
7
|
+
// budget — backed off the same way tagfeed backs off a stranger's instance.
|
|
8
|
+
|
|
9
|
+
const DEFAULTS = { intervalMin: 5 };
|
|
10
|
+
const PER_SWEEP = 30;
|
|
11
|
+
const MAX_BSKY_ENTRIES = 200;
|
|
12
|
+
const BACKOFF_MIN_MS = 5 * 60_000;
|
|
13
|
+
const BACKOFF_MAX_MS = 2 * 60 * 60_000;
|
|
14
|
+
|
|
15
|
+
export const profileUrl = (did) => `https://bsky.app/profile/${did}`;
|
|
16
|
+
export const postUrl = (uri) => {
|
|
17
|
+
const m = String(uri).match(/^at:\/\/([^/]+)\/[^/]+\/(.+)$/);
|
|
18
|
+
return m ? `https://bsky.app/profile/${m[1]}/post/${m[2]}` : null;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export class BskyFeed {
|
|
22
|
+
constructor({ store, atproto, log = console.log, onNotification = null }) {
|
|
23
|
+
Object.assign(this, { store, atproto, log, onNotification });
|
|
24
|
+
this.lastSweep = null;
|
|
25
|
+
this.lastAdded = 0;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
config() { return { ...DEFAULTS, ...this.store.read('bskyfeed.json', {}) }; }
|
|
29
|
+
|
|
30
|
+
setConfig(patch) {
|
|
31
|
+
const clean = {};
|
|
32
|
+
if (patch.intervalMin) clean.intervalMin = Math.max(1, Number(patch.intervalMin) || DEFAULTS.intervalMin);
|
|
33
|
+
this.store.write('bskyfeed.json', { ...this.config(), ...clean });
|
|
34
|
+
this.stop();
|
|
35
|
+
this.start();
|
|
36
|
+
return this.config();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
start() {
|
|
40
|
+
this.stopped = false;
|
|
41
|
+
this.sweep().catch(e => this.log(`bskyfeed: ${e.message}`));
|
|
42
|
+
const tick = () => {
|
|
43
|
+
this.timer = setTimeout(() => {
|
|
44
|
+
this.sweep()
|
|
45
|
+
.catch(e => this.log(`bskyfeed: ${e.message}`))
|
|
46
|
+
.finally(() => { if (!this.stopped) tick(); });
|
|
47
|
+
}, Math.round(this.config().intervalMin * 60_000 * (0.85 + Math.random() * 0.3)));
|
|
48
|
+
this.timer.unref?.();
|
|
49
|
+
};
|
|
50
|
+
tick();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
stop() { this.stopped = true; clearTimeout(this.timer); }
|
|
54
|
+
|
|
55
|
+
_backOff(status, retryAfter) {
|
|
56
|
+
this.failures = (this.failures || 0) + 1;
|
|
57
|
+
const ladder = Math.min(BACKOFF_MIN_MS * 2 ** (this.failures - 1), BACKOFF_MAX_MS);
|
|
58
|
+
const wait = retryAfter || Math.round(ladder * (0.85 + Math.random() * 0.3));
|
|
59
|
+
this.quietUntil = Date.now() + wait;
|
|
60
|
+
this.log(`bskyfeed: ${status ? `bluesky answered ${status}` : 'bluesky did not answer'} — not asking again for ${Math.round(wait / 60_000)} min`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// One author into the shared actor cache, under a page anyone can open.
|
|
64
|
+
_rememberAuthor(a) {
|
|
65
|
+
const url = profileUrl(a.did);
|
|
66
|
+
if (!this.store.getActors()[url]) {
|
|
67
|
+
this.store.cacheActor(url, {
|
|
68
|
+
name: a.displayName || a.handle, preferredUsername: a.handle,
|
|
69
|
+
icon: a.avatar || null, type: 'Person',
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
return url;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// One Bluesky post into the statuses index. Returns its noteId, new or not.
|
|
76
|
+
_mirrorPost(post, { via = null } = {}) {
|
|
77
|
+
const noteId = post.uri;
|
|
78
|
+
const existing = this.store.getStatuses().some(s => s.noteId === noteId);
|
|
79
|
+
if (existing) return { noteId, added: false };
|
|
80
|
+
const actor = this._rememberAuthor(post.author);
|
|
81
|
+
if (this.store.isBlocked(actor)) return { noteId, added: false };
|
|
82
|
+
const text = post.record?.text || '';
|
|
83
|
+
const images = post.embed?.images || [];
|
|
84
|
+
this.store.addStatus({
|
|
85
|
+
noteId, actor,
|
|
86
|
+
content: `<p>${text.replace(/[&<>]/g, c => ({ '&': '&', '<': '<', '>': '>' }[c]))}</p>`,
|
|
87
|
+
published: post.record?.createdAt || post.indexedAt,
|
|
88
|
+
kind: 'bsky',
|
|
89
|
+
...(post.cid ? { cid: post.cid } : {}),
|
|
90
|
+
link: postUrl(noteId),
|
|
91
|
+
...(via ? { via } : {}),
|
|
92
|
+
...(images.length ? {
|
|
93
|
+
attachments: images.map(i => ({ url: i.fullsize, mediaType: 'image/jpeg', description: i.alt || '' })),
|
|
94
|
+
} : {}),
|
|
95
|
+
});
|
|
96
|
+
return { noteId, added: true };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async sweep() {
|
|
100
|
+
if (!this.atproto?.connected()) return;
|
|
101
|
+
if (this.quietUntil && Date.now() < this.quietUntil) return;
|
|
102
|
+
this.lastSweep = new Date().toISOString();
|
|
103
|
+
const self = this.atproto.read()?.did;
|
|
104
|
+
let added = 0;
|
|
105
|
+
try {
|
|
106
|
+
// Our own CROSS-POSTS must not echo back into the feed — but a post or
|
|
107
|
+
// reply written natively on Bluesky is ours to see here too. The mirrors
|
|
108
|
+
// are known by uri, so only they are skipped.
|
|
109
|
+
const ownMirrors = new Set(this.store.getStatuses().map(s => s.atproto?.uri).filter(Boolean));
|
|
110
|
+
const tl = await this.atproto.xrpc('app.bsky.feed.getTimeline', { params: { limit: PER_SWEEP } });
|
|
111
|
+
for (const item of tl.feed || []) {
|
|
112
|
+
if (!item?.post?.uri) continue;
|
|
113
|
+
if (item.post.author?.did === self && ownMirrors.has(item.post.uri)) continue;
|
|
114
|
+
const via = item.reason?.$type === 'app.bsky.feed.defs#reasonRepost' && item.reason.by
|
|
115
|
+
? this._rememberAuthor(item.reason.by) : null;
|
|
116
|
+
if (this._mirrorPost(item.post, { via }).added) added++;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Only notifications newer than the last sweep's high-water mark are
|
|
120
|
+
// acted on — the list is a window, not a queue, and re-reading it must
|
|
121
|
+
// not re-fire the group hooks.
|
|
122
|
+
const seen = this.store.read('bskyfeed.json', {}).lastNotifiedAt || '';
|
|
123
|
+
let high = seen;
|
|
124
|
+
const nots = await this.atproto.xrpc('app.bsky.notification.listNotifications', { params: { limit: PER_SWEEP } });
|
|
125
|
+
for (const n of nots.notifications || []) {
|
|
126
|
+
if (!n?.author?.did || n.author.did === self) continue;
|
|
127
|
+
if (n.indexedAt) {
|
|
128
|
+
if (seen && n.indexedAt <= seen) continue;
|
|
129
|
+
if (n.indexedAt > high) high = n.indexedAt;
|
|
130
|
+
}
|
|
131
|
+
const actor = this._rememberAuthor(n.author);
|
|
132
|
+
// A like or repost points at OUR mirror; the notification should point
|
|
133
|
+
// at the pod post it mirrors, which is the one the client can open.
|
|
134
|
+
if (n.reason === 'like' || n.reason === 'repost') {
|
|
135
|
+
const mine = this.store.getStatuses().find(s => s.atproto?.uri === n.reasonSubject);
|
|
136
|
+
if (!mine) continue;
|
|
137
|
+
this.store.addNotification({
|
|
138
|
+
type: n.reason === 'like' ? 'favourite' : 'reblog',
|
|
139
|
+
actor, noteId: mine.noteId, bsky: true,
|
|
140
|
+
});
|
|
141
|
+
} else if (n.reason === 'follow') {
|
|
142
|
+
this.store.addNotification({ type: 'follow', actor, bsky: true });
|
|
143
|
+
await this.onNotification?.(n, { actor });
|
|
144
|
+
} else if (n.reason === 'mention' || n.reason === 'reply') {
|
|
145
|
+
const record = { uri: n.uri, cid: n.cid, author: n.author, record: n.record, indexedAt: n.indexedAt };
|
|
146
|
+
this._mirrorPost(record);
|
|
147
|
+
this.store.addNotification({ type: 'mention', actor, noteId: n.uri, bsky: true });
|
|
148
|
+
await this.onNotification?.(n, { actor });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (high && high !== seen) this.store.write('bskyfeed.json', { ...this.config(), lastNotifiedAt: high });
|
|
152
|
+
this.failures = 0;
|
|
153
|
+
} catch (e) {
|
|
154
|
+
this._backOff(e.status || 0, null);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const all = this.store.getStatuses();
|
|
158
|
+
const mirrored = all.filter(s => s.kind === 'bsky');
|
|
159
|
+
if (mirrored.length > MAX_BSKY_ENTRIES) {
|
|
160
|
+
const drop = new Set(mirrored.slice(MAX_BSKY_ENTRIES).map(s => s.noteId));
|
|
161
|
+
this.store.write('statuses.json', all.filter(s => !drop.has(s.noteId)));
|
|
162
|
+
}
|
|
163
|
+
this.lastAdded = added;
|
|
164
|
+
if (added) this.log(`bskyfeed: +${added} from the bluesky timeline`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// bskygroup.mjs — Bluesky members of a group, bridged-first. A bridged Bluesky
|
|
2
|
+
// account (one that follows @ap.brid.gy) is a plain AP actor and takes the
|
|
3
|
+
// existing FEP-1b12 paths untouched. This module covers the rest: knowing who
|
|
4
|
+
// is bridged, nudging who isn't, and the degraded Bluesky-only membership —
|
|
5
|
+
// follow-as-join, mention-as-submission, repost as the group's carry.
|
|
6
|
+
|
|
7
|
+
import { profileUrl } from './bskyfeed.mjs';
|
|
8
|
+
import { bskyText } from './atproto.mjs';
|
|
9
|
+
|
|
10
|
+
const BRIDGE_ACTOR = (did) => `https://bsky.brid.gy/ap/${did}`;
|
|
11
|
+
const AP_ACCEPT = 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"';
|
|
12
|
+
|
|
13
|
+
export class BskyGroup {
|
|
14
|
+
constructor({ store, atproto, intake, publisher, log = console.log, fetcher = null }) {
|
|
15
|
+
Object.assign(this, { store, atproto, intake, publisher, log });
|
|
16
|
+
this.fetcher = fetcher; // tests inject; default rides safefetch lazily
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// A DID is bridged when Bridgy Fed serves an AP actor for it. Cached on
|
|
20
|
+
// whatever record the caller keeps, so one join costs one probe.
|
|
21
|
+
async isBridged(did) {
|
|
22
|
+
try {
|
|
23
|
+
const { safeFetch } = await import('./safefetch.mjs');
|
|
24
|
+
const doFetch = this.fetcher || ((u, i) => safeFetch(u, i));
|
|
25
|
+
const res = await doFetch(BRIDGE_ACTOR(did), { headers: { accept: AP_ACCEPT } });
|
|
26
|
+
return res.status < 400;
|
|
27
|
+
} catch { return false; }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ONE reply ever: tell an unbridged joiner how to reach the fediverse side.
|
|
31
|
+
async nudge({ did, handle }) {
|
|
32
|
+
const text = `@${handle} welcome! Follow @ap.brid.gy and your posts will reach `
|
|
33
|
+
+ 'the fediverse side of this group too.';
|
|
34
|
+
const start = 0;
|
|
35
|
+
const rec = this.atproto.read();
|
|
36
|
+
await this.atproto.xrpc('com.atproto.repo.createRecord', {
|
|
37
|
+
body: {
|
|
38
|
+
repo: rec.did, collection: 'app.bsky.feed.post',
|
|
39
|
+
record: {
|
|
40
|
+
$type: 'app.bsky.feed.post', text,
|
|
41
|
+
facets: [{
|
|
42
|
+
index: { byteStart: start, byteEnd: start + 1 + handle.length },
|
|
43
|
+
features: [{ $type: 'app.bsky.richtext.facet#mention', did }],
|
|
44
|
+
}],
|
|
45
|
+
createdAt: new Date().toISOString(),
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// A native follow of the group's Bluesky account. Bridged accounts are left
|
|
52
|
+
// to join through the bridge; unbridged ones become Bluesky-only members,
|
|
53
|
+
// through the same approval queue as everyone else.
|
|
54
|
+
async onFollow(author) {
|
|
55
|
+
const actor = profileUrl(author.did);
|
|
56
|
+
const contacts = this.store.getContacts();
|
|
57
|
+
if (contacts.followers.some(f => f.actor === actor)) return;
|
|
58
|
+
if (this.store.getRequests().some(r => r.actor === actor)) return;
|
|
59
|
+
if (this.store.isBlocked(actor)) return;
|
|
60
|
+
if (await this.isBridged(author.did)) {
|
|
61
|
+
this.log(`bluesky follow from bridged @${author.handle} — their join arrives over AP`);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const member = { actor, bsky: { did: author.did, handle: author.handle } };
|
|
65
|
+
if (this.store.getConfig()?.approveJoins) {
|
|
66
|
+
const reqs = this.store.getRequests();
|
|
67
|
+
reqs.unshift({ ...member, at: new Date().toISOString() });
|
|
68
|
+
this.store.setRequests(reqs);
|
|
69
|
+
this.log(`bluesky join request held: @${author.handle}`);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
contacts.followers.push(member);
|
|
73
|
+
this.store.setContacts(contacts);
|
|
74
|
+
this.store.addNotification({ type: 'follow', actor, bsky: true });
|
|
75
|
+
await this.nudgeOnce(member);
|
|
76
|
+
this.log(`bluesky member joined: @${author.handle}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async nudgeOnce(member) {
|
|
80
|
+
if (member.nudgedAt) return;
|
|
81
|
+
try {
|
|
82
|
+
await this.nudge(member.bsky);
|
|
83
|
+
member.nudgedAt = new Date().toISOString();
|
|
84
|
+
const contacts = this.store.getContacts();
|
|
85
|
+
const rec = contacts.followers.find(f => f.actor === member.actor);
|
|
86
|
+
if (rec) { rec.nudgedAt = member.nudgedAt; this.store.setContacts(contacts); }
|
|
87
|
+
} catch (e) { this.log(`bluesky nudge failed for @${member.bsky?.handle}: ${e.message}`); }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// A mention of the group's handle is a submission — same rules as an AP
|
|
91
|
+
// member addressing the group. amplify() applies the member, mute, and
|
|
92
|
+
// review checks; its bsky branch calls carry() below.
|
|
93
|
+
async onMention(n) {
|
|
94
|
+
const actor = profileUrl(n.author.did);
|
|
95
|
+
const isMember = this.store.getContacts().followers.some(f => f.actor === actor);
|
|
96
|
+
if (!isMember) { this.log(`bluesky mention from non-member @${n.author.handle} — ignored`); return; }
|
|
97
|
+
await this.intake.amplify(n.uri, {});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// The group's carry of a member's Bluesky post: a native repost. AP
|
|
101
|
+
// followers get it only when the author is bridged — the group never
|
|
102
|
+
// fabricates an AP object for someone else's words.
|
|
103
|
+
async carry(s) {
|
|
104
|
+
const rec = this.atproto.read();
|
|
105
|
+
const out = await this.atproto.xrpc('com.atproto.repo.createRecord', {
|
|
106
|
+
body: {
|
|
107
|
+
repo: rec.did, collection: 'app.bsky.feed.repost',
|
|
108
|
+
record: {
|
|
109
|
+
$type: 'app.bsky.feed.repost',
|
|
110
|
+
subject: { uri: s.noteId, cid: s.cid },
|
|
111
|
+
createdAt: new Date().toISOString(),
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
this.store.updateStatus(s.noteId, { announcedAt: new Date().toISOString(), repostUri: out.uri });
|
|
116
|
+
this.store.setPending(this.store.getPending().filter(p => p.noteId !== s.noteId));
|
|
117
|
+
this.log(`bluesky repost: ${s.noteId}`);
|
|
118
|
+
return { ok: true, noteId: s.noteId, repost: out.uri };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Unsay a repost.
|
|
122
|
+
async retract(s) {
|
|
123
|
+
if (!s.repostUri) throw new Error('that post was never carried');
|
|
124
|
+
await this.atproto.deleteCrossPost(s.repostUri);
|
|
125
|
+
this.store.updateStatus(s.noteId, {
|
|
126
|
+
announcedAt: undefined, repostUri: undefined, retractedAt: new Date().toISOString(),
|
|
127
|
+
});
|
|
128
|
+
return { ok: true, noteId: s.noteId, inboxes: 0 };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// An AP member's post the group just Announced, shown natively to the
|
|
132
|
+
// group's Bluesky followers: the group account posts the conversion with
|
|
133
|
+
// attribution and the link back.
|
|
134
|
+
async mirrorCarry(s) {
|
|
135
|
+
if (!this.store.getConfig()?.atproto?.crossPost) return;
|
|
136
|
+
const handle = this.store.handleOf?.(s.actor) || s.actor;
|
|
137
|
+
const plain = String(s.content || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
|
138
|
+
const body = bskyText(`via ${handle}: ${plain}`, s.noteId);
|
|
139
|
+
const rec = this.atproto.read();
|
|
140
|
+
await this.atproto.xrpc('com.atproto.repo.createRecord', {
|
|
141
|
+
body: {
|
|
142
|
+
repo: rec.did, collection: 'app.bsky.feed.post',
|
|
143
|
+
record: {
|
|
144
|
+
$type: 'app.bsky.feed.post', text: body.text,
|
|
145
|
+
...(body.facets.length ? { facets: body.facets } : {}),
|
|
146
|
+
createdAt: new Date().toISOString(),
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Ejection's Bluesky half: the DID is blocked so the departure sticks.
|
|
153
|
+
async blockDid(did) {
|
|
154
|
+
const rec = this.atproto.read();
|
|
155
|
+
await this.atproto.xrpc('com.atproto.repo.createRecord', {
|
|
156
|
+
body: {
|
|
157
|
+
repo: rec.did, collection: 'app.bsky.graph.block',
|
|
158
|
+
record: { $type: 'app.bsky.graph.block', subject: did, createdAt: new Date().toISOString() },
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|