basic notification system for atproto stuff using ntfy

first commit

aylac.top 07dc8f4b

+2
.gitignore
··· 1 + dist 2 + node_modules
+27
flake.lock
··· 1 + { 2 + "nodes": { 3 + "nixpkgs": { 4 + "locked": { 5 + "lastModified": 1757068644, 6 + "narHash": "sha256-NOrUtIhTkIIumj1E/Rsv1J37Yi3xGStISEo8tZm3KW4=", 7 + "owner": "NixOS", 8 + "repo": "nixpkgs", 9 + "rev": "8eb28adfa3dc4de28e792e3bf49fcf9007ca8ac9", 10 + "type": "github" 11 + }, 12 + "original": { 13 + "owner": "NixOS", 14 + "ref": "nixos-unstable", 15 + "repo": "nixpkgs", 16 + "type": "github" 17 + } 18 + }, 19 + "root": { 20 + "inputs": { 21 + "nixpkgs": "nixpkgs" 22 + } 23 + } 24 + }, 25 + "root": "root", 26 + "version": 7 27 + }
+189
index.ts
··· 1 + import { 2 + Client, 3 + CredentialManager, 4 + ok, 5 + simpleFetchHandler, 6 + } from "@atcute/client"; 7 + import { JetstreamSubscription } from "@atcute/jetstream"; 8 + import { Did, is, RecordKey } from "@atcute/lexicons"; 9 + 10 + import { AppBskyFeedPost } from "@atcute/bluesky"; 11 + import { 12 + ProfileViewDetailed, 13 + VerificationView, 14 + } from "@atcute/bluesky/types/app/actor/defs"; 15 + 16 + const TARGET_DID = process.env.TARGET_DID || "did:plc:3c6vkaq7xf5kz3va3muptjh5"; 17 + 18 + const JETSTREAM_URL = 19 + process.env.JETSTREAM_URL || 20 + "wss://jetstream2.us-east.bsky.network/subscribe"; 21 + const NTFY_URL = process.env.NTFY_URL || "http://0.0.0.0"; 22 + const BSKY_URL = process.env.BSKY_URL || "https://bsky.app"; 23 + const PDSLS_URL = process.env.PDSLS_URL || "https://pdsls.dev"; 24 + const TANGLED_URL = process.env.TANGLED_URL || "https://tangled.sh"; 25 + 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 + }); 31 + 32 + const profileCache = new Map< 33 + Did, 34 + { profile: ProfileViewDetailed; timestamp: number } 35 + >(); 36 + 37 + const getProfile = async (did: Did) => { 38 + const cached = profileCache.get(did); 39 + const now = Date.now(); 40 + 41 + if (cached && now - cached.timestamp < CACHE_LIFETIME_MS) { 42 + return cached.profile; 43 + } 44 + 45 + const profile = ( 46 + await client.get("app.bsky.actor.getProfile", { 47 + params: { 48 + actor: did, 49 + }, 50 + }) 51 + ).data; 52 + 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; 60 + 61 + profileCache.set(did, { profile, timestamp: now }); 62 + return profile; 63 + }; 64 + 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, { 73 + method: "POST", 74 + headers: { 75 + Title: title, 76 + Icon: icon ?? "", 77 + Priority: priority.toString(), 78 + Click: url, 79 + }, 80 + body: message, 81 + }); 82 + }; 83 + 84 + const wantedCollections = [ 85 + "app.bsky.feed.post", 86 + "app.bsky.feed.follow", 87 + "app.bsky.graph.verification", 88 + "sh.tangled.graph.follow", 89 + "sh.tangled.feed.star", 90 + "sh.tangled.repo.issue", 91 + "sh.tangled.repo.issue.comment", 92 + "sh.tangled.repo.issue.state", 93 + ]; 94 + 95 + const notificationHandlers: { 96 + [key in (typeof wantedCollections)[number]]: ( 97 + did: Did, 98 + rkey: RecordKey, 99 + record: any, 100 + ) => void; 101 + } = { 102 + "app.bsky.feed.post": async (did, rkey, record: AppBskyFeedPost.Main) => { 103 + const profile = await getProfile(did); 104 + 105 + const typeOfPost = 106 + record.reply?.parent.uri.includes(TARGET_DID) || 107 + record.reply?.root.uri.includes(TARGET_DID) 108 + ? "replied" 109 + : "mentioned you"; 110 + 111 + 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 + ); 118 + }, 119 + "app.bsky.feed.follow": async (did, rkey, record) => { 120 + const profile = await getProfile(did); 121 + 122 + sendNotification( 123 + "Bluesky", 124 + profile.avatar, 125 + `${profile.handle} followed you`, 126 + `${BSKY_URL}/profile/${profile.did}`, 127 + 2, 128 + ); 129 + }, 130 + "app.bsky.graph.verification": async ( 131 + did, 132 + rkey, 133 + record: VerificationView, 134 + ) => { 135 + const profile = await getProfile(did); 136 + 137 + sendNotification( 138 + "Bluesky", 139 + profile.avatar, 140 + `${profile.handle} verified you`, 141 + `${PDSLS_URL}/${record.uri}`, 142 + 2, 143 + ); 144 + }, 145 + "sh.tangled.graph.follow": async (did, rkey, record) => { 146 + const profile = await getProfile(did); 147 + 148 + sendNotification( 149 + "Tangled", 150 + profile.avatar, 151 + `${profile.handle} followed you`, 152 + `${TANGLED_URL}/@${profile.did}`, 153 + 2, 154 + ); 155 + }, 156 + }; 157 + 158 + async function main() { 159 + console.log("Started notification server."); 160 + 161 + const subscription = new JetstreamSubscription({ 162 + url: JETSTREAM_URL, 163 + wantedCollections: wantedCollections, 164 + }); 165 + 166 + for await (const event of subscription) { 167 + if (event.did !== TARGET_DID && event.kind === "commit") { 168 + const commit = event.commit; 169 + 170 + if ( 171 + commit.operation === "create" && 172 + wantedCollections.includes(commit.collection) 173 + ) { 174 + const record = commit.record; 175 + const recordText = JSON.stringify(record); 176 + 177 + if (recordText.includes(TARGET_DID)) { 178 + const handler = notificationHandlers[commit.collection]; 179 + 180 + if (handler) { 181 + handler(event.did, event.commit.rkey, record); 182 + } 183 + } 184 + } 185 + } 186 + } 187 + } 188 + 189 + main();
+20
package.json
··· 1 + { 2 + "scripts": { 3 + "build": "tsc", 4 + "prestart": "npm run build", 5 + "start": "node dist/index.js", 6 + "dev": "tsc --watch", 7 + "clean": "rm -rf dist *.tsbuildinfo", 8 + "lint": "echo 'No linting configured yet.'" 9 + }, 10 + "devDependencies": { 11 + "@types/node": "^24.3.1", 12 + "typescript": "^5.5.3" 13 + }, 14 + "dependencies": { 15 + "@atcute/bluesky": "^3.2.2", 16 + "@atcute/client": "^4.0.3", 17 + "@atcute/jetstream": "^1.1.0", 18 + "@atcute/lexicons": "^1.1.1" 19 + } 20 + }
+150
pnpm-lock.yaml
··· 1 + lockfileVersion: '9.0' 2 + 3 + settings: 4 + autoInstallPeers: true 5 + excludeLinksFromLockfile: false 6 + 7 + importers: 8 + 9 + .: 10 + dependencies: 11 + '@atcute/bluesky': 12 + specifier: ^3.2.2 13 + version: 3.2.2 14 + '@atcute/client': 15 + specifier: ^4.0.3 16 + version: 4.0.3 17 + '@atcute/jetstream': 18 + specifier: ^1.1.0 19 + version: 1.1.0 20 + '@atcute/lexicons': 21 + specifier: ^1.1.1 22 + version: 1.1.1 23 + devDependencies: 24 + '@types/node': 25 + specifier: ^24.3.1 26 + version: 24.3.1 27 + typescript: 28 + specifier: ^5.5.3 29 + version: 5.9.2 30 + 31 + packages: 32 + 33 + '@atcute/atproto@3.1.3': 34 + resolution: {integrity: sha512-+5u0l+8E7h6wZO7MM1HLXIPoUEbdwRtr28ZRTgsURp+Md9gkoBj9e5iMx/xM8F2Exfyb65J5RchW/WlF2mw/RQ==} 35 + 36 + '@atcute/bluesky@3.2.2': 37 + resolution: {integrity: sha512-L8RrMNeRLGvSHMq2KDIAGXrpuNGA87YOXpXHY1yhmovVCjQ5n55FrR6JoQaxhprdXdKKQiefxNwQQQybDrfgFQ==} 38 + 39 + '@atcute/client@4.0.3': 40 + resolution: {integrity: sha512-RIOZWFVLca/HiPAAUDqQPOdOreCxTbL5cb+WUf5yqQOKIu5yEAP3eksinmlLmgIrlr5qVOE7brazUUzaskFCfw==} 41 + 42 + '@atcute/identity@1.1.0': 43 + resolution: {integrity: sha512-6vRvRqJatDB+JUQsb+UswYmtBGQnSZcqC3a2y6H5DB/v5KcIh+6nFFtc17G0+3W9rxdk7k9M4KkgkdKf/YDNoQ==} 44 + 45 + '@atcute/jetstream@1.1.0': 46 + resolution: {integrity: sha512-XrSeEHLt2FnVNm3KBDQYY7+rWM0IQKBjLQUjdoCj4mnkMCdm3/dC09qs5ubQQGrHieUWeKHHEko/D6EB891hPg==} 47 + 48 + '@atcute/lexicons@1.1.1': 49 + resolution: {integrity: sha512-k6qy5p3j9fJJ6ekaMPfEfp3ni4TW/XNuH9ZmsuwC0fi0tOjp+Fa8ZQakHwnqOzFt/cVBfGcmYE/lKNAbeTjgUg==} 50 + 51 + '@badrap/valita@0.4.6': 52 + resolution: {integrity: sha512-4kdqcjyxo/8RQ8ayjms47HCWZIF5981oE5nIenbfThKDxWXtEHKipAOWlflpPJzZx9y/JWYQkp18Awr7VuepFg==} 53 + engines: {node: '>= 18'} 54 + 55 + '@mary-ext/event-iterator@1.0.0': 56 + resolution: {integrity: sha512-l6gCPsWJ8aRCe/s7/oCmero70kDHgIK5m4uJvYgwEYTqVxoBOIXbKr5tnkLqUHEg6mNduB4IWvms3h70Hp9ADQ==} 57 + 58 + '@mary-ext/simple-event-emitter@1.0.0': 59 + resolution: {integrity: sha512-meA/zJZKIN1RVBNEYIbjufkUrW7/tRjHH60FjolpG1ixJKo76TB208qefQLNdOVDA7uIG0CGEDuhmMirtHKLAg==} 60 + 61 + '@types/node@24.3.1': 62 + resolution: {integrity: sha512-3vXmQDXy+woz+gnrTvuvNrPzekOi+Ds0ReMxw0LzBiK3a+1k0kQn9f2NWk+lgD4rJehFUmYy2gMhJ2ZI+7YP9g==} 63 + 64 + esm-env@1.2.2: 65 + resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} 66 + 67 + event-target-polyfill@0.0.4: 68 + resolution: {integrity: sha512-Gs6RLjzlLRdT8X9ZipJdIZI/Y6/HhRLyq9RdDlCsnpxr/+Nn6bU2EFGuC94GjxqhM+Nmij2Vcq98yoHrU8uNFQ==} 69 + 70 + partysocket@1.1.5: 71 + resolution: {integrity: sha512-8uw9foq9bij4sKLCtTSHvyqMrMTQ5FJjrHc7BjoM2s95Vu7xYCN63ABpI7OZHC7ZMP5xaom/A+SsoFPXmTV6ZQ==} 72 + 73 + type-fest@4.41.0: 74 + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} 75 + engines: {node: '>=16'} 76 + 77 + typescript@5.9.2: 78 + resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==} 79 + engines: {node: '>=14.17'} 80 + hasBin: true 81 + 82 + undici-types@7.10.0: 83 + resolution: {integrity: sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==} 84 + 85 + yocto-queue@1.2.1: 86 + resolution: {integrity: sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==} 87 + engines: {node: '>=12.20'} 88 + 89 + snapshots: 90 + 91 + '@atcute/atproto@3.1.3': 92 + dependencies: 93 + '@atcute/lexicons': 1.1.1 94 + 95 + '@atcute/bluesky@3.2.2': 96 + dependencies: 97 + '@atcute/atproto': 3.1.3 98 + '@atcute/lexicons': 1.1.1 99 + 100 + '@atcute/client@4.0.3': 101 + dependencies: 102 + '@atcute/identity': 1.1.0 103 + '@atcute/lexicons': 1.1.1 104 + 105 + '@atcute/identity@1.1.0': 106 + dependencies: 107 + '@atcute/lexicons': 1.1.1 108 + '@badrap/valita': 0.4.6 109 + 110 + '@atcute/jetstream@1.1.0': 111 + dependencies: 112 + '@atcute/lexicons': 1.1.1 113 + '@badrap/valita': 0.4.6 114 + '@mary-ext/event-iterator': 1.0.0 115 + '@mary-ext/simple-event-emitter': 1.0.0 116 + partysocket: 1.1.5 117 + type-fest: 4.41.0 118 + yocto-queue: 1.2.1 119 + 120 + '@atcute/lexicons@1.1.1': 121 + dependencies: 122 + esm-env: 1.2.2 123 + 124 + '@badrap/valita@0.4.6': {} 125 + 126 + '@mary-ext/event-iterator@1.0.0': 127 + dependencies: 128 + yocto-queue: 1.2.1 129 + 130 + '@mary-ext/simple-event-emitter@1.0.0': {} 131 + 132 + '@types/node@24.3.1': 133 + dependencies: 134 + undici-types: 7.10.0 135 + 136 + esm-env@1.2.2: {} 137 + 138 + event-target-polyfill@0.0.4: {} 139 + 140 + partysocket@1.1.5: 141 + dependencies: 142 + event-target-polyfill: 0.0.4 143 + 144 + type-fest@4.41.0: {} 145 + 146 + typescript@5.9.2: {} 147 + 148 + undici-types@7.10.0: {} 149 + 150 + yocto-queue@1.2.1: {}
+45
tsconfig.json
··· 1 + { 2 + // Visit https://aka.ms/tsconfig to read more about this file 3 + "compilerOptions": { 4 + // File Layout 5 + "rootDir": "./", 6 + "outDir": "./dist", 7 + 8 + // Environment Settings 9 + // See also https://aka.ms/tsconfig/module 10 + "module": "NodeNext", 11 + "moduleResolution": "nodenext", 12 + "target": "esnext", 13 + "types": ["@types/node", "@atcute/lexicons"], 14 + // For nodejs: 15 + // "lib": ["esnext"], 16 + // "types": ["node"], 17 + // and npm install -D @types/node 18 + 19 + // Other Outputs 20 + "sourceMap": true, 21 + "declaration": true, 22 + "declarationMap": true, 23 + 24 + // Stricter Typechecking Options 25 + "noUncheckedIndexedAccess": true, 26 + "exactOptionalPropertyTypes": true, 27 + 28 + // Style Options 29 + // "noImplicitReturns": true, 30 + // "noImplicitOverride": true, 31 + // "noUnusedLocals": true, 32 + // "noUnusedParameters": true, 33 + // "noFallthroughCasesInSwitch": true, 34 + // "noPropertyAccessFromIndexSignature": true, 35 + 36 + // Recommended Options 37 + "strict": true, 38 + "jsx": "react-jsx", 39 + "verbatimModuleSyntax": false, 40 + "isolatedModules": true, 41 + "noUncheckedSideEffectImports": true, 42 + "moduleDetection": "force", 43 + "skipLibCheck": true 44 + } 45 + }