forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {AtUri, BskyAgent} from '@atproto/api'
2import {type TestBsky, TestNetwork} from '@atproto/dev-env'
3import fs from 'fs'
4import net from 'net'
5import path from 'path'
6
7export interface TestUser {
8 email: string
9 did: string
10 handle: string
11 password: string
12 agent: BskyAgent
13}
14
15export interface TestPDS {
16 appviewDid: string
17 pdsUrl: string
18 mocker: Mocker
19 close: () => Promise<void>
20}
21
22class StringIdGenerator {
23 _nextId = [0]
24 constructor(
25 public _chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ',
26 ) {}
27
28 next() {
29 const r = []
30 for (const char of this._nextId) {
31 r.unshift(this._chars[char])
32 }
33 this._increment()
34 return r.join('')
35 }
36
37 _increment() {
38 for (let i = 0; i < this._nextId.length; i++) {
39 const val = ++this._nextId[i]
40 if (val >= this._chars.length) {
41 this._nextId[i] = 0
42 } else {
43 return
44 }
45 }
46 this._nextId.push(0)
47 }
48
49 *[Symbol.iterator]() {
50 while (true) {
51 yield this.next()
52 }
53 }
54}
55
56const ids = new StringIdGenerator()
57
58export async function createServer(
59 {inviteRequired}: {inviteRequired: boolean} = {
60 inviteRequired: false,
61 },
62): Promise<TestPDS> {
63 const port = 3000
64 const port2 = await getPort(port + 1)
65 const port3 = await getPort(port2 + 1)
66 const pdsUrl = `http://localhost:${port}`
67 const id = ids.next()
68
69 const testNet = await TestNetwork.create({
70 pds: {
71 port,
72 hostname: 'localhost',
73 inviteRequired,
74 },
75 bsky: {
76 dbPostgresSchema: `bsky_${id}`,
77 port: port3,
78 publicUrl: 'http://localhost:2584',
79 },
80 plc: {port: port2},
81 })
82
83 // DISABLED - looks like dev-env added this and now it conflicts
84 // add the test mod authority
85 // const agent = new BskyAgent({service: pdsUrl})
86 // const res = await agent.api.com.atproto.server.createAccount({
87 // email: 'mod-authority@test.com',
88 // handle: 'mod-authority.test',
89 // password: 'hunter2',
90 // })
91 // agent.api.setHeader('Authorization', `Bearer ${res.data.accessJwt}`)
92 // await agent.api.app.bsky.actor.profile.create(
93 // {repo: res.data.did},
94 // {
95 // displayName: 'Dev-env Moderation',
96 // description: `The pretend version of mod.bsky.app`,
97 // },
98 // )
99
100 // await agent.api.app.bsky.labeler.service.create(
101 // {repo: res.data.did, rkey: 'self'},
102 // {
103 // policies: {
104 // labelValues: ['!hide', '!warn'],
105 // labelValueDefinitions: [],
106 // },
107 // createdAt: new Date().toISOString(),
108 // },
109 // )
110
111 const pic = fs.readFileSync(
112 path.join(__dirname, '..', 'assets', 'default-avatar.png'),
113 )
114
115 return {
116 appviewDid: testNet.bsky.serverDid,
117 pdsUrl,
118 mocker: new Mocker(testNet, pdsUrl, pic),
119 async close() {
120 await testNet.close()
121 },
122 }
123}
124
125class Mocker {
126 agent: BskyAgent
127 users: Record<string, TestUser> = {}
128
129 constructor(
130 public testNet: TestNetwork,
131 public service: string,
132 public pic: Uint8Array,
133 ) {
134 this.agent = new BskyAgent({service})
135 }
136
137 get pds() {
138 return this.testNet.pds
139 }
140
141 get bsky() {
142 return this.testNet.bsky
143 }
144
145 get plc() {
146 return this.testNet.plc
147 }
148
149 // NOTE
150 // deterministic date generator
151 // we use this to ensure the mock dataset is always the same
152 // which is very useful when testing
153 *dateGen() {
154 let start = 1657846031914
155 while (true) {
156 yield new Date(start).toISOString()
157 start += 1e3
158 }
159 }
160
161 async createUser(name: string) {
162 const agent = new BskyAgent({service: this.service})
163
164 const inviteRes = await agent.api.com.atproto.server.createInviteCode(
165 {useCount: 1},
166 {
167 headers: this.pds.adminAuthHeaders(),
168 encoding: 'application/json',
169 },
170 )
171
172 const email = `fake${Object.keys(this.users).length + 1}@fake.com`
173 const res = await agent.createAccount({
174 inviteCode: inviteRes.data.code,
175 email,
176 handle: name + '.test',
177 password: 'hunter2',
178 })
179 await agent.upsertProfile(async () => {
180 const blob = await agent.uploadBlob(this.pic, {
181 encoding: 'image/jpeg',
182 })
183 return {
184 displayName: name,
185 avatar: blob.data.blob,
186 }
187 })
188 this.users[name] = {
189 did: res.data.did,
190 email,
191 handle: name + '.test',
192 password: 'hunter2',
193 agent: agent,
194 }
195 }
196
197 async follow(a: string, b: string) {
198 await this.users[a].agent.follow(this.users[b].did)
199 }
200
201 async generateStandardGraph() {
202 await this.createUser('alice')
203 await this.createUser('bob')
204 await this.createUser('carla')
205
206 await this.users.alice.agent.upsertProfile(() => ({
207 displayName: 'Alice',
208 description: 'Test user 1',
209 }))
210
211 await this.users.bob.agent.upsertProfile(() => ({
212 displayName: 'Bob',
213 description: 'Test user 2',
214 }))
215
216 await this.users.carla.agent.upsertProfile(() => ({
217 displayName: 'Carla',
218 description: 'Test user 3',
219 }))
220
221 await this.follow('alice', 'bob')
222 await this.follow('alice', 'carla')
223 await this.follow('bob', 'alice')
224 await this.follow('bob', 'carla')
225 await this.follow('carla', 'alice')
226 await this.follow('carla', 'bob')
227 }
228
229 async createPost(user: string, text: string) {
230 const agent = this.users[user]?.agent
231 if (!agent) {
232 throw new Error(`Not a user: ${user}`)
233 }
234 return await agent.post({
235 text,
236 langs: ['en'],
237 createdAt: new Date().toISOString(),
238 })
239 }
240
241 async createImagePost(user: string, text: string) {
242 const agent = this.users[user]?.agent
243 if (!agent) {
244 throw new Error(`Not a user: ${user}`)
245 }
246 const blob = await agent.uploadBlob(this.pic, {
247 encoding: 'image/jpeg',
248 })
249 return await agent.post({
250 text,
251 langs: ['en'],
252 embed: {
253 $type: 'app.bsky.embed.images',
254 images: [{image: blob.data.blob, alt: ''}],
255 },
256 createdAt: new Date().toISOString(),
257 })
258 }
259
260 async createQuotePost(
261 user: string,
262 text: string,
263 {uri, cid}: {uri: string; cid: string},
264 ) {
265 const agent = this.users[user]?.agent
266 if (!agent) {
267 throw new Error(`Not a user: ${user}`)
268 }
269 return await agent.post({
270 text,
271 embed: {$type: 'app.bsky.embed.record', record: {uri, cid}},
272 langs: ['en'],
273 createdAt: new Date().toISOString(),
274 })
275 }
276
277 async createReply(
278 user: string,
279 text: string,
280 {uri, cid}: {uri: string; cid: string},
281 ) {
282 const agent = this.users[user]?.agent
283 if (!agent) {
284 throw new Error(`Not a user: ${user}`)
285 }
286 return await agent.post({
287 text,
288 reply: {root: {uri, cid}, parent: {uri, cid}},
289 langs: ['en'],
290 createdAt: new Date().toISOString(),
291 })
292 }
293
294 async like(user: string, {uri, cid}: {uri: string; cid: string}) {
295 const agent = this.users[user]?.agent
296 if (!agent) {
297 throw new Error(`Not a user: ${user}`)
298 }
299 return await agent.like(uri, cid)
300 }
301
302 async createFeed(user: string, rkey: string, posts: string[]) {
303 const agent = this.users[user]?.agent
304 if (!agent) {
305 throw new Error(`Not a user: ${user}`)
306 }
307 const fgUri = AtUri.make(
308 this.users[user].did,
309 'app.bsky.feed.generator',
310 rkey,
311 )
312 const fg1 = await this.testNet.createFeedGen({
313 [fgUri.toString()]: async () => {
314 return {
315 encoding: 'application/json',
316 body: {
317 feed: posts.slice(0, 30).map(uri => ({post: uri})),
318 },
319 }
320 },
321 })
322 const avatarRes = await agent.api.com.atproto.repo.uploadBlob(this.pic, {
323 encoding: 'image/png',
324 })
325 return await agent.api.app.bsky.feed.generator.create(
326 {repo: this.users[user].did, rkey},
327 {
328 did: fg1.did,
329 displayName: rkey,
330 description: 'all my fav stuff',
331 avatar: avatarRes.data.blob,
332 createdAt: new Date().toISOString(),
333 },
334 )
335 }
336
337 async createInvite(forAccount: string) {
338 const agent = new BskyAgent({service: this.service})
339 await agent.api.com.atproto.server.createInviteCode(
340 {useCount: 1, forAccount},
341 {
342 headers: this.pds.adminAuthHeaders(),
343 encoding: 'application/json',
344 },
345 )
346 }
347
348 async labelAccount(label: string, user: string) {
349 const did = this.users[user]?.did
350 if (!did) {
351 throw new Error(`Invalid user: ${user}`)
352 }
353 const ctx = this.bsky.ctx
354 if (!ctx) {
355 throw new Error('Invalid appview')
356 }
357 await createLabel(this.bsky, {
358 uri: did,
359 cid: '',
360 val: label,
361 })
362 }
363
364 async labelProfile(label: string, user: string) {
365 const agent = this.users[user]?.agent
366 const did = this.users[user]?.did
367 if (!did) {
368 throw new Error(`Invalid user: ${user}`)
369 }
370
371 const profile = await agent.app.bsky.actor.profile.get({
372 repo: user + '.test',
373 rkey: 'self',
374 })
375
376 const ctx = this.bsky.ctx
377 if (!ctx) {
378 throw new Error('Invalid appview')
379 }
380 await createLabel(this.bsky, {
381 uri: profile.uri,
382 cid: profile.cid,
383 val: label,
384 })
385 }
386
387 async labelPost(label: string, {uri, cid}: {uri: string; cid: string}) {
388 const ctx = this.bsky.ctx
389 if (!ctx) {
390 throw new Error('Invalid appview')
391 }
392 await createLabel(this.bsky, {
393 uri,
394 cid,
395 val: label,
396 })
397 }
398
399 async createMuteList(user: string, name: string): Promise<string> {
400 const res = await this.users[user]?.agent.app.bsky.graph.list.create(
401 {repo: this.users[user]?.did},
402 {
403 purpose: 'app.bsky.graph.defs#modlist',
404 name,
405 createdAt: new Date().toISOString(),
406 },
407 )
408 await this.users[user]?.agent.app.bsky.graph.muteActorList({
409 list: res.uri,
410 })
411 return res.uri
412 }
413
414 async addToMuteList(owner: string, list: string, subject: string) {
415 await this.users[owner]?.agent.app.bsky.graph.listitem.create(
416 {repo: this.users[owner]?.did},
417 {
418 list,
419 subject,
420 createdAt: new Date().toISOString(),
421 },
422 )
423 }
424}
425
426const checkAvailablePort = (port: number) =>
427 new Promise(resolve => {
428 const server = net.createServer()
429 server.unref()
430 server.on('error', () => resolve(false))
431 server.listen({port}, () => {
432 server.close(() => {
433 resolve(true)
434 })
435 })
436 })
437
438async function getPort(start = 3000) {
439 for (let i = start; i < 65000; i++) {
440 if (await checkAvailablePort(i)) {
441 return i
442 }
443 }
444 throw new Error('Unable to find an available port')
445}
446
447const createLabel = async (
448 bsky: TestBsky,
449 opts: {uri: string; cid: string; val: string},
450) => {
451 await bsky.db.db
452 .insertInto('label')
453 .values({
454 uri: opts.uri,
455 cid: opts.cid,
456 val: opts.val,
457 cts: new Date().toISOString(),
458 neg: false,
459 src: 'did:example:labeler',
460 })
461 .execute()
462}