this repo has no description
0
fork

Configure Feed

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

Initial commit.

alice 5102b107

+513
+12
.editorconfig
··· 1 + # EditorConfig is awesome: https://EditorConfig.org 2 + 3 + # top-most EditorConfig file 4 + root = true 5 + 6 + [*] 7 + indent_style = space 8 + indent_size = 2 9 + end_of_line = lf 10 + charset = utf-8 11 + trim_trailing_whitespace = false 12 + insert_final_newline = true
+8
.env.example
··· 1 + DID=did:plc:xxx 2 + SIGNING_KEY=xxx 3 + BSKY_IDENTIFIER=xxx 4 + BSKY_PASSWORD=xxx 5 + PORT=4002 6 + METRICS_PORT=4102 7 + FIREHOSE_URL=wss://jetstream.atproto.tools/subscribe 8 + CURSOR_UPDATE_INTERVAL=10000
+6
.gitignore
··· 1 + .env 2 + node_modules 3 + cursor.txt 4 + *.log 5 + labels.db* 6 + bun.lockb
+2
.husky/pre-commit
··· 1 + # shellcheck disable=SC2148 2 + bunx lint-staged
+1
.nvmrc
··· 1 + v22.9.0
+1
.prettierignore
··· 1 + src/constants.ts
+12
.prettierrc
··· 1 + { 2 + "trailingComma": "all", 3 + "tabWidth": 2, 4 + "semi": true, 5 + "singleQuote": true, 6 + "printWidth": 120, 7 + "experimentalTernaries": true, 8 + "plugins": ["@trivago/prettier-plugin-sort-imports"], 9 + "importOrder": ["^[./]"], 10 + "importOrderSeparation": true, 11 + "importOrderSortSpecifiers": true 12 + }
+13
LICENSE
··· 1 + Copyright (c) 2024 Juliet Philippe <notjuliet@riseup.net> 2 + Copyright (c) 2024 Alice <aliceisjustplaying@gmail.com> 3 + 4 + Permission to use, copy, modify, and/or distribute this software for any 5 + purpose with or without fee is hereby granted. 6 + 7 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 8 + REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 9 + AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 10 + INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 11 + LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 12 + OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 13 + PERFORMANCE OF THIS SOFTWARE.
+57
README.md
··· 1 + # Bluesky Labeler Starter Kit 2 + 3 + Use this repository to get started with your own Bluesky Labeler. Click the "Use this template" button above to create a new repository, and then follow the instructions below. 4 + 5 + As an example, this repository includes a labeler for setting your favorite of the five elements (Earth, Fire, Air, Water, Love) to your profile. You can edit the labels, descriptions, and other parameters in the `src/constants.ts` file. 6 + 7 + ## Prerequisites 8 + 9 + - [Node.js](https://nodejs.org/) 21 or later 10 + - [Bun](https://bun.sh/) 11 + 12 + ## Setup 13 + 14 + Clone the repo and run `bun i` to install the dependencies. This project uses [Bun](https://bun.sh/) for package management. 15 + 16 + Run `bunx @skyware/labeler setup` to convert an existing account into a labeler. You can exit after converting the account; there's no need to add the labels with the wizard. We'll do that from code. 17 + 18 + Copy the `.env.example` file to `.env` and fill in the values: 19 + 20 + ```Dotenv 21 + DID=did:plc:xxx 22 + SIGNING_KEY=xxx 23 + BSKY_IDENTIFIER=xxx 24 + BSKY_PASSWORD=xxx 25 + PORT=4002 26 + METRICS_PORT=4102 27 + FIREHOSE_URL=wss://jetstream.atproto.tools/subscribe 28 + CURSOR_UPDATE_INTERVAL=10000 29 + ``` 30 + 31 + A `cursor.txt` file containing the time in microseconds also needs to be present. If it doesn't exist, it will be created with the current time. 32 + 33 + Create the necessary posts from the labeler account, fill out `src/constants.ts` with the related post rkeys ([record keys](https://atproto.com/specs/record-key)), label IDs and so on, then run `bunx tsx src/set-labels.ts` to create/update all labels at once. 34 + 35 + Alternatively, use `bunx @skyware/labeler label add` and edit `src/constants.ts` after. 36 + 37 + The server connects to [Jetstream](https://github.com/bluesky-social/jetstream), which provides a WebSocket endpoint that emits ATProto events in JSON. There is a public instance available at `wss://jetstream.atproto.tools/subscribe`. 38 + 39 + The server needs to be reachable outside your local network using the URL you provided during the account setup (typically using a reverse proxy such as [Caddy](https://caddyserver.com/)): 40 + 41 + ```Caddyfile 42 + labeler.example.com { 43 + reverse_proxy 127.0.0.1:4002 44 + } 45 + ``` 46 + 47 + Metrics are exposed on the defined `METRICS_PORT` for [Prometheus](https://prometheus.io/). [This dashboard](https://grafana.com/grafana/dashboards/11159-nodejs-application-dashboard/) can be used to visualize the metrics in [Grafana](https://grafana.com/grafana/). 48 + 49 + Start the project with `bun run start`. 50 + 51 + You can check that the labeler is reachable by checking the `/xrpc/com.atproto.label.queryLabels` endpoint of your labeler's server. A new, empty labeler returns `{"cursor":"0","labels":[]}`. 52 + 53 + ## Credits 54 + 55 + - [alice](https://bsky.app/profile/did:plc:by3jhwdqgbtrcc7q4tkkv3cf), creator of the [Zodiac Sign Labels](https://github.com/aliceisjustplaying/zodiacsigns) 56 + - [Juliet](https://bsky.app/profile/did:plc:b3pn34agqqchkaf75v7h43dk), author of the [Pronouns labeler](https://github.com/notjuliet/pronouns-bsky), whose code my labelers were originally based on 57 + - [futur](https://bsky.app/profile/did:plc:uu5axsmbm2or2dngy4gwchec), creator of the [skyware libraries](https://skyware.js.org/) which make it easier to build things for Bluesky
+26
eslint.config.mjs
··· 1 + import eslint from '@eslint/js'; 2 + import tseslint from 'typescript-eslint'; 3 + 4 + export default tseslint.config( 5 + eslint.configs.recommended, 6 + ...tseslint.configs.strictTypeChecked, 7 + ...tseslint.configs.stylisticTypeChecked, 8 + { 9 + languageOptions: { 10 + parserOptions: { 11 + projectService: { 12 + allowDefaultProject: ['*.js'], 13 + defaultProject: 'tsconfig.json', 14 + }, 15 + }, 16 + }, 17 + rules: { 18 + '@typescript-eslint/no-non-null-assertion': 'off', 19 + '@typescript-eslint/restrict-template-expressions': 'off', 20 + }, 21 + }, 22 + { 23 + files: ['eslint.config.mjs'], 24 + extends: [tseslint.configs.disableTypeChecked], 25 + }, 26 + );
+40
package.json
··· 1 + { 2 + "name": "zodiacsigns-bsky", 3 + "version": "1.0.0", 4 + "type": "module", 5 + "scripts": { 6 + "start": "npx tsx src/main.ts", 7 + "dev": "npx tsx --watch src/main.ts", 8 + "format": "bunx prettier --write .", 9 + "lint": "bunx eslint .", 10 + "lint:fix": "bunx eslint --fix ." 11 + }, 12 + "lint-staged": { 13 + "*": "prettier --ignore-unknown --write" 14 + }, 15 + "devDependencies": { 16 + "@eslint/js": "^9.11.1", 17 + "@trivago/prettier-plugin-sort-imports": "^4.3.0", 18 + "@types/better-sqlite3": "^7.6.11", 19 + "@types/eslint__js": "^8.42.3", 20 + "@types/node": "^22.7.4", 21 + "@types/ws": "^8.5.12", 22 + "@types/express": "^4.17.21", 23 + "eslint": "^9.11.1", 24 + "prettier": "^3.3.3", 25 + "typescript": "^5.6.2", 26 + "typescript-eslint": "^8.8.0" 27 + }, 28 + "dependencies": { 29 + "@atproto/api": "^0.13.8", 30 + "@skyware/jetstream": "^0.1.6", 31 + "@skyware/labeler": "^0.1.7", 32 + "dotenv": "^16.4.5", 33 + "express": "^4.21.0", 34 + "husky": "^9.1.6", 35 + "lint-staged": "^15.2.10", 36 + "pino": "^9.4.0", 37 + "pino-pretty": "^11.2.2", 38 + "prom-client": "^15.1.3" 39 + } 40 + }
+12
src/config.ts
··· 1 + import 'dotenv/config'; 2 + 3 + export const DID = process.env.DID ?? ''; 4 + export const SIGNING_KEY = process.env.SIGNING_KEY ?? ''; 5 + export const PORT = process.env.PORT ? Number(process.env.PORT) : 4002; 6 + export const METRICS_PORT = process.env.METRICS_PORT ? Number(process.env.METRICS_PORT) : 4102; 7 + export const FIREHOSE_URL = process.env.FIREHOSE_URL ?? 'wss://jetstream.atproto.tools/subscribe'; 8 + export const WANTED_COLLECTION = 'app.bsky.feed.like'; 9 + export const BSKY_IDENTIFIER = process.env.BSKY_IDENTIFIER ?? ''; 10 + export const BSKY_PASSWORD = process.env.BSKY_PASSWORD ?? ''; 11 + export const CURSOR_UPDATE_INTERVAL = 12 + process.env.CURSOR_UPDATE_INTERVAL ? Number(process.env.CURSOR_UPDATE_INTERVAL) : 10000;
+46
src/constants.ts
··· 1 + import { Label } from './types.js'; 2 + 3 + export const DELETE = 'insert-rkey-of-delete-post-here'; 4 + export const LABEL_LIMIT = 1; 5 + export const LABELS: Label[] = [ 6 + { 7 + rkey: 'insert-rkey-here', 8 + identifier: 'earth', 9 + locales: [ 10 + { lang: 'en', name: 'Earth 🌎', description: 'Earth'}, 11 + { lang: 'pt-BR', name: 'Terra 🌎', description: 'Terra'}, 12 + ] 13 + }, 14 + { 15 + rkey: 'insert-rkey-here', 16 + identifier: 'fire', 17 + locales: [ 18 + { lang: 'en', name: 'Fire 🔥', description: 'Fire'}, 19 + { lang: 'pt-BR', name: 'Fogo 🔥', description: 'Fogo'}, 20 + ] 21 + }, 22 + { 23 + rkey: 'insert-rkey-here', 24 + identifier: 'air', 25 + locales: [ 26 + { lang: 'en', name: 'Air 💨', description: 'Air'}, 27 + { lang: 'pt-BR', name: 'Ar 💨', description: 'Ar'}, 28 + ] 29 + }, 30 + { 31 + rkey: 'insert-rkey-here', 32 + identifier: 'water', 33 + locales: [ 34 + { lang: 'en', name: 'Water 💧', description: 'Water'}, 35 + { lang: 'pt-BR', name: 'Água 💧', description: 'Água'}, 36 + ] 37 + }, 38 + { 39 + rkey: 'insert-rkey-here', 40 + identifier: 'love', 41 + locales: [ 42 + { lang: 'en', name: 'Love 💞', description: 'Love'}, 43 + { lang: 'pt-BR', name: 'Amor 💞', description: 'Amor'}, 44 + ] 45 + }, 46 + ];
+84
src/label.ts
··· 1 + import { AppBskyActorDefs, ComAtprotoLabelDefs } from '@atproto/api'; 2 + import { LabelerServer } from '@skyware/labeler'; 3 + 4 + import { DID, SIGNING_KEY } from './config.js'; 5 + import { DELETE, LABELS, LABEL_LIMIT } from './constants.js'; 6 + import logger from './logger.js'; 7 + 8 + export const labelerServer = new LabelerServer({ did: DID, signingKey: SIGNING_KEY }); 9 + 10 + export const label = async (subject: string | AppBskyActorDefs.ProfileView, rkey: string) => { 11 + const did = AppBskyActorDefs.isProfileView(subject) ? subject.did : subject; 12 + logger.info(`Received rkey: ${rkey} for ${did}`); 13 + 14 + if (rkey === 'self') { 15 + logger.info(`${did} liked the labeler. Returning.`); 16 + return; 17 + } 18 + try { 19 + const labels = fetchCurrentLabels(did); 20 + 21 + if (rkey.includes(DELETE)) { 22 + await deleteAllLabels(did, labels); 23 + } else { 24 + await addOrUpdateLabel(did, rkey, labels); 25 + } 26 + } catch (error) { 27 + logger.error(`Error in \`label\` function: ${error}`); 28 + } 29 + }; 30 + 31 + function fetchCurrentLabels(did: string) { 32 + const query = labelerServer.db 33 + .prepare<unknown[], ComAtprotoLabelDefs.Label>(`SELECT * FROM labels WHERE uri = ?`) 34 + .all(did); 35 + 36 + const labels = query.reduce((set, label) => { 37 + if (!label.neg) set.add(label.val); 38 + else set.delete(label.val); 39 + return set; 40 + }, new Set<string>()); 41 + 42 + if (labels.size > 0) { 43 + logger.info(`Current labels: ${Array.from(labels).join(', ')}`); 44 + } 45 + 46 + return labels; 47 + } 48 + 49 + async function deleteAllLabels(did: string, labels: Set<string>) { 50 + const labelsToDelete: string[] = Array.from(labels); 51 + 52 + if (labelsToDelete.length === 0) { 53 + logger.info(`No labels to delete`); 54 + } else { 55 + logger.info(`Labels to delete: ${labelsToDelete.join(', ')}`); 56 + try { 57 + await labelerServer.createLabels({ uri: did }, { negate: labelsToDelete }); 58 + logger.info('Successfully deleted all labels'); 59 + } catch (error) { 60 + logger.error(`Error deleting all labels: ${error}`); 61 + } 62 + } 63 + } 64 + 65 + async function addOrUpdateLabel(did: string, rkey: string, labels: Set<string>) { 66 + const newLabel = LABELS.find((label) => label.rkey === rkey); 67 + logger.info(`New label: ${newLabel?.identifier}`); 68 + 69 + if (labels.size >= LABEL_LIMIT) { 70 + try { 71 + await labelerServer.createLabels({ uri: did }, { negate: Array.from(labels) }); 72 + logger.info(`Successfully negated existing labels: ${Array.from(labels).join(', ')}`); 73 + } catch (error) { 74 + logger.error(`Error negating existing labels: ${error}`); 75 + } 76 + } 77 + 78 + try { 79 + await labelerServer.createLabel({ uri: did, val: newLabel!.identifier }); 80 + logger.info(`Successfully labeled ${did} with ${newLabel?.identifier}`); 81 + } catch (error) { 82 + logger.error(`Error adding new label: ${error}`); 83 + } 84 + }
+19
src/logger.ts
··· 1 + import { pino } from 'pino'; 2 + 3 + const logger = pino({ 4 + level: process.env.LOG_LEVEL ?? 'info', 5 + transport: 6 + process.env.NODE_ENV !== 'production' ? 7 + { 8 + target: 'pino-pretty', 9 + options: { 10 + colorize: true, 11 + translateTime: 'SYS:standard', 12 + ignore: 'pid,hostname', 13 + }, 14 + } 15 + : undefined, 16 + timestamp: pino.stdTimeFunctions.isoTime, 17 + }); 18 + 19 + export default logger;
+95
src/main.ts
··· 1 + import { CommitCreateEvent, Jetstream } from '@skyware/jetstream'; 2 + import fs from 'node:fs'; 3 + 4 + import { CURSOR_UPDATE_INTERVAL, DID, FIREHOSE_URL, METRICS_PORT, PORT, WANTED_COLLECTION } from './config.js'; 5 + import { label, labelerServer } from './label.js'; 6 + import logger from './logger.js'; 7 + import { startMetricsServer } from './metrics.js'; 8 + 9 + let cursor = 0; 10 + let cursorUpdateInterval: NodeJS.Timeout; 11 + 12 + function epochUsToDateTime(cursor: number): string { 13 + return new Date(cursor / 1000).toISOString(); 14 + } 15 + 16 + try { 17 + logger.info('Trying to read cursor from cursor.txt...'); 18 + cursor = Number(fs.readFileSync('cursor.txt', 'utf8')); 19 + logger.info(`Cursor found: ${cursor} (${epochUsToDateTime(cursor)})`); 20 + } catch (error) { 21 + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { 22 + cursor = Math.floor(Date.now() * 1000); 23 + logger.info(`Cursor not found in cursor.txt, setting cursor to: ${cursor} (${epochUsToDateTime(cursor)})`); 24 + fs.writeFileSync('cursor.txt', cursor.toString(), 'utf8'); 25 + } else { 26 + logger.error(error); 27 + process.exit(1); 28 + } 29 + } 30 + 31 + const jetstream = new Jetstream({ 32 + wantedCollections: [WANTED_COLLECTION], 33 + endpoint: FIREHOSE_URL, 34 + cursor: cursor, 35 + }); 36 + 37 + jetstream.on('open', () => { 38 + logger.info( 39 + `Connected to Jetstream at ${FIREHOSE_URL} with cursor ${jetstream.cursor} (${epochUsToDateTime(jetstream.cursor!)})`, 40 + ); 41 + cursorUpdateInterval = setInterval(() => { 42 + if (jetstream.cursor) { 43 + logger.info(`Cursor updated to: ${jetstream.cursor} (${epochUsToDateTime(jetstream.cursor)})`); 44 + fs.writeFile('cursor.txt', jetstream.cursor.toString(), (err) => { 45 + if (err) logger.error(err); 46 + }); 47 + } 48 + }, CURSOR_UPDATE_INTERVAL); 49 + }); 50 + 51 + jetstream.on('close', () => { 52 + clearInterval(cursorUpdateInterval); 53 + logger.info('Jetstream connection closed.'); 54 + }); 55 + 56 + jetstream.on('error', (error) => { 57 + logger.error(`Jetstream error: ${error.message}`); 58 + }); 59 + 60 + jetstream.onCreate(WANTED_COLLECTION, (event: CommitCreateEvent<typeof WANTED_COLLECTION>) => { 61 + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 62 + if (event.commit?.record?.subject?.uri?.includes(DID)) { 63 + label(event.did, event.commit.record.subject.uri.split('/').pop()!).catch((error: unknown) => { 64 + logger.error(`Unexpected error labeling ${event.did}: ${error}`); 65 + }); 66 + } 67 + }); 68 + 69 + const metricsServer = startMetricsServer(METRICS_PORT); 70 + 71 + labelerServer.start(PORT, (error, address) => { 72 + if (error) { 73 + logger.error('Error starting server: %s', error); 74 + } else { 75 + logger.info(`Labeler server listening on ${address}`); 76 + } 77 + }); 78 + 79 + jetstream.start(); 80 + 81 + function shutdown() { 82 + try { 83 + logger.info('Shutting down gracefully...'); 84 + fs.writeFileSync('cursor.txt', jetstream.cursor!.toString(), 'utf8'); 85 + jetstream.close(); 86 + labelerServer.stop(); 87 + metricsServer.close(); 88 + } catch (error) { 89 + logger.error(`Error shutting down gracefully: ${error}`); 90 + process.exit(1); 91 + } 92 + } 93 + 94 + process.on('SIGINT', shutdown); 95 + process.on('SIGTERM', shutdown);
+28
src/metrics.ts
··· 1 + import express from 'express'; 2 + import { Registry, collectDefaultMetrics } from 'prom-client'; 3 + 4 + import logger from './logger.js'; 5 + 6 + const register = new Registry(); 7 + collectDefaultMetrics({ register }); 8 + 9 + const app = express(); 10 + 11 + app.get('/metrics', (req, res) => { 12 + register 13 + .metrics() 14 + .then((metrics) => { 15 + res.set('Content-Type', register.contentType); 16 + res.send(metrics); 17 + }) 18 + .catch((ex: unknown) => { 19 + logger.error(`Error serving metrics: ${(ex as Error).message}`); 20 + res.status(500).end((ex as Error).message); 21 + }); 22 + }); 23 + 24 + export const startMetricsServer = (port: number, host = '127.0.0.1') => { 25 + return app.listen(port, host, () => { 26 + logger.info(`Metrics server is listening on ${host}:${port}`); 27 + }); 28 + };
+33
src/set-labels.ts
··· 1 + import { type ComAtprotoLabelDefs } from '@atproto/api'; 2 + import { type LoginCredentials, setLabelerLabelDefinitions } from '@skyware/labeler/scripts'; 3 + 4 + import { BSKY_IDENTIFIER, BSKY_PASSWORD } from './config.js'; 5 + import { LABELS } from './constants.js'; 6 + import logger from './logger.js'; 7 + 8 + const loginCredentials: LoginCredentials = { 9 + identifier: BSKY_IDENTIFIER, 10 + password: BSKY_PASSWORD, 11 + }; 12 + 13 + const labelDefinitions: ComAtprotoLabelDefs.LabelValueDefinition[] = []; 14 + 15 + for (const label of LABELS) { 16 + const labelValueDefinition: ComAtprotoLabelDefs.LabelValueDefinition = { 17 + identifier: label.identifier, 18 + severity: 'inform', 19 + blurs: 'none', 20 + defaultSetting: 'warn', 21 + adultOnly: false, 22 + locales: label.locales, 23 + }; 24 + 25 + labelDefinitions.push(labelValueDefinition); 26 + } 27 + 28 + try { 29 + await setLabelerLabelDefinitions(loginCredentials, labelDefinitions); 30 + logger.info('Label definitions set successfully.'); 31 + } catch (error) { 32 + logger.error(`Error setting label definitions: ${error}`); 33 + }
+7
src/types.ts
··· 1 + import { LabelValueDefinitionStrings } from '@atproto/api/dist/client/types/com/atproto/label/defs.js'; 2 + 3 + export interface Label { 4 + rkey: string; 5 + identifier: string; 6 + locales: LabelValueDefinitionStrings[]; 7 + }
+11
tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + "strict": true, 4 + "target": "ESNext", 5 + "module": "NodeNext", 6 + "moduleResolution": "NodeNext", 7 + "allowSyntheticDefaultImports": true, 8 + "esModuleInterop": true, 9 + "types": ["node"] 10 + } 11 + }