├── resources ├── template.png └── fonts │ ├── cascadiacode.ttf │ └── licenseplate.ttf ├── .gitmodules ├── .github └── dependabot.yml ├── Dockerfile ├── pm2.json ├── .env.example ├── package.json ├── LICENSE.md ├── networks ├── mastodon.js ├── bluesky.js ├── tumblr.js └── twitter.js ├── README.md ├── app.js ├── .gitignore ├── bot.js └── moderation.js /resources/template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rjindael/ca-dmv-bot/HEAD/resources/template.png -------------------------------------------------------------------------------- /resources/fonts/cascadiacode.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rjindael/ca-dmv-bot/HEAD/resources/fonts/cascadiacode.ttf -------------------------------------------------------------------------------- /resources/fonts/licenseplate.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rjindael/ca-dmv-bot/HEAD/resources/fonts/licenseplate.ttf -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "resources/workbooks"] 2 | path = resources/workbooks 3 | url = https://github.com/21five/ca-license-plates 4 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:lts-alpine 2 | 3 | RUN apk add --no-cache graphicsmagick 4 | 5 | WORKDIR /app 6 | COPY . . 7 | 8 | RUN npm install 9 | RUN npm install -g pm2 babel-cli 10 | 11 | CMD ["pm2-runtime", "start", "pm2.json"] -------------------------------------------------------------------------------- /pm2.json: -------------------------------------------------------------------------------- 1 | { 2 | "apps": [{ 3 | "name": "ca-dmv-bot", 4 | "script": "./app.js", 5 | "watch": false, 6 | "exec_interpreter": "babel-node", 7 | "exec_mode": "fork" 8 | }] 9 | } 10 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | DISCORD_TOKEN= 2 | DISCORD_CHANNEL_ID= 3 | DISCORD_MODERATOR_ROLE_ID= 4 | DISCORD_OWNER_USER_ID= 5 | TWITTER_CONSUMER_KEY= 6 | TWITTER_CONSUMER_SECRET= 7 | TWITTER_ACCESS_TOKEN= 8 | TWITTER_ACCESS_TOKEN_SECRET= 9 | MASTODON_URL= 10 | MASTODON_ACCESS_TOKEN= 11 | TUMBLR_CONSUMER_KEY= 12 | TUMBLR_CONSUMER_SECRET= 13 | TUMBLR_TOKEN= 14 | TUMBLR_TOKEN_SECRET= 15 | BLUESKY_SERVICE=https://bsky.social 16 | BLUESKY_IDENTIFIER= 17 | BLUESKY_PASSWORD= -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ca-dmv-bot", 3 | "version": "1.3.0", 4 | "description": "Social media bot that posts public records of California DMV vanity license plate submissions", 5 | "main": "app.js", 6 | "type": "module", 7 | "scripts": { 8 | "start": "node --no-warnings app.js" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/rjindael/ca-dmv-bot.git" 13 | }, 14 | "author": "rjindael", 15 | "license": "MIT", 16 | "bugs": { 17 | "url": "https://github.com/rjindael/ca-dmv-bot/issues" 18 | }, 19 | "homepage": "https://github.com/rjindael/ca-dmv-bot#readme", 20 | "dependencies": { 21 | "@atproto/api": "^0.13.6", 22 | "csv-parser": "^3.0.0", 23 | "discord.js": "^14.16.1", 24 | "dotenv": "^16.4.5", 25 | "fs-extra": "^11.2.0", 26 | "gm": "^1.25.0", 27 | "masto": "^6.8.0", 28 | "node-schedule": "^2.1.1", 29 | "string-to-stream": "^3.0.1", 30 | "tumblr.js": "^5.0.0", 31 | "twitter-api-v2": "^1.17.2" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | ===================== 3 | 4 | Copyright © 2022-2024 rjindael 5 | 6 | Permission is hereby granted, free of charge, to any person 7 | obtaining a copy of this software and associated documentation 8 | files (the “Software”), to deal in the Software without 9 | restriction, including without limitation the rights to use, 10 | copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the 12 | Software is furnished to do so, subject to the following 13 | conditions: 14 | 15 | The above copyright notice and this permission notice shall be 16 | included in all copies or substantial portions of the Software. 17 | 18 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, 19 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 20 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 21 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 22 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 23 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 24 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 25 | OTHER DEALINGS IN THE SOFTWARE. 26 | -------------------------------------------------------------------------------- /networks/mastodon.js: -------------------------------------------------------------------------------- 1 | import fs from "fs-extra" 2 | import { createRestAPIClient } from "masto" 3 | import util from "node:util" 4 | 5 | import bot from "../bot.js" 6 | 7 | const name = "Mastodon" 8 | 9 | var client 10 | 11 | async function authenticate(credentials) { 12 | client = createRestAPIClient(credentials) 13 | 14 | let user = await client.v1.accounts.verifyCredentials() 15 | console.log(`Logged into Mastodon as "${user.displayName}" (@${user.acct} : ${user.id})`) 16 | } 17 | 18 | async function post(plate) { 19 | let text = util.format(bot.formats.post, plate.customerComment, plate.dmvComment, plate.verdict ? "ACCEPTED" : "DENIED") 20 | let attachment = await client.v2.mediaAttachments.create({ 21 | file: new Blob([ fs.readFileSync(plate.fileName) ]), 22 | description: bot.formatAltText(plate.text) 23 | }) 24 | 25 | let post = await client.v1.statuses.create({ 26 | status: text, 27 | visibility: "public", 28 | mediaIds: [ attachment.id ] 29 | }) 30 | 31 | return post.url 32 | } 33 | 34 | async function updateBio(text) { 35 | await client.v1.accounts.updateCredentials({ note: text }) 36 | } 37 | 38 | export default { name, authenticate, post, updateBio } -------------------------------------------------------------------------------- /networks/bluesky.js: -------------------------------------------------------------------------------- 1 | import fs from "fs-extra" 2 | import bsky from "@atproto/api" 3 | import util from "node:util" 4 | 5 | import bot from "../bot.js" 6 | 7 | const { BskyAgent } = bsky 8 | const name = "Bluesky" 9 | 10 | var agent 11 | var handle 12 | 13 | async function authenticate(credentials) { 14 | agent = new BskyAgent({ service: credentials.service }) 15 | 16 | let response = await agent.login({ identifier: credentials.identifier, password: credentials.password }) 17 | if (!response.success) { 18 | console.error(`Failed to log into Bluesky`) 19 | return 20 | } 21 | 22 | handle = response.data.handle 23 | 24 | console.log(`Logged into Bluesky as @${handle}`) 25 | } 26 | 27 | async function post(plate) { 28 | // TODO: Accomodate for Bluesky's 300char limit 29 | 30 | let text = util.format(bot.formats.post, plate.customerComment, plate.dmvComment, plate.verdict ? "ACCEPTED" : "DENIED") 31 | let altText = bot.formatAltText(plate.text) 32 | 33 | let image = await agent.uploadBlob(fs.readFileSync(plate.fileName), { encoding: "image/png" }) 34 | let response = await agent.post({ 35 | text: text, 36 | embed: { 37 | $type: "app.bsky.embed.images", 38 | images: [{ 39 | image: image.data.blob, 40 | alt: altText 41 | }] 42 | }, 43 | createdAt: new Date().toISOString() 44 | }) 45 | 46 | return `https://bsky.app/profile/${handle}/post/${response.uri.split("/").pop()}` 47 | } 48 | 49 | async function updateBio(text) { 50 | await agent.upsertProfile((existing) => { 51 | existing.description = text 52 | return existing 53 | }) 54 | } 55 | 56 | export default { name, authenticate, post, updateBio } -------------------------------------------------------------------------------- /networks/tumblr.js: -------------------------------------------------------------------------------- 1 | import fs from "fs-extra" 2 | import tumblr from "tumblr.js" 3 | import util from "node:util" 4 | 5 | import bot from "../bot.js" 6 | 7 | const name = "Tumblr" 8 | const globalTags = [ "bot", "ca-dmv-bot", "california", "dmv", "funny", "government", "lol", "public records" ] 9 | 10 | var client 11 | var handle 12 | var blogName 13 | 14 | // This is designed to work with only *one* blog! 15 | 16 | function authenticate(credentials) { 17 | client = tumblr.createClient({ 18 | consumer_key: credentials.consumerKey, 19 | consumer_secret: credentials.consumerSecret, 20 | token: credentials.accessToken, 21 | token_secret: credentials.accessTokenSecret 22 | }) 23 | 24 | return new Promise((resolve) => { 25 | client.userInfo((_, data) => { 26 | blogName = data.user.blogs[0].name 27 | handle = data.user.name 28 | 29 | console.log(`Logged into Tumblr as "${handle}" (blog name: "${blogName}", blog title: "${data.user.blogs[0].title}")`) 30 | resolve() 31 | }) 32 | }) 33 | } 34 | 35 | async function post(plate) { 36 | let text = util.format(bot.formats.post, plate.customerComment, plate.dmvComment, plate.verdict ? "ACCEPTED" : "DENIED").replaceAll("\n", "
") 37 | let altText = bot.formatAltText(plate.text).replaceAll("\"", "").replaceAll(".", "") 38 | let tagString = [ altText, plate.verdict ? "ACCEPTED" : "DENIED", ... globalTags].join(",") 39 | 40 | return new Promise((resolve) => { 41 | client.createLegacyPost(blogName, { 42 | type: "photo", 43 | caption: text, 44 | data64: fs.readFileSync(plate.fileName, { encoding: "base64" }), 45 | tags: tagString, 46 | link: bot.repositoryURL 47 | }, (_, data) => { 48 | resolve(`https://www.tumblr.com/${handle}/${data.id_string}`) 49 | }) 50 | }) 51 | } 52 | 53 | function updateBio() { 54 | // Tumblr doesn't support updating a profiles description through the API. :-( 55 | } 56 | 57 | export default { name, authenticate, post, updateBio } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ca-dmv-bot 2 | 3 | [![License](https://img.shields.io/github/license/rjindael/ca-dmv-bot)](https://github.com/rjindael/ca-dmv-bot/blob/trunk/LICENSE.md) 4 | [![Tumblr](https://img.shields.io/twitter/follow/ca-dmv-bot?logo=tumblr&style=social)](https://www.tumblr.com/ca-dmv-bot) 5 | [![Bluesky](https://img.shields.io/twitter/follow/ca-dmv-bot.bsky.social?logo=bluesky&style=social)](https://bsky.app/profile/ca-dmv-bot.bsky.social) 6 | [![Star](https://img.shields.io/github/stars/rjindael/ca-dmv-bot?style=social)](https://github.com/rjindael/ca-dmv-bot/stargazers) 7 | 8 | Social media bot that randomly posts [35,509 personalized license plate applications the California DMV received from 2015-2017](https://github.com/21five/ca-license-plates). 9 | 10 | Watch it live on the following platforms: 11 | 12 | - Tumblr: [@ca-dmv-bot](https://www.tumblr.com/ca-dmv-bot) 13 | - Bluesky: [@ca-dmv-bot.bsky.social](https://bsky.app/profile/ca-dmv-bot.bsky.social) 14 | 15 | ## Data 16 | 17 | The data ca-dmv-bot uses is sourced from [@21five/ca-license-plates](https://github.com/21five/ca-license-plates), which states; 18 | 19 | - 35,509 personalized license plate applications that the [California DMV](https://dmv.ca.gov) received from 2015 through 2017 20 | - These aren't **all** applications reviewed by the DMV during that timeframe; only applications that were flagged for additional review by the Review Committee 21 | - Compiled from 623 Excel workbooks that the DMV prepared for a public records request 22 | 23 | ## Notes 24 | 25 | - Uses Discord to manually approve plates (to ensure no obscene content gets posted, such as slurs) 26 | - Uses GraphicsMagick (fork of ImageMagick) to generate images 27 | - Does not include review reason codes for brevity purposes 28 | - Made on a slow weekend; only ephemeral logging exists and there is little-to-no error checking 29 | 30 | ## Thanks 31 | 32 | - Noah Veltman ([@veltman](https://github.com/veltman)), 21five ([@21five_public](https://twitter.com/21five_public)) - Compiling the data that ca-dmv-bot uses 33 | - Dylan ([@brickdylanfake](https://twitter.com/brickdylanfake)) - Creating a custom font for the bot to use as well as making the profile picture and banner 34 | - My friends - Helping me moderate the bot :D 35 | 36 | ## License 37 | 38 | ca-dmv-bot is licensed under the [MIT license](https://github.com/rjindael/ca-dmv-bot/blob/trunk/LICENSE.md). A copy of it has been included with ca-dmv-bot. 39 | 40 | ca-dmv-bot uses the [Cascadia Code](https://github.com/microsoft/cascadia-code) font, a project licensed under the SIL Open Font License (OFL). 41 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | import fs from "fs-extra" 2 | import schedule from "node-schedule" 3 | import dotenv from "dotenv" 4 | 5 | import bot from "./bot.js" 6 | import moderation from "./moderation.js" 7 | 8 | var queue = [] 9 | 10 | async function run() { 11 | while (queue.length == 0) { 12 | addPlatesToQueue(await moderation.process()) 13 | } 14 | 15 | await bot.post(queue.pop()) 16 | fs.writeFileSync("./data/queue.json", JSON.stringify(queue)) 17 | 18 | moderation.updateStatus(queue.length) 19 | await moderation.notifyQueueAmount(queue.length) 20 | } 21 | 22 | async function initialize() { 23 | dotenv.config() 24 | 25 | await moderation.initialize({ 26 | token: process.env.DISCORD_TOKEN, 27 | channelId: process.env.DISCORD_CHANNEL_ID, 28 | moderatorRoleId: process.env.DISCORD_MODERATOR_ROLE_ID, 29 | ownerUserId: process.env.DISCORD_OWNER_USER_ID 30 | }) 31 | 32 | await bot.initialize({ 33 | // twitter: { 34 | // appKey: process.env.TWITTER_CONSUMER_KEY, 35 | // appSecret: process.env.TWITTER_CONSUMER_SECRET, 36 | // accessToken: process.env.TWITTER_ACCESS_TOKEN, 37 | // accessSecret: process.env.TWITTER_ACCESS_TOKEN_SECRET 38 | // }, 39 | 40 | // mastodon: { 41 | // url: process.env.MASTODON_URL, 42 | // accessToken: process.env.MASTODON_ACCESS_TOKEN 43 | // }, 44 | 45 | tumblr: { 46 | consumerKey: process.env.TUMBLR_CONSUMER_KEY, 47 | consumerSecret: process.env.TUMBLR_CONSUMER_SECRET, 48 | accessToken: process.env.TUMBLR_TOKEN, 49 | accessTokenSecret: process.env.TUMBLR_TOKEN_SECRET 50 | }, 51 | 52 | bluesky: { 53 | service: process.env.BLUESKY_SERVICE, 54 | identifier: process.env.BLUESKY_IDENTIFIER, 55 | password: process.env.BLUESKY_PASSWORD 56 | } 57 | }) 58 | 59 | // Hourly 60 | schedule.scheduleJob("0 * * * *", async () => { 61 | await run() 62 | }) 63 | 64 | // Daily (at 0:00) 65 | schedule.scheduleJob("0 0 * * *", async () => { 66 | await bot.updateBio() 67 | }) 68 | 69 | queue = JSON.parse(fs.readFileSync("./data/queue.json")) 70 | 71 | moderation.updateStatus(queue.length) 72 | await bot.updateBio() 73 | } 74 | 75 | function getQueue() { 76 | return queue 77 | } 78 | 79 | function addPlatesToQueue(plates) { 80 | queue = plates.reverse().concat(queue) 81 | fs.writeFileSync("./data/queue.json", JSON.stringify(queue)) 82 | } 83 | 84 | initialize() 85 | 86 | export default { getQueue, addPlatesToQueue } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | data/ 2 | backup/ 3 | 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | .pnpm-debug.log* 12 | 13 | # Diagnostic reports (https://nodejs.org/api/report.html) 14 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 15 | 16 | # Runtime data 17 | pids 18 | *.pid 19 | *.seed 20 | *.pid.lock 21 | 22 | # Directory for instrumented libs generated by jscoverage/JSCover 23 | lib-cov 24 | 25 | # Coverage directory used by tools like istanbul 26 | coverage 27 | *.lcov 28 | 29 | # nyc test coverage 30 | .nyc_output 31 | 32 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 33 | .grunt 34 | 35 | # Bower dependency directory (https://bower.io/) 36 | bower_components 37 | 38 | # node-waf configuration 39 | .lock-wscript 40 | 41 | # Compiled binary addons (https://nodejs.org/api/addons.html) 42 | build/Release 43 | 44 | # Dependency directories 45 | node_modules/ 46 | jspm_packages/ 47 | 48 | # Snowpack dependency directory (https://snowpack.dev/) 49 | web_modules/ 50 | 51 | # TypeScript cache 52 | *.tsbuildinfo 53 | 54 | # Optional npm cache directory 55 | .npm 56 | 57 | # Optional eslint cache 58 | .eslintcache 59 | 60 | # Optional stylelint cache 61 | .stylelintcache 62 | 63 | # Microbundle cache 64 | .rpt2_cache/ 65 | .rts2_cache_cjs/ 66 | .rts2_cache_es/ 67 | .rts2_cache_umd/ 68 | 69 | # Optional REPL history 70 | .node_repl_history 71 | 72 | # Output of 'npm pack' 73 | *.tgz 74 | 75 | # Yarn Integrity file 76 | .yarn-integrity 77 | 78 | # dotenv environment variable files 79 | .env 80 | .env.development.local 81 | .env.test.local 82 | .env.production.local 83 | .env.local 84 | 85 | # parcel-bundler cache (https://parceljs.org/) 86 | .cache 87 | .parcel-cache 88 | 89 | # Next.js build output 90 | .next 91 | out 92 | 93 | # Nuxt.js build / generate output 94 | .nuxt 95 | dist 96 | 97 | # Gatsby files 98 | .cache/ 99 | # Comment in the public line in if your project uses Gatsby and not Next.js 100 | # https://nextjs.org/blog/next-9-1#public-directory-support 101 | # public 102 | 103 | # vuepress build output 104 | .vuepress/dist 105 | 106 | # vuepress v2.x temp and cache directory 107 | .temp 108 | .cache 109 | 110 | # Docusaurus cache and generated files 111 | .docusaurus 112 | 113 | # Serverless directories 114 | .serverless/ 115 | 116 | # FuseBox cache 117 | .fusebox/ 118 | 119 | # DynamoDB Local files 120 | .dynamodb/ 121 | 122 | # TernJS port file 123 | .tern-port 124 | 125 | # Stores VSCode versions used for testing VSCode extensions 126 | .vscode-test 127 | 128 | # yarn v2 129 | .yarn/cache 130 | .yarn/unplugged 131 | .yarn/build-state.yml 132 | .yarn/install-state.gz 133 | .pnp.* -------------------------------------------------------------------------------- /networks/twitter.js: -------------------------------------------------------------------------------- 1 | import { TwitterApi } from "twitter-api-v2" 2 | import util from "node:util" 3 | 4 | import bot from "../bot.js" 5 | 6 | const name = "Twitter" 7 | const maxLength = 20000 // set to 280 if non-premium 8 | 9 | var client 10 | var handle 11 | 12 | async function authenticate(credentials) { 13 | client = new TwitterApi(credentials) 14 | 15 | let user = await client.currentUser() 16 | handle = user.screen_name 17 | 18 | console.log(`Logged into Twitter as "${user.name}" (@${handle} : ${user.id_str})`) 19 | } 20 | 21 | async function post(plate) { 22 | let altText = bot.formatAltText(plate.text) 23 | let mediaId = await client.v1.uploadMedia(plate.fileName) 24 | 25 | // Pinnacle of API design 26 | await client.v1.createMediaMetadata(mediaId, { 27 | alt_text: { 28 | text: altText 29 | } 30 | }) 31 | 32 | let text = util.format(bot.formats.post, plate.customerComment, plate.dmvComment, plate.verdict ? "ACCEPTED" : "DENIED") 33 | let snowflakeStr 34 | 35 | if (text.length <= maxLength) { 36 | let tweet = await client.v2.tweet({ text: text, media: { media_ids: [ mediaId ] } }) 37 | snowflakeStr = tweet.data.id 38 | } else { 39 | /** 40 | * Trim the post, then add a reply to the original tweet with the customer comment. 41 | * This is a Twitter-specific case since Mastodon has a 500-char limit and Tumblr has 42 | * a 4096-char limit (i.e. both cases of which the records do not and will never exceed.) 43 | * 44 | * However, this does **not** check if the DMV reviewer comments are too long, and this 45 | * will likely break if it does. That said, this should hopefully never happen. I hope I 46 | * won't have to come back to this later... 47 | */ 48 | 49 | let removeCharacters = (text.length - maxLength) + 3 // "..." 50 | 51 | if (plate.customerComment.length - removeCharacters < 0) { 52 | console.error(`DMV reviewer comments exceeded the maximum Tweet length! Plate: "${plate.text}"`) 53 | return 54 | } 55 | 56 | let trimmedCustomerComment = plate.customerComment.slice(0, -removeCharacters) 57 | 58 | text = util.format(bot.formats.template, trimmedCustomerComment + "...", plate.dmvComment, plate.status ? "ACCEPTED" : "DENIED") 59 | 60 | let tweet = await client.v2.tweet({ text: text, media: { media_ids: [ mediaId ] } }) 61 | snowflakeStr = tweet.id 62 | 63 | let replyText = `(This Tweet was trimmed from its original.)\n\nCustomer: ${plate.customerComment}` 64 | 65 | await client.v2.reply(replyText, tweet.data.id) 66 | } 67 | 68 | return `https://twitter.com/${handle}/status/${snowflakeStr}` 69 | } 70 | 71 | async function updateBio(text) { 72 | // Method deprecated & non-functional as of Elon Musk 73 | // await client.v1.updateAccountProfile({ description: text }) 74 | } 75 | 76 | export default { name, authenticate, post, updateBio } -------------------------------------------------------------------------------- /bot.js: -------------------------------------------------------------------------------- 1 | import crypto from "node:crypto" 2 | import csv from "csv-parser" 3 | import fs from "fs-extra" 4 | import gm from "gm" 5 | import path from "node:path" 6 | import strToStream from "string-to-stream" 7 | import util from "node:util" 8 | 9 | import moderation from "./moderation.js" 10 | import twitter from "./networks/twitter.js" 11 | import mastodon from "./networks/mastodon.js" 12 | import tumblr from "./networks/tumblr.js" 13 | import bluesky from "./networks/bluesky.js" 14 | 15 | const __dirname = path.resolve() 16 | const services = { 17 | // "twitter": twitter, 18 | // "mastodon": mastodon, 19 | "tumblr": tumblr, 20 | "bluesky": bluesky 21 | } 22 | 23 | const repositoryURL = "https://github.com/rjindael/ca-dmv-bot" 24 | const symbols = { "#": "hand", "$": "heart", "+": "plus", "&": "star", "/": "" } 25 | const formats = { 26 | altText: "California license plate with text \"%s\".", 27 | bio: "Real personalized license plate applications that the California DMV received from 2015-2017. Posts hourly. Not the CA DMV. (%d% complete)", 28 | post: "Customer: %s\nDMV: %s\n\nVerdict: %s" 29 | } 30 | 31 | var records = [] 32 | var totalSourceRecords = 0 33 | 34 | async function initialize(credentials) { 35 | // await services.twitter.authenticate(credentials.twitter) 36 | // await services.mastodon.authenticate(credentials.mastodon) 37 | await services.tumblr.authenticate(credentials.tumblr) 38 | await services.bluesky.authenticate(credentials.bluesky) 39 | 40 | if (!fs.existsSync("./resources")) { 41 | throw "Bad install" 42 | } 43 | 44 | let sourceRecords = [] 45 | for (let file of fs.readdirSync("./resources/workbooks")) { 46 | if (file.split(".").pop() == "csv") { 47 | await new Promise((resolve) => { 48 | strToStream(fs.readFileSync(`./resources/workbooks/${file}`)).pipe(csv()).on("data", record => sourceRecords.push(record)).on("end", resolve) 49 | }) 50 | } 51 | } 52 | 53 | totalSourceRecords = sourceRecords.length 54 | 55 | if (!fs.existsSync("./data") || !fs.existsSync("./data/records.json")) { 56 | fs.ensureDirSync("./data") 57 | fs.ensureDirSync("./data/tmp") 58 | fs.writeFileSync("./data/queue.json", "[]") 59 | fs.writeFileSync("./data/posted.json", "[]") 60 | 61 | /** 62 | * plate, review_reason_code, customer_meaning, reviewer_comments, status 63 | * text, customer, dmv, verdict 64 | */ 65 | 66 | let parsedSourceRecords = [] 67 | 68 | for (let i = 0; i < sourceRecords.length; i++) { 69 | // hack 70 | if (sourceRecords[i].plate === undefined) { 71 | sourceRecords[i].plate = Object.values(sourceRecords[i])[0] 72 | } 73 | 74 | if (!sourceRecords[i].plate.length || (sourceRecords[i].status != "N" && sourceRecords[i].status != "Y")) { 75 | continue 76 | } 77 | 78 | parsedSourceRecords.push({ 79 | "text": sourceRecords[i].plate.toUpperCase(), 80 | "customerComment": correctClericalErrors(sourceRecords[i].customer_meaning), 81 | "dmvComment": correctClericalErrors(sourceRecords[i].reviewer_comments), 82 | "verdict": sourceRecords[i].status == "Y" 83 | }) 84 | } 85 | 86 | fs.writeFileSync("./data/records.json", JSON.stringify(parsedSourceRecords)) 87 | } 88 | 89 | records = JSON.parse(fs.readFileSync("./data/records.json")) 90 | } 91 | 92 | /** 93 | * This is a small function to automatically correct clerical errors that exist in the source record data. 94 | * Many entries have columns that are like "NO MEANING REG 17", "NO MICRO", "NOT ON QUICKWEB YET". 95 | * 96 | * If a comment contains a matching keyword, it modifies it to become a meaningless "(not on record)" 97 | * so that the plate may still be posted while preserving clarity. 98 | * 99 | * This also strips enclosing quotation marks and duplicate quotation marks. 100 | */ 101 | function correctClericalErrors(comment) { 102 | if (!comment.length) { 103 | return "(not on record)" 104 | } 105 | 106 | let matches = ["no micro", "not on micro", "reg 17", "quickweb", "quick web"] 107 | for (let i = 0; i < matches.length; i++) { 108 | if (comment.toLowerCase().includes(matches[i]) || comment.toLowerCase().trim() == matches[i]) { 109 | return "(not on record)" 110 | } 111 | } 112 | 113 | if (comment[0] == "\"" && comment[comment.length - 1] == "\"") { 114 | comment = comment.slice(1, -1) 115 | } 116 | 117 | comment = comment.replaceAll("\"\"", "\"") 118 | 119 | return comment 120 | } 121 | 122 | async function post(plate) { 123 | process.stdout.write(`Posting plate "${plate.text}"... `) 124 | 125 | let notification = await moderation.notify(plate) 126 | let urls = {} 127 | 128 | for (let [_, service] of Object.entries(services)) { 129 | try { 130 | urls[service.name] = await service.post(plate) 131 | } catch (e) { 132 | urls[service.name] = ` Service had an error. Error: \`${e.toString()}\` ` 133 | } 134 | 135 | await moderation.updateNotification(notification, plate, urls, Object.keys(urls).length == Object.keys(services).length) 136 | } 137 | 138 | removePlate(plate) 139 | 140 | process.stdout.write("posted!\n") 141 | } 142 | 143 | async function getPlate() { 144 | let fileName = `./data/tmp/${crypto.randomBytes(16).toString("hex")}.png` 145 | let index = Math.floor(Math.random() * records.length) 146 | let plate = records[index] 147 | 148 | await drawPlateImage(plate.text, fileName) 149 | 150 | return { 151 | "index": index, 152 | "fileName": path.resolve(fileName), 153 | "text": plate.text, 154 | "customerComment": plate.customerComment, 155 | "dmvComment": plate.dmvComment, 156 | "verdict": plate.verdict 157 | } 158 | } 159 | 160 | function removePlateFromRecords(plate) { 161 | records.splice(plate.index, 1) 162 | fs.writeFileSync("./data/records.json", JSON.stringify(records)) 163 | } 164 | 165 | function removePlate(plate) { 166 | // fs.unlinkSync(plate.fileName) 167 | 168 | let posted = JSON.parse(fs.readFileSync("./data/posted.json")) 169 | posted.push(plate) 170 | fs.writeFileSync("./data/posted.json", JSON.stringify(posted)) 171 | } 172 | 173 | function drawPlateImage(text, fileName) { 174 | return new Promise((resolve) => { 175 | let plate = gm(1280, 720, "#FFFFFFFF") 176 | 177 | // Draw license plate text 178 | plate.fill("#1F2A64") 179 | plate.font(path.join(__dirname, "resources", "fonts", "licenseplate.ttf"), 240) 180 | plate.drawText(0, 80, text, "center").raise(10, 190) 181 | 182 | // Draw watermark 183 | plate.fill("#00000032") 184 | plate.font(path.join(__dirname, "resources", "fonts", "cascadiacode.ttf"), 20) 185 | plate.drawText(25, 25, repositoryURL, "southeast") 186 | 187 | /** 188 | * Overlay this image with the license plate template 189 | * The way I do this is stupid, but unless gm.composite can accept a stream then this will have to do. 190 | */ 191 | 192 | let overlayFileName = path.join(__dirname, "data", "tmp", `${path.basename(fileName).replace(/\.[^/.]+$/, "")}_overlay.png`) 193 | 194 | plate.write(overlayFileName, (error) => { 195 | if (error) { 196 | throw error 197 | } 198 | 199 | plate = gm("./resources/template.png").composite(overlayFileName) 200 | plate.write(fileName, (error) => { 201 | if (error) { 202 | throw error 203 | } 204 | 205 | fs.rmSync(overlayFileName) 206 | resolve() 207 | }) 208 | }) 209 | }) 210 | } 211 | 212 | async function updateBio() { 213 | let percentage = (((totalSourceRecords - records.length) / totalSourceRecords) * 100).toFixed(2) 214 | 215 | for (let [_, service] of Object.entries(services)) { 216 | try { 217 | await service.updateBio(util.format(formats.bio, percentage)) 218 | } catch (e) { 219 | console.log(`Service "${service.name}" had an error while updating the account bio. Error: ${e.toString}`) 220 | } 221 | } 222 | } 223 | 224 | function formatAltText(text) { 225 | let formattedText = "" 226 | 227 | for (let i = 0; i < text.length; i++) { 228 | if (text[i] == "/") { 229 | formattedText += " " 230 | continue 231 | } 232 | 233 | if (symbols[text[i]]) { 234 | formattedText += ` (${symbols[text[i]]}) ` 235 | continue 236 | } 237 | 238 | formattedText += text[i] 239 | } 240 | 241 | formattedText = formattedText.trim() 242 | return util.format(formats.altText, formattedText) 243 | } 244 | 245 | export default { repositoryURL, formats, initialize, post, getPlate, removePlate, removePlateFromRecords, updateBio, formatAltText } -------------------------------------------------------------------------------- /moderation.js: -------------------------------------------------------------------------------- 1 | import { 2 | ActionRowBuilder, 3 | AttachmentBuilder, 4 | ButtonBuilder, 5 | ButtonStyle, 6 | Client, 7 | Events, 8 | GatewayIntentBits, 9 | REST, 10 | Routes, 11 | SlashCommandBuilder 12 | } from "discord.js" 13 | 14 | import fs from "fs-extra" 15 | import util from "node:util" 16 | 17 | import app from "./app.js" 18 | import bot from "./bot.js" 19 | 20 | var client = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers, GatewayIntentBits.GuildMessages] }) 21 | var channel 22 | var moderatorRoleId 23 | var ownerUserId 24 | 25 | function initialize(credentials) { 26 | moderatorRoleId = credentials.moderatorRoleId 27 | ownerUserId = credentials.ownerUserId 28 | 29 | return new Promise((resolve) => { 30 | client.login(credentials.token) 31 | 32 | client.once(Events.ClientReady, async () => { 33 | await deployCommands(credentials.token) 34 | 35 | console.log(`Logged into Discord as "${client.user.tag}" (${client.user.id})`) 36 | channel = client.channels.cache.find(channel => channel.id == credentials.channelId) 37 | 38 | resolve() 39 | }) 40 | 41 | client.on(Events.InteractionCreate, async (interaction) => { 42 | if (!interaction.isChatInputCommand() || !interactionFilter(interaction)) { 43 | return 44 | } 45 | 46 | let queue = app.getQueue() 47 | 48 | switch (interaction.commandName) { 49 | case "ping": 50 | await interaction.deferReply({ ephemeral: true }) 51 | await interaction.editReply("Pong!") 52 | break 53 | case "bio": 54 | if (interaction.user.id != ownerUserId) { 55 | await interaction.deferReply({ ephemeral: true }) 56 | await interaction.editReply("You are not authorized to use this command.") 57 | return 58 | } 59 | 60 | await interaction.deferReply({ ephemeral: true }) 61 | await bot.updateBio() 62 | await interaction.editReply("Refreshed bio!") 63 | 64 | break 65 | case "post": 66 | if (interaction.user.id != ownerUserId) { 67 | await interaction.deferReply({ ephemeral: true }) 68 | await interaction.editReply("You are not authorized to use this command.") 69 | return 70 | } 71 | 72 | await interaction.deferReply({ ephemeral: true }) 73 | await interaction.editReply("Posting plate...") 74 | 75 | if (queue.length == 0) { 76 | await interaction.editReply("There is no plate to post - please review some plates first.") 77 | return 78 | } 79 | 80 | await bot.post(queue.pop()) 81 | fs.writeFileSync("./data/queue.json", JSON.stringify(queue)) 82 | 83 | updateStatus(queue.length) 84 | await notifyQueueAmount(queue.length) 85 | 86 | await interaction.editReply("Posted plate!") 87 | 88 | break 89 | case "review": 90 | await startReviewProcessForUser(interaction) 91 | 92 | break 93 | case "queue": 94 | await interaction.deferReply({ ephemeral: true }) 95 | 96 | queue = queue.map(plate => `\`${plate.text}\``) 97 | 98 | await interaction.editReply(queue.length == 0 ? "There are no plates in the queue." : `There are **${queue.length}** plate(s) left to be posted, and they are (from first to last): ${queue.reverse().join(", ")}.`) 99 | 100 | break 101 | } 102 | }) 103 | }) 104 | } 105 | 106 | async function deployCommands(token) { 107 | let rest = new REST({ version: "10" }).setToken(token) 108 | let commands = [ 109 | new SlashCommandBuilder().setName("ping").setDescription("Replies with pong!").toJSON(), 110 | new SlashCommandBuilder().setName("post").setDescription("Manually posts the next plate in queue").toJSON(), 111 | new SlashCommandBuilder().setName("bio").setDescription("Updates the bot's bio").toJSON(), 112 | new SlashCommandBuilder().setName("review").setDescription("Review some plates").toJSON(), 113 | new SlashCommandBuilder().setName("queue").setDescription("Returns the plates in the queue").toJSON() 114 | ] 115 | 116 | await rest.put( 117 | Routes.applicationCommands(client.user.id), 118 | { body: commands } 119 | ) 120 | } 121 | 122 | async function interactionFilter(interaction) { 123 | let member = channel.guild.members.cache.get(interaction.user.id) 124 | 125 | return !member.bot && member.roles.cache.has(moderatorRoleId) 126 | } 127 | 128 | function process() { 129 | return new Promise(async (resolve) => { 130 | let buttons = new ActionRowBuilder() 131 | .addComponents( 132 | new ButtonBuilder() 133 | .setLabel("Let me review some plates") 134 | .setStyle(ButtonStyle.Primary) 135 | .setCustomId("review") 136 | .setDisabled(false) 137 | ) 138 | 139 | let message = await channel.send({ 140 | components: [ buttons ], 141 | content: `<@&${moderatorRoleId}> The queue is empty and new plates need to be reviewed!` 142 | }) 143 | 144 | let opportunist = null 145 | let collector = message.createMessageComponentCollector({ interactionFilter, time: 60 * 60 * 24 * 1000 }) 146 | collector.on("collect", async (interaction) => { 147 | if (opportunist !== null) { 148 | return 149 | } 150 | 151 | opportunist = channel.guild.members.cache.get(interaction.user.id) 152 | await message.edit({ 153 | components: [], 154 | content: `~~${message.content}~~ <@${opportunist.id}> took the opportunity.` 155 | }) 156 | 157 | let plates = await startReviewProcessForUser(interaction) 158 | resolve(plates) 159 | }) 160 | }) 161 | } 162 | 163 | async function startReviewProcessForUser(interaction) { 164 | let approvedPlates = [] 165 | let isReviewing = true 166 | let tag = interaction.user.tag 167 | 168 | console.log(`"${tag}" started reviewing plates.`) 169 | 170 | await interaction.deferReply({ ephemeral: true }) 171 | 172 | while (isReviewing) { 173 | await new Promise(async (resolve) => { 174 | let plate = await bot.getPlate() 175 | bot.removePlateFromRecords(plate) 176 | 177 | let buttons = new ActionRowBuilder() 178 | .addComponents( 179 | new ButtonBuilder() 180 | .setLabel("Approve") 181 | .setStyle(ButtonStyle.Primary) 182 | .setCustomId("approve") 183 | .setDisabled(false) 184 | ) 185 | .addComponents( 186 | new ButtonBuilder() 187 | .setLabel("Disapprove") 188 | .setStyle(ButtonStyle.Danger) 189 | .setCustomId("disapprove") 190 | .setDisabled(false) 191 | ) 192 | .addComponents( 193 | new ButtonBuilder() 194 | .setLabel("I'm finished reviewing plates") 195 | .setStyle(ButtonStyle.Secondary) 196 | .setCustomId("finished") 197 | .setDisabled(approvedPlates.length == 0) 198 | ) 199 | 200 | let examplePostText = util.format(bot.formats.post, plate.customerComment, plate.dmvComment, plate.verdict ? "ACCEPTED" : "DENIED") 201 | 202 | let message = await interaction.editReply({ 203 | files: [ new AttachmentBuilder(plate.fileName) ], 204 | components: [ buttons ], 205 | content: `Click the appropriate button to approve or disapprove this plate (\`${plate.text}\`). Please refer to the pins for moderation guidelines. This message will time out in **5 minutes**.\n\`\`\`${examplePostText}\`\`\``, 206 | ephemeral: true 207 | }) 208 | 209 | let filter = async (response) => { 210 | let validIds = ["approve", "disapprove", "finished"] 211 | return response.user.tag == tag && validIds.includes(response.customId) 212 | } 213 | 214 | let collector = message.createMessageComponentCollector({ filter, time: 60 * 5 * 1000 }) 215 | 216 | collector.on("collect", async (response) => { 217 | await interaction.editReply({ 218 | "components": [], 219 | "files": [] 220 | }) 221 | 222 | switch (response.customId) { 223 | case "approve": 224 | console.log(`"${tag}" approved plate "${plate.text}".`) 225 | app.addPlatesToQueue([plate]) 226 | approvedPlates.push(plate) 227 | updateStatus(app.getQueue().length) 228 | await interaction.editReply(`**Approved \`${plate.text}\`.** Fetching next plate...`) 229 | 230 | break 231 | case "disapprove": 232 | console.log(`"${tag}" disapproved plate "${plate.text}".`) 233 | bot.removePlate(plate) 234 | await interaction.editReply(`**Disapproved \`${plate.text}\`.** Fetching next plate...`) 235 | 236 | break 237 | case "finished": 238 | console.log(`"${tag}" stopped reviewing plates.`) 239 | isReviewing = false 240 | await interaction.editReply(`Stopped reviewing plates. You approved **${approvedPlates.length} plate(s).** You may always enter the command \`/review\` at any time to restart the review process and \`/queue\` to see all plates in queue to be posted.`) 241 | 242 | break 243 | } 244 | 245 | response.deferUpdate({ ephemeral: true }) 246 | collector.stop() 247 | resolve() 248 | }) 249 | 250 | collector.on("end", async (collected) => { 251 | if (!collected.size) { 252 | await interaction.editReply({ 253 | components: [], 254 | files: [], 255 | content: `Stopped reviewing plates (timed out). You approved **${approvedPlates.length} plate(s).** You may always enter the command \`/review\` at any time to restart the review process.` 256 | }) 257 | 258 | isReviewing = false 259 | 260 | collector.stop() 261 | resolve() 262 | } 263 | }) 264 | }) 265 | } 266 | 267 | return approvedPlates 268 | } 269 | 270 | async function notify(plate) { 271 | return await channel.send(`Posting plate \`${plate.text}\`...`) 272 | } 273 | 274 | async function updateNotification(notification, plate, urls, finished) { 275 | let body = `Posting plate \`${plate.text}\`...${finished ? " finished!" : ""}\n` 276 | 277 | for (let [service, url] of Object.entries(urls)) { 278 | body += `**${service}:** <${url}>\n` 279 | } 280 | 281 | await notification.edit(body) 282 | } 283 | 284 | async function notifyQueueAmount(queueAmount) { 285 | await channel.send(`There are **${queueAmount}** plate(s) left in the queue.`) 286 | 287 | if (queueAmount == 0) { 288 | await process() 289 | } 290 | } 291 | 292 | function updateStatus(queueAmount) { 293 | client.user.setPresence({ activities: [{ name: `${queueAmount} plate(s) left to be posted` }] }); 294 | } 295 | 296 | export default { initialize, process, notify, updateNotification, notifyQueueAmount, updateStatus } --------------------------------------------------------------------------------