Bluesky app fork with some witchin' additions 馃挮
at main 11 kB view raw
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}