+27
flake.lock
+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
+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
+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
+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
+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
+
}