+1
-1
flake.nix
+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
+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
+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
+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
+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"],