Bluesky app fork with some witchin' additions 馃挮 witchsky.app
bluesky fork client
117
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 462 lines 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}