basic notification system for atproto stuff using ntfy

added more notifications

aylac.top aae94758 54f8f393

verified
+1 -1
flake.nix
··· 14 14 pname = "atproto-basic-notifications"; 15 15 version = "0.1.0"; 16 16 src = ./.; 17 - npmDepsHash = "sha256-gGiNDtxgof7L5y3bH7VWukezEMZbzYkSDdovUwaKQGA="; 17 + npmDepsHash = "sha256-TWw+/vTB3Ai4wTakUvYEI8/NYPdgudAkxZteR/55tcw="; 18 18 meta.mainProgram = "atproto-basic-notifications"; 19 19 }; 20 20 in {
+256 -72
index.ts
··· 1 1 import { 2 2 Client, 3 - CredentialManager, 4 - ok, 3 + ClientResponse, 4 + FailedClientResponse, 5 5 simpleFetchHandler, 6 6 } from "@atcute/client"; 7 7 import { JetstreamSubscription } from "@atcute/jetstream"; 8 - import { Did, is, RecordKey } from "@atcute/lexicons"; 8 + import { 9 + CanonicalResourceUri, 10 + Did, 11 + parseCanonicalResourceUri, 12 + ParsedCanonicalResourceUri, 13 + RecordKey, 14 + } from "@atcute/lexicons"; 9 15 10 16 import { AppBskyFeedPost } from "@atcute/bluesky"; 11 17 import { 12 18 ProfileViewDetailed, 13 19 VerificationView, 14 20 } from "@atcute/bluesky/types/app/actor/defs"; 21 + import { 22 + ShTangledFeedStar, 23 + ShTangledRepoIssue, 24 + ShTangledRepoIssueComment, 25 + } from "@atcute/tangled"; 26 + import { 27 + CompositeDidDocumentResolver, 28 + PlcDidDocumentResolver, 29 + WebDidDocumentResolver, 30 + } from "@atcute/identity-resolver"; 31 + import { AtprotoDid } from "@atcute/lexicons/syntax"; 32 + import { XRPCProcedures, XRPCQueries } from "@atcute/lexicons/ambient"; 15 33 16 - const TARGET_DID = process.env.TARGET_DID || "did:plc:3c6vkaq7xf5kz3va3muptjh5"; 34 + const TARGET_DID = (process.env.TARGET_DID || 35 + "did:plc:3c6vkaq7xf5kz3va3muptjh5") as Did; 17 36 18 37 const JETSTREAM_URL = 19 38 process.env.JETSTREAM_URL || ··· 23 42 const PDSLS_URL = process.env.PDSLS_URL || "https://pdsls.dev"; 24 43 const TANGLED_URL = process.env.TANGLED_URL || "https://tangled.sh"; 25 44 26 - const CACHE_LIFETIME_MS = 30 * 60 * 1000; // 30 minutes in milliseconds 27 - 28 - const client = new Client({ 29 - handler: simpleFetchHandler({ service: "https://public.api.bsky.app" }), 30 - }); 45 + const CACHE_LIFETIME = 60 * 60 * 1000; // 60 minutes in milliseconds 31 46 32 - const profileCache = new Map< 33 - Did, 34 - { profile: ProfileViewDetailed; timestamp: number } 47 + const cache = new Map< 48 + string, 49 + { value: any; timestamp: number; lifetime: number } 35 50 >(); 36 51 37 - const getProfile = async (did: Did) => { 38 - const cached = profileCache.get(did); 52 + const getWithCache = async <T>( 53 + key: string, 54 + fetcher: () => Promise<T>, 55 + lifetime?: number, 56 + ): Promise<T> => { 57 + const cached = cache.get(key); 39 58 const now = Date.now(); 40 59 41 - if (cached && now - cached.timestamp < CACHE_LIFETIME_MS) { 42 - return cached.profile; 60 + if (cached && now - cached.timestamp < cached.lifetime) { 61 + return cached.value as T; 43 62 } 44 63 45 - const profile = ( 46 - await client.get("app.bsky.actor.getProfile", { 47 - params: { 48 - actor: did, 49 - }, 50 - }) 51 - ).data; 64 + const value = await fetcher(); 65 + cache.set(key, { 66 + value, 67 + timestamp: now, 68 + lifetime: lifetime ?? CACHE_LIFETIME, 69 + }); 70 + return value; 71 + }; 52 72 53 - if ("error" in profile) 54 - return { 55 - $type: "app.bsky.actor.defs#profileViewDetailed", 56 - did: did, 57 - handle: "handle.invalid", 58 - displayName: "silent error!", 59 - } as ProfileViewDetailed; 73 + const docResolver = new CompositeDidDocumentResolver({ 74 + methods: { 75 + plc: new PlcDidDocumentResolver(), 76 + web: new WebDidDocumentResolver(), 77 + }, 78 + }); 60 79 61 - profileCache.set(did, { profile, timestamp: now }); 62 - return profile; 80 + const bskyClient = new Client({ 81 + handler: simpleFetchHandler({ service: "https://public.api.bsky.app" }), 82 + }); 83 + const clientGetRecord = async ( 84 + uri: ParsedCanonicalResourceUri, 85 + ): Promise< 86 + ClientResponse< 87 + XRPCQueries["com.atproto.repo.getRecord"], 88 + XRPCQueries["com.atproto.repo.getRecord"] 89 + > 90 + > => { 91 + return getWithCache( 92 + uri.collection + uri.repo + uri.rkey, 93 + async () => { 94 + try { 95 + const doc = await docResolver.resolve(uri.repo as AtprotoDid); 96 + const atprotoPdsService = doc.service?.find( 97 + (s) => 98 + s.id === "#atproto_pds" && s.type === "AtprotoPersonalDataServer", 99 + ); 100 + const pdsServiceEndpoint = atprotoPdsService?.serviceEndpoint; 101 + if (!pdsServiceEndpoint || typeof pdsServiceEndpoint !== "string") { 102 + throw new Error("No PDS service endpoint found"); 103 + } 104 + const client = new Client({ 105 + handler: simpleFetchHandler({ service: pdsServiceEndpoint }), 106 + }); 107 + return await client.get("com.atproto.repo.getRecord", { params: uri }); 108 + } catch (err) { 109 + return { ok: false, data: err } as FailedClientResponse; 110 + } 111 + }, 112 + CACHE_LIFETIME * 24, 113 + ); 63 114 }; 64 115 65 - const sendNotification = async ( 66 - title: string, 67 - icon: `${string}:${string}` | undefined, 68 - message: string, 69 - url: string, 70 - priority: number = 3, 71 - ) => { 72 - await fetch(NTFY_URL, { 116 + const getId = (profile: ProfileViewDetailed) => { 117 + return profile.handle !== "handle.invalid" ? profile.handle : profile.did; 118 + }; 119 + 120 + const getProfile = async (did: Did): Promise<ProfileViewDetailed> => { 121 + return getWithCache( 122 + "bskyProfile_" + did, 123 + async () => { 124 + const profile = ( 125 + await bskyClient.get("app.bsky.actor.getProfile", { 126 + params: { 127 + actor: did, 128 + }, 129 + }) 130 + ).data; 131 + 132 + if ("error" in profile) 133 + return { 134 + $type: "app.bsky.actor.defs#profileViewDetailed", 135 + did: did, 136 + handle: "handle.invalid", 137 + displayName: "silent error!", 138 + } as ProfileViewDetailed; 139 + 140 + return profile; 141 + }, 142 + CACHE_LIFETIME * 4, 143 + ); 144 + }; 145 + 146 + const errorRepo = { 147 + name: "Repository not found", 148 + }; 149 + 150 + const getTangledRepo = async ( 151 + uri: CanonicalResourceUri, 152 + ): Promise<{ name: string }> => { 153 + const res = parseCanonicalResourceUri(uri); 154 + if (!res.ok) return errorRepo; 155 + 156 + return getWithCache(uri, async () => { 157 + const repo = (await clientGetRecord(res.value)).data; 158 + 159 + if ("error" in repo || !("name" in repo.value)) return errorRepo; 160 + 161 + return repo.value as { name: string }; 162 + }); 163 + }; 164 + 165 + const errorIssue = { 166 + title: "Repository not found", 167 + repo: "at://did:web:fake/nope.nada/nada" as CanonicalResourceUri, 168 + }; 169 + 170 + const getTangledIssue = async ( 171 + uri: CanonicalResourceUri, 172 + ): Promise<{ title: string; repo: CanonicalResourceUri }> => { 173 + const res = parseCanonicalResourceUri(uri); 174 + if (!res.ok) return errorIssue; 175 + 176 + return getWithCache(uri, async () => { 177 + const repo = (await clientGetRecord(res.value)).data; 178 + 179 + if ("error" in repo || !("title" in repo.value)) return errorIssue; 180 + 181 + return repo.value as { 182 + title: string; 183 + repo: CanonicalResourceUri; 184 + }; 185 + }); 186 + }; 187 + 188 + const sendNotification = async (args: { 189 + title?: string; 190 + icon?: `${string}:${string}` | undefined; 191 + message?: string; 192 + url?: string; 193 + priority?: number; 194 + picture?: string | undefined; 195 + }) => { 196 + const res = await fetch(NTFY_URL, { 73 197 method: "POST", 74 198 headers: { 75 - Title: title, 76 - Icon: icon ?? "", 77 - Priority: priority.toString(), 78 - Click: url, 199 + Title: args.title ?? "", 200 + Icon: args.icon ?? "", 201 + Priority: args.priority?.toString() ?? "3", 202 + Click: args.url ?? "", 203 + Attach: args.picture ?? "", 79 204 }, 80 - body: message, 205 + body: args.message ?? null, 81 206 }); 207 + 208 + if ("error" in res) { 209 + console.error(JSON.stringify(res)); 210 + } 82 211 }; 83 212 84 213 const wantedCollections = [ ··· 100 229 ) => void; 101 230 } = { 102 231 "app.bsky.feed.post": async (did, rkey, record: AppBskyFeedPost.Main) => { 232 + const embedTable = { 233 + "app.bsky.embed.external": "External Link", 234 + "app.bsky.embed.images": "Image", 235 + "app.bsky.embed.record": "Record", 236 + "app.bsky.embed.recordWithMedia": "Record with Media", 237 + "app.bsky.embed.video": "Video", 238 + }; 239 + 103 240 const profile = await getProfile(did); 104 241 105 242 const typeOfPost = ··· 109 246 : "mentioned you"; 110 247 111 248 const post = record as AppBskyFeedPost.Main; 112 - sendNotification( 113 - "Bluesky", 114 - profile.avatar, 115 - `${profile.handle} ${typeOfPost}: ${post.text}`, 116 - `${BSKY_URL}/profile/${profile.did}/post/${rkey}`, 117 - ); 249 + sendNotification({ 250 + title: "Bluesky", 251 + icon: profile.avatar, 252 + message: 253 + `${getId(profile)} ${typeOfPost}: ${post.text}` + 254 + (post.embed 255 + ? (post.text.length > 0 ? " " : "") + 256 + `[${embedTable[post.embed.$type]}]` 257 + : ""), 258 + url: `${BSKY_URL}/profile/${profile.did}/post/${rkey}`, 259 + }); 118 260 }, 119 261 "app.bsky.feed.follow": async (did, rkey, record) => { 120 262 const profile = await getProfile(did); 121 263 122 - sendNotification( 123 - "Bluesky", 124 - profile.avatar, 125 - `${profile.handle} followed you`, 126 - `${BSKY_URL}/profile/${profile.did}`, 127 - 2, 128 - ); 264 + sendNotification({ 265 + title: "Bluesky", 266 + icon: profile.avatar, 267 + message: `${getId(profile)} followed you`, 268 + url: `${BSKY_URL}/profile/${profile.did}`, 269 + priority: 2, 270 + }); 129 271 }, 130 272 "app.bsky.graph.verification": async ( 131 273 did, ··· 134 276 ) => { 135 277 const profile = await getProfile(did); 136 278 137 - sendNotification( 138 - "Bluesky", 139 - profile.avatar, 140 - `${profile.handle} verified you`, 141 - `${PDSLS_URL}/${record.uri}`, 142 - 2, 143 - ); 279 + sendNotification({ 280 + title: "Bluesky", 281 + icon: profile.avatar, 282 + message: `${getId(profile)} verified you`, 283 + url: `${PDSLS_URL}/${record.uri}`, 284 + priority: 2, 285 + }); 144 286 }, 145 287 "sh.tangled.graph.follow": async (did, rkey, record) => { 146 288 const profile = await getProfile(did); 147 289 148 - sendNotification( 149 - "Tangled", 150 - profile.avatar, 151 - `${profile.handle} followed you`, 152 - `${TANGLED_URL}/@${profile.did}`, 153 - 2, 154 - ); 290 + sendNotification({ 291 + title: "Tangled", 292 + icon: profile.avatar, 293 + message: `${getId(profile)} followed you`, 294 + url: `${TANGLED_URL}/@${profile.did}`, 295 + }); 296 + }, 297 + "sh.tangled.feed.star": async (did, rkey, record: ShTangledFeedStar.Main) => { 298 + const profile = await getProfile(did); 299 + const repo = await getTangledRepo(record.subject as CanonicalResourceUri); 300 + 301 + sendNotification({ 302 + title: "Tangled", 303 + icon: profile.avatar, 304 + message: `${getId(profile)} starred ${repo.name}`, 305 + url: `${TANGLED_URL}/@${profile.did}`, 306 + priority: 2, 307 + }); 308 + }, 309 + "sh.tangled.repo.issue": async ( 310 + did, 311 + rkey, 312 + record: ShTangledRepoIssue.Main, 313 + ) => { 314 + const profile = await getProfile(did); 315 + const repo = await getTangledRepo(record.repo as CanonicalResourceUri); 316 + 317 + sendNotification({ 318 + title: "Tangled", 319 + icon: profile.avatar, 320 + message: `${getId(profile)} opened an issue, "${record.title}", on ${repo.name}: ${record.body}`, 321 + url: `${TANGLED_URL}`, 322 + }); 323 + }, 324 + "sh.tangled.repo.issue.comment": async ( 325 + did, 326 + rkey, 327 + record: ShTangledRepoIssueComment.Main, 328 + ) => { 329 + const profile = await getProfile(did); 330 + const issue = await getTangledIssue(record.issue as CanonicalResourceUri); 331 + const repo = await getTangledRepo(issue.repo as CanonicalResourceUri); 332 + 333 + sendNotification({ 334 + title: "Tangled", 335 + icon: profile.avatar, 336 + message: `${getId(profile)} commented on issue "${issue.title}", on ${repo.name}: ${record.body}`, 337 + url: `${TANGLED_URL}/@${profile.did}`, 338 + }); 155 339 }, 156 340 }; 157 341
+41 -1
package-lock.json
··· 1 1 { 2 2 "name": "atproto-basic-notifications", 3 + "version": "0.1.0", 3 4 "lockfileVersion": 3, 4 5 "requires": true, 5 6 "packages": { 6 7 "": { 8 + "name": "atproto-basic-notifications", 9 + "version": "0.1.0", 10 + "license": "MIT", 7 11 "dependencies": { 12 + "@atcute/atproto": "^3.1.3", 8 13 "@atcute/bluesky": "^3.2.2", 9 14 "@atcute/client": "^4.0.3", 15 + "@atcute/identity-resolver": "^1.1.3", 10 16 "@atcute/jetstream": "^1.1.0", 11 - "@atcute/lexicons": "^1.1.1" 17 + "@atcute/lexicons": "^1.1.1", 18 + "@atcute/tangled": "^1.0.5" 12 19 }, 13 20 "bin": { 14 21 "atproto-basic-notifications": "dist/index.js" ··· 57 64 "@badrap/valita": "^0.4.5" 58 65 } 59 66 }, 67 + "node_modules/@atcute/identity-resolver": { 68 + "version": "1.1.3", 69 + "resolved": "https://registry.npmjs.org/@atcute/identity-resolver/-/identity-resolver-1.1.3.tgz", 70 + "integrity": "sha512-KZgGgg99CWaV7Df3+h3X/WMrDzTPQVfsaoIVbTNLx2B56BvCL2EmaxPSVw/7BFUJMZHlVU4rtoEB4lyvNyMswA==", 71 + "license": "MIT", 72 + "dependencies": { 73 + "@atcute/lexicons": "^1.0.4", 74 + "@atcute/util-fetch": "^1.0.1", 75 + "@badrap/valita": "^0.4.4" 76 + }, 77 + "peerDependencies": { 78 + "@atcute/identity": "^1.0.0" 79 + } 80 + }, 60 81 "node_modules/@atcute/jetstream": { 61 82 "version": "1.1.0", 62 83 "resolved": "https://registry.npmjs.org/@atcute/jetstream/-/jetstream-1.1.0.tgz", ··· 79 100 "license": "0BSD", 80 101 "dependencies": { 81 102 "esm-env": "^1.2.2" 103 + } 104 + }, 105 + "node_modules/@atcute/tangled": { 106 + "version": "1.0.5", 107 + "resolved": "https://registry.npmjs.org/@atcute/tangled/-/tangled-1.0.5.tgz", 108 + "integrity": "sha512-aitbeyrFQ0uWLMI/W6uWsQnDaHVCqrRo8hIEoDWd0sAjFmLAMsev6SuRUICDbRHBmj76vK+ZQxGGOf5QfDBa3g==", 109 + "license": "0BSD", 110 + "dependencies": { 111 + "@atcute/atproto": "^3.1.3", 112 + "@atcute/lexicons": "^1.1.1" 113 + } 114 + }, 115 + "node_modules/@atcute/util-fetch": { 116 + "version": "1.0.1", 117 + "resolved": "https://registry.npmjs.org/@atcute/util-fetch/-/util-fetch-1.0.1.tgz", 118 + "integrity": "sha512-Clc0E/5ufyGBVfYBUwWNlHONlZCoblSr4Ho50l1LhmRPGB1Wu/AQ9Sz+rsBg7fdaW/auve8ulmwhRhnX2cGRow==", 119 + "license": "MIT", 120 + "dependencies": { 121 + "@badrap/valita": "^0.4.2" 82 122 } 83 123 }, 84 124 "node_modules/@badrap/valita": {
+5 -2
package.json
··· 10 10 "scripts": { 11 11 "build": "npm install && tsc", 12 12 "start": "node dist/index.js", 13 - "dev": "tsc --watch" 13 + "dev": "tsc && npm run start" 14 14 }, 15 15 "bin": { 16 16 "atproto-basic-notifications": "dist/index.js" ··· 20 20 "typescript": "^5.5.3" 21 21 }, 22 22 "dependencies": { 23 + "@atcute/atproto": "^3.1.3", 23 24 "@atcute/bluesky": "^3.2.2", 24 25 "@atcute/client": "^4.0.3", 26 + "@atcute/identity-resolver": "^1.1.3", 25 27 "@atcute/jetstream": "^1.1.0", 26 - "@atcute/lexicons": "^1.1.1" 28 + "@atcute/lexicons": "^1.1.1", 29 + "@atcute/tangled": "^1.0.5" 27 30 } 28 31 }
+8 -1
tsconfig.json
··· 10 10 "module": "NodeNext", 11 11 "moduleResolution": "nodenext", 12 12 "target": "esnext", 13 - "types": ["@types/node", "@atcute/lexicons"], 13 + "types": [ 14 + "@types/node", 15 + "@atcute/lexicons", 16 + "@atcute/atproto", 17 + "@atcute/bluesky", 18 + "@atcute/tangled", 19 + "@atcute/identity-resolver" 20 + ], 14 21 // For nodejs: 15 22 // "lib": ["esnext"], 16 23 // "types": ["node"],