[
  {
    "path": ".github/workflows/docker.yml",
    "content": "name: Docker build & push image\n\non:\n  push:\n    branches: [ master ]\n  workflow_dispatch:\n\n\njobs:\n  docker:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v1\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v1\n\n      - name: Login to DockerHub\n        uses: docker/login-action@v1\n        with:\n          registry: ghcr.io\n          username: ${{ github.repository_owner }}\n          password: ${{ secrets.CR_PAT }}\n\n      - name: Build and push\n        id: docker_build\n        uses: docker/build-push-action@v2\n        with:\n          push: true\n          tags: ghcr.io/placenl/placenl-bot:latest\n          platforms: linux/amd64,linux/arm64,linux/arm/v7\n"
  },
  {
    "path": ".gitignore",
    "content": "node_modules/\n.DS_Store\nyarn.lock\n"
  },
  {
    "path": "Dockerfile",
    "content": "FROM node:17-alpine\nWORKDIR /usr/src/app\nENV PREFIX docker\nCOPY package*.json ./\nRUN npm ci\nCOPY bot.js .\nUSER node\nENTRYPOINT [\"node\", \"bot.js\"]\n"
  },
  {
    "path": "README.md",
    "content": "**NEDERLANDSE VERSIE ONDERAAN DE PAGINA**\n\n# PlaceNL Bot (English)\n\nThe bot for PlaceNL and their allies! This bot connects with the [command server](https://github.com/PlaceNL/Commando) and gets it's orders from there. You can see the orderhistory [here](https://placenl.noahvdaa.me/).\n\n## User script bot\n\n### Installation instructions\n\nbefore you start, make sure your cooldown has run out!\n\n1. Install the [Tampermonkey](https://www.tampermonkey.net/) browserextention.\n2. Click on this link: [https://github.com/PlaceNL/Bot/raw/master/placenlbot.user.js](https://github.com/PlaceNL/Bot/raw/master/placenlbot.user.js). If everything went well you'll see Tampermonkey ask you to add it. Click **Install**.\n3. Reload your **r/place** tab. If everything went well, you'll see \"Accesstoken ophalen...\" in the top right of your screen. The bot is now active, You'll be able to see what the bot is doing through these messages.\n\n### Cons of this bot\n\n- When the bot places a pixel, it will look as if it wasn't placed, while the bot has already done that (and thus you're in cooldown). You can see the cooldown in the topright of your screen.\n\n## Headless bot\n\n### How to get reddit_session cookie\n**NOTE: People have reported that this is annoying to do on chrome because teksts get unselected. Therefore we recommend that you use firefox.**\n\n1. Go to [r/place](https://reddit.com/r/place)\n2. Open dev tools and go to the network tab\n3. Refresh the page\n4. Click on the first request to reddit.com/r/place (See image)\n![Screenshot_20220403_165251](https://user-images.githubusercontent.com/9784257/161433856-27ef7e7c-7f00-4b37-b274-4199ea919aa9.png)\n5. Go to the tab called `Cookies`\n6. Copy the value of the `reddit_session` cookie\n\n### Installation instructions\n\n1. Install [NodeJS](https://nodejs.org/).\n2. Download the bot via [this link](https://github.com/PlaceNL/Bot/archive/refs/heads/master.zip).\n3. Extract the bot anywhere on your desktop\n4. Open a command prompt/terminal in this folder\n    Windows: Shift+right mousebutton in the folder -> Click on \"open Powershell here\"\n    \n    Mac: No clue, sorry!\n    \n    Linux: Is this necessary?\n5. install the dependencies: `npm i`\n6. execute the bot `node bot.js SESSION_COOKIE_HERE`\n7. BONUS: You can repeat these steps for any amount of accounts you'd want. Keep in mind to use different accounts.\n\n# Docker alternative\n\nThis option is mostly useful for people who are already using docker.\n\nIt has been confirmed to run on x64(average desktop computer) and armv7(raspberry pi), but it should also be able to run on arm64(new apple computers).\n\n1. Install [Docker](https://docs.docker.com/get-docker/)\n2. Run this command: `docker run --pull=always --restart unless-stopped -it ghcr.io/placenl/placenl-bot SESSION_COOKIE_HERE`\n\n-----\n\n# PlaceNL Bot\n\nDe bot voor PlaceNL! Deze bot verbindt met de [commando server](https://github.com/PlaceNL/Commando) en krijgt daar order van. De ordergeschiedenis kan je [hier](https://placenl.noahvdaa.me/) bekijken.\n\n## User script bot\n\n### Installatieinstructies\n\nVoordat je begint, zorg dat je pixel wachttijd is verlopen!\n\n1. Installeer de [Tampermonkey](https://www.tampermonkey.net/) browserextensie.\n2. Klik op deze link: [https://github.com/PlaceNL/Bot/raw/master/placenlbot.user.js](https://github.com/PlaceNL/Bot/raw/master/placenlbot.user.js). Als het goed is zal Tampermonkey je moeten aanbieden om een userscript te installeren. Klik op **Install**.\n3. Herlaad je **r/place** tabblad. Als alles goed is gegaan, zie je \"Accesstoken ophalen...\" rechtsbovenin je scherm. De bot is nu actief, en zal je via deze meldingen rechtsbovenin je scherm op de hoogte houden van wat 'ie doet.\n\n### Nadelen van deze bot\n\n- Wanneer de bot een pixel plaatst, ziet het er voor jezelf uit alsof je nog steeds een pixel kunt plaatsen, terwijl de bot dit al voor je heeft gedaan (en je dus in de 5 minuten cooldown zit). De cooldown wordt daarom rechtsbovenin je scherm weergegeven.\n\n## Headless bot\n\n### Je sessie cookie verkrijgen\n**NOTE: People have reported that this is annoying to do on chrome because teksts get unselected. Therefore we recommend that you use firefox.**\n\n**NOTE: Mensen hebben ons verteld dat dit process vervelend is op chrome, hierom raden wij firefox aan.**\n\n1. Ga naar [r/place](https://reddit.com/r/place)\n2. Open Element inspecteren/F12 en ga naar het tabje netwerk\n3. Herlaad de pagina\n4. Click op de eerste request naar reddit.com/r/place (Zie afbeelding)\n![Screenshot_20220403_165251](https://user-images.githubusercontent.com/9784257/161433856-27ef7e7c-7f00-4b37-b274-4199ea919aa9.png)\n5. Ga naar het tabje cookies\n6. Kopieer de waarde van `reddit_session`\n\n### Installatieinstructies\n\n1. Installeer [NodeJS](https://nodejs.org/).\n2. Download de bot via [deze link](https://github.com/PlaceNL/Bot/archive/refs/heads/master.zip).\n3. Pak de bot uit naar een folder ergens op je computer.\n4. Open een command prompt/terminal in deze folder\n    Windows: Shift+Rechter muis knop in de folder -> Click op \"Powershell hier openen\"\n    Mac: Echt geen idee. Sorry!\n    Linux: Niet echt nodig toch?\n5. Installeer de nodige depdendencies met `npm i`\n6. Voor de bot uit met `node bot.js SESSIE_COOKIE_HIER`\n7. BONUS: Je kunt de laatse twee stappen zo vaak doen als je wil voor extra accounts. Let wel op dat je andere accounts gebruikt anders heeft het niet heel veel zin.\n\n# Docker alternatief\n\nDit alternatief is vooral geschikt voor iedereen die al docker gebruikt.\n\nHet is bevestigd dat het op x64(gemiddelde desktopcomputer) en armv7(raspberry pi) draait, maar het zou ook op arm64(nieuwe Apple-computers) moeten kunnen draaien.\n\n1. Installeer [Docker](https://docs.docker.com/get-docker/)\n2. Start dit command: `docker run --pull=always --restart unless-stopped -it ghcr.io/placenl/placenl-bot SESSIE_COOKIE_HIER`\n"
  },
  {
    "path": "bot.js",
    "content": "import fetch from 'node-fetch';\nimport getPixels from \"get-pixels\";\nimport WebSocket from 'ws';\n\nconst PREFIX = process.env.PREFIX || \"simple\"\nconst VERSION_NUMBER = 11;\n\nconsole.log(`PlaceNL headless client V${VERSION_NUMBER}`);\n\nconst args = process.argv.slice(2);\n\n//if (args.length != 1 && !process.env.ACCESS_TOKEN) {\n//    console.error(\"Missing access token.\")\n//    process.exit(1);\n//}\nif (args.length != 1 && !process.env.REDDIT_SESSION) {\n    console.error(\"Missing reddit_session cookie.\")\n    process.exit(1);\n}\n\nlet redditSessionCookies = (process.env.REDDIT_SESSION || args[0]).split(';');\n\nvar hasTokens = false;\n\nlet accessTokenHolders = [];\nlet defaultAccessToken;\n\nif (redditSessionCookies.length > 4) {\n    console.warn(\"Meer dan 4 reddit accounts per IP addres wordt niet geadviseerd!\")\n}\n\nvar socket;\nvar currentOrders;\nvar currentOrderList;\n\nconst COLOR_MAPPINGS = {\n    '#6D001A': 0,\n    '#BE0039': 1,\n    '#FF4500': 2,\n    '#FFA800': 3,\n    '#FFD635': 4,\n    '#FFF8B8': 5,\n    '#00A368': 6,\n    '#00CC78': 7,\n    '#7EED56': 8,\n    '#00756F': 9,\n    '#009EAA': 10,\n    '#00CCC0': 11,\n    '#2450A4': 12,\n    '#3690EA': 13,\n    '#51E9F4': 14,\n    '#493AC1': 15,\n    '#6A5CFF': 16,\n    '#94B3FF': 17,\n    '#811E9F': 18,\n    '#B44AC0': 19,\n    '#E4ABFF': 20,\n    '#DE107F': 21,\n    '#FF3881': 22,\n    '#FF99AA': 23,\n    '#6D482F': 24,\n    '#9C6926': 25,\n    '#FFB470': 26,\n    '#000000': 27,\n    '#515252': 28,\n    '#898D90': 29,\n    '#D4D7D9': 30,\n    '#FFFFFF': 31\n};\n\nlet USER_AGENTS = [\n    \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.84 Safari/537.36\",\n    \"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:99.0) Gecko/20100101 Firefox/99.0\",\n    \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.60 Safari/537.36 Edg/100.0.1185.29\",\n    \"Mozilla/5.0 (Macintosh; Intel Mac OS X 12_3_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.3 Safari/605.1.15\",\n    \"Mozilla/5.0 (Macintosh; Intel Mac OS X 12.3; rv:98.0) Gecko/20100101 Firefox/98.0\",\n    \"Mozilla/5.0 (Macintosh; Intel Mac OS X 12_3_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.60 Safari/537.36\",\n    \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.60 Safari/537.36\",\n    \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.3 Safari/605.1.15\",\n    \"Mozilla/5.0 (X11; Linux x86_64; rv:98.0) Gecko/20100101 Firefox/98.0\",\n    \"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:98.0) Gecko/20100101 Firefox/98.0\",\n    \"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.60 Safari/537.36\",\n    \"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.141 Safari/537.36\",\n    \"Mozilla/5.0 (Macintosh; Intel Mac OS X 12_3_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.60 Safari/537.36 Edg/99.0.1150.36\"\n];\n\nlet CHOSEN_AGENT = USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)];\n\nlet rgbaJoinH = (a1, a2, rowSize = 1000, cellSize = 4) => {\n    const rawRowSize = rowSize * cellSize;\n    const rows = a1.length / rawRowSize;\n    let result = new Uint8Array(a1.length + a2.length);\n    for (var row = 0; row < rows; row++) {\n        result.set(a1.slice(rawRowSize * row, rawRowSize * (row + 1)), rawRowSize * 2 * row);\n        result.set(a2.slice(rawRowSize * row, rawRowSize * (row + 1)), rawRowSize * (2 * row + 1));\n    }\n    return result;\n};\n\nlet rgbaJoinV = (a1, a2, rowSize = 2000, cellSize = 4) => {\n    let result = new Uint8Array(a1.length + a2.length);\n\n    const rawRowSize = rowSize * cellSize;\n\n    const rows1 = a1.length / rawRowSize;\n\n    for (var row = 0; row < rows1; row++) {\n        result.set(a1.slice(rawRowSize * row, rawRowSize * (row + 1)), rawRowSize * row);\n    }\n\n    const rows2 = a2.length / rawRowSize;\n\n    for (var row = 0; row < rows2; row++) {\n        result.set(a2.slice(rawRowSize * row, rawRowSize * (row + 1)), (rawRowSize * row) + a1.length);\n    }\n\n    return result;\n};\n\nlet getRealWork = rgbaOrder => {\n    let order = [];\n    for (var i = 0; i < 4000000; i++) {\n        if (rgbaOrder[(i * 4) + 3] !== 0) {\n            order.push(i);\n        }\n    }\n    return order;\n};\n\nlet getPendingWork = (work, rgbaOrder, rgbaCanvas) => {\n    let pendingWork = [];\n    for (const i of work) {\n        if (rgbaOrderToHex(i, rgbaOrder) !== rgbaOrderToHex(i, rgbaCanvas)) {\n            pendingWork.push(i);\n        }\n    }\n    return pendingWork;\n};\n\n(async function () {\n    refreshTokens();\n    connectSocket();\n\n    startPlacement();\n\n    setInterval(() => {\n        if (socket && socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: 'ping' }));\n    }, 5000);\n    // Refresh de tokens elke 30 minuten. Moet genoeg zijn toch.\n    setInterval(refreshTokens, 30 * 60 * 1000);\n})();\n\nfunction startPlacement() {\n    if (!hasTokens) {\n        // Probeer over een seconde opnieuw.\n        setTimeout(startPlacement, 10000);\n        return\n    }\n\n    // Try to stagger pixel placement\n    const interval = 300 / accessTokenHolders.length;\n    var delay = 0;\n    for (const accessTokenHolder of accessTokenHolders) {\n        setTimeout(() => attemptPlace(accessTokenHolder), delay * 1000);\n        delay += interval;\n    }\n}\n\nasync function refreshTokens() {\n    if (accessTokenHolders.length === 0) {\n        for (const _ of redditSessionCookies) {\n            accessTokenHolders.push({});\n        }\n    }\n\n    let tokens = [];\n    for (const cookie of redditSessionCookies) {\n        const response = await fetch(\"https://www.reddit.com/r/place/\", {\n            headers: {\n                cookie: `reddit_session=${cookie}`\n            }\n        });\n        const responseText = await response.text()\n\n        let token = responseText.split('\\\"accessToken\\\":\\\"')[1].split('\"')[0];\n        tokens.push(token);\n    }\n\n    console.log(\"Refreshed tokens: \", tokens)\n    tokens.forEach((token, idx) => {\n        accessTokenHolders[idx].token = token;\n    });\n    defaultAccessToken = tokens[0];\n    hasTokens = true;\n}\n\nfunction connectSocket() {\n    console.log('Verbinden met PlaceNL server...')\n\n    socket = new WebSocket('wss://placenl.noahvdaa.me/api/ws');\n\n    socket.onerror = function (e) {\n        console.error(\"Socket error: \" + e.message)\n    }\n\n    socket.onopen = function () {\n        console.log('Verbonden met PlaceNL server!')\n        socket.send(JSON.stringify({ type: 'getmap' }));\n        socket.send(JSON.stringify({ type: 'brand', brand: `nodeheadless-${PREFIX}-V${VERSION_NUMBER}` }));\n    };\n\n    socket.onmessage = async function (message) {\n        var data;\n        try {\n            data = JSON.parse(message.data);\n        } catch (e) {\n            return;\n        }\n\n        switch (data.type.toLowerCase()) {\n            case 'map':\n                console.log(`Nieuwe map geladen (reden: ${data.reason ? data.reason : 'verbonden met server'})`)\n                currentOrders = await getMapFromUrl(`https://placenl.noahvdaa.me/maps/${data.data}`);\n                currentOrderList = getRealWork(currentOrders.data);\n                break;\n            default:\n                break;\n        }\n    };\n\n    socket.onclose = function (e) {\n        console.warn(`PlaceNL server heeft de verbinding verbroken: ${e.reason}`)\n        console.error('Socketfout: ', e.reason);\n        socket.close();\n        setTimeout(connectSocket, 1000);\n    };\n}\n\nasync function attemptPlace(accessTokenHolder) {\n    let retry = () => attemptPlace(accessTokenHolder);\n    if (currentOrderList === undefined) {\n        setTimeout(retry, 10000); // probeer opnieuw in 10sec.\n        return;\n    }\n\n    var map0;\n    var map1;\n    var map2;\n    var map3;\n    try {\n        map0 = await getMapFromUrl(await getCurrentImageUrl('0'));\n        map1 = await getMapFromUrl(await getCurrentImageUrl('1'));\n        map2 = await getMapFromUrl(await getCurrentImageUrl('2'));\n        map3 = await getMapFromUrl(await getCurrentImageUrl('3'));\n    } catch (e) {\n        console.warn('Fout bij ophalen map: ', e);\n        setTimeout(retry, 15000); // probeer opnieuw in 15sec.\n        return;\n    }\n\n    const rgbaOrder = currentOrders.data;\n    const rgbaCanvasH0 = rgbaJoinH(map0.data, map1.data);\n    const rgbaCanvasH1 = rgbaJoinH(map2.data, map3.data);\n    const rgbaCanvas = rgbaJoinV(rgbaCanvasH0, rgbaCanvasH1);\n    const work = getPendingWork(currentOrderList, rgbaOrder, rgbaCanvas);\n\n    if (work.length === 0) {\n        console.log(`Alle pixels staan al op de goede plaats! Opnieuw proberen in 30 sec...`);\n        setTimeout(retry, 30000); // probeer opnieuw in 30sec.\n        return;\n    }\n\n    const percentComplete = 100 - Math.ceil(work.length * 100 / currentOrderList.length);\n    const workRemaining = work.length;\n    const idx = Math.floor(Math.random() * work.length);\n    const i = work[idx];\n    const x = i % 2000;\n    const y = Math.floor(i / 2000);\n    const hex = rgbaOrderToHex(i, rgbaOrder);\n\n    console.log(`Proberen pixel te plaatsen op ${x}, ${y}... (${percentComplete}% compleet, nog ${workRemaining} over)`);\n\n    const res = await place(x, y, COLOR_MAPPINGS[hex], accessTokenHolder.token);\n    const data = await res.json();\n    try {\n        if (data.error || data.errors) {\n            const error = data.error || data.errors[0];\n            if (error.extensions && error.extensions.nextAvailablePixelTs) {\n                const nextPixel = error.extensions.nextAvailablePixelTs + 3000;\n                const nextPixelDate = new Date(nextPixel);\n                const delay = nextPixelDate.getTime() - Date.now();\n                console.log(`Pixel te snel geplaatst! Volgende pixel wordt geplaatst om ${nextPixelDate.toLocaleTimeString()}.`)\n                setTimeout(retry, delay);\n            } else {\n                const message = error.message || error.reason || 'Unknown error';\n                const guidance = message === 'user is not logged in' ? 'Heb je de \"reddit_session\" cookie goed gekopieerd?' : '';\n                console.error(`[!!] Kritieke fout: ${message}. ${guidance}`);\n                console.error(`[!!] Los dit op en herstart het script`);\n            }\n        } else {\n            const nextPixel = data.data.act.data[0].data.nextAvailablePixelTimestamp + 3000 + Math.floor(Math.random() * 10000); // Random tijd toevoegen tussen 0 en 10 sec om detectie te voorkomen en te spreiden na server herstart.\n            const nextPixelDate = new Date(nextPixel);\n            const delay = nextPixelDate.getTime() - Date.now(); \n            console.log(`Pixel geplaatst op ${x}, ${y}! Volgende pixel wordt geplaatst om ${nextPixelDate.toLocaleTimeString()}.`)\n            setTimeout(retry, delay);\n        }\n    } catch (e) {\n        console.warn('Fout bij response analyseren', e);\n        setTimeout(retry, 10000);\n    }\n}\n\nfunction place(x, y, color, accessToken = defaultAccessToken) {\n    socket.send(JSON.stringify({ type: 'placepixel', x, y, color }));\n    return fetch('https://gql-realtime-2.reddit.com/query', {\n        method: 'POST',\n        body: JSON.stringify({\n            'operationName': 'setPixel',\n            'variables': {\n                'input': {\n                    'actionName': 'r/replace:set_pixel',\n                    'PixelMessageData': {\n                        'coordinate': {\n                            'x': x % 1000,\n                            'y': y % 1000\n                        },\n                        'colorIndex': color,\n                        'canvasIndex': getCanvas(x, y)\n                    }\n                }\n            },\n            'query': 'mutation setPixel($input: ActInput!) {\\n  act(input: $input) {\\n    data {\\n      ... on BasicMessage {\\n        id\\n        data {\\n          ... on GetUserCooldownResponseMessageData {\\n            nextAvailablePixelTimestamp\\n            __typename\\n          }\\n          ... on SetPixelResponseMessageData {\\n            timestamp\\n            __typename\\n          }\\n          __typename\\n        }\\n        __typename\\n      }\\n      __typename\\n    }\\n    __typename\\n  }\\n}\\n'\n        }),\n        headers: {\n            'origin': 'https://hot-potato.reddit.com',\n            'referer': 'https://hot-potato.reddit.com/',\n            'apollographql-client-name': 'mona-lisa',\n            'Authorization': `Bearer ${accessToken}`,\n            'Content-Type': 'application/json',\n            'User-Agent': CHOSEN_AGENT\n        }\n    });\n}\n\nasync function getCurrentImageUrl(id = '0') {\n    return new Promise((resolve, reject) => {\n        const ws = new WebSocket('wss://gql-realtime-2.reddit.com/query', 'graphql-ws', {\n            headers: {\n                \"User-Agent\": CHOSEN_AGENT,\n                \"Origin\": \"https://hot-potato.reddit.com\"\n            }\n        });\n\n        ws.onopen = () => {\n            ws.send(JSON.stringify({\n                'type': 'connection_init',\n                'payload': {\n                    'Authorization': `Bearer ${defaultAccessToken}`\n                }\n            }));\n\n            ws.send(JSON.stringify({\n                'id': '1',\n                'type': 'start',\n                'payload': {\n                    'variables': {\n                        'input': {\n                            'channel': {\n                                'teamOwner': 'AFD2022',\n                                'category': 'CANVAS',\n                                'tag': id\n                            }\n                        }\n                    },\n                    'extensions': {},\n                    'operationName': 'replace',\n                    'query': 'subscription replace($input: SubscribeInput!) {\\n  subscribe(input: $input) {\\n    id\\n    ... on BasicMessage {\\n      data {\\n        __typename\\n        ... on FullFrameMessageData {\\n          __typename\\n          name\\n          timestamp\\n        }\\n      }\\n      __typename\\n    }\\n    __typename\\n  }\\n}'\n                }\n            }));\n        };\n\n        ws.onmessage = (message) => {\n            const { data } = message;\n            const parsed = JSON.parse(data);\n\n            if (parsed.type === 'connection_error') {\n                console.error(`[!!] Kon /r/place map niet laden: ${parsed.payload.message}. Is de access token niet meer geldig?`);\n            }\n\n            // TODO: ew\n            if (!parsed.payload || !parsed.payload.data || !parsed.payload.data.subscribe || !parsed.payload.data.subscribe.data) return;\n\n            ws.close();\n            resolve(parsed.payload.data.subscribe.data.name + `?noCache=${Date.now() * Math.random()}`);\n        }\n\n\n        ws.onerror = reject;\n    });\n}\n\nfunction getMapFromUrl(url) {\n    return new Promise((resolve, reject) => {\n        getPixels(url, function (err, pixels) {\n            if (err) {\n                console.log(\"Bad image path\")\n                reject()\n                return\n            }\n            resolve(pixels)\n        })\n    });\n}\n\nfunction getCanvas(x, y) {\n    if (x <= 999) {\n        return y <= 999 ? 0 : 2;\n    } else {\n        return y <= 999 ? 1 : 3;\n    }\n}\n\nfunction rgbToHex(r, g, b) {\n    return '#' + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).toUpperCase();\n}\n\nlet rgbaOrderToHex = (i, rgbaOrder) =>\n    rgbToHex(rgbaOrder[i * 4], rgbaOrder[i * 4 + 1], rgbaOrder[i * 4 + 2]);\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"type\": \"module\",\n  \"dependencies\": {\n    \"get-pixels\": \"^3.3.3\",\n    \"node-fetch\": \"^3.2.3\",\n    \"nodejs-fetch\": \"^1.0.0\",\n    \"ws\": \"^8.5.0\"\n  }\n}\n"
  },
  {
    "path": "placenlbot.user.js",
    "content": "// ==UserScript==\n// @name         PlaceNL Bot\n// @namespace    https://github.com/PlaceNL/Bot\n// @version      26\n// @description  De bot voor PlaceNL!\n// @author       NoahvdAa\n// @match        https://www.reddit.com/r/place/*\n// @match        https://new.reddit.com/r/place/*\n// @connect      reddit.com\n// @connect      placenl.noahvdaa.me\n// @icon         https://www.google.com/s2/favicons?sz=64&domain=reddit.com\n// @require\t     https://cdn.jsdelivr.net/npm/toastify-js\n// @resource     TOASTIFY_CSS https://cdn.jsdelivr.net/npm/toastify-js/src/toastify.min.css\n// @updateURL    https://github.com/PlaceNL/Bot/raw/master/placenlbot.user.js\n// @downloadURL  https://github.com/PlaceNL/Bot/raw/master/placenlbot.user.js\n// @grant        GM_getResourceText\n// @grant        GM_addStyle\n// @grant        GM.xmlHttpRequest\n// ==/UserScript==\n\n// Sorry voor de rommelige code, haast en clean gaatn iet altijd samen ;)\n\nvar socket;\nvar order = undefined;\nvar accessToken;\nvar currentOrderCanvas = document.createElement('canvas');\nvar currentOrderCtx = currentOrderCanvas.getContext('2d');\nvar currentPlaceCanvas = document.createElement('canvas');\n\n// Global constants\nconst DEFAULT_TOAST_DURATION_MS = 10000;\n\nconst COLOR_MAPPINGS = {\n    '#6D001A': 0,\n    '#BE0039': 1,\n    '#FF4500': 2,\n    '#FFA800': 3,\n    '#FFD635': 4,\n    '#FFF8B8': 5,\n    '#00A368': 6,\n    '#00CC78': 7,\n    '#7EED56': 8,\n    '#00756F': 9,\n    '#009EAA': 10,\n    '#00CCC0': 11,\n    '#2450A4': 12,\n    '#3690EA': 13,\n    '#51E9F4': 14,\n    '#493AC1': 15,\n    '#6A5CFF': 16,\n    '#94B3FF': 17,\n    '#811E9F': 18,\n    '#B44AC0': 19,\n    '#E4ABFF': 20,\n    '#DE107F': 21,\n    '#FF3881': 22,\n    '#FF99AA': 23,\n    '#6D482F': 24,\n    '#9C6926': 25,\n    '#FFB470': 26,\n    '#000000': 27,\n    '#515252': 28,\n    '#898D90': 29,\n    '#D4D7D9': 30,\n    '#FFFFFF': 31\n};\n\nlet getRealWork = rgbaOrder => {\n    let order = [];\n    for (var i = 0; i < 4000000; i++) {\n        if (rgbaOrder[(i * 4) + 3] !== 0) {\n            order.push(i);\n        }\n    }\n    return order;\n};\n\nlet getPendingWork = (work, rgbaOrder, rgbaCanvas) => {\n    let pendingWork = [];\n    for (const i of work) {\n        if (rgbaOrderToHex(i, rgbaOrder) !== rgbaOrderToHex(i, rgbaCanvas)) {\n            pendingWork.push(i);\n        }\n    }\n    return pendingWork;\n};\n\n(async function () {\n    GM_addStyle(GM_getResourceText('TOASTIFY_CSS'));\n    currentOrderCanvas.width = 2000;\n    currentOrderCanvas.height = 2000;\n    currentOrderCanvas.style.display = 'none';\n    currentOrderCanvas = document.body.appendChild(currentOrderCanvas);\n    currentPlaceCanvas.width = 2000;\n    currentPlaceCanvas.height = 2000;\n    currentPlaceCanvas.style.display = 'none';\n    currentPlaceCanvas = document.body.appendChild(currentPlaceCanvas);\n\n    Toastify({\n        text: 'Accesstoken ophalen...',\n        duration: DEFAULT_TOAST_DURATION_MS\n    }).showToast();\n    accessToken = await getAccessToken();\n    Toastify({\n        text: 'Accesstoken opgehaald!',\n        duration: DEFAULT_TOAST_DURATION_MS\n    }).showToast();\n\n    connectSocket();\n    attemptPlace();\n\n    setInterval(() => {\n        if (socket && socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: 'ping' }));\n    }, 5000);\n    setInterval(async () => {\n        accessToken = await getAccessToken();\n    }, 30 * 60 * 1000)\n})();\n\nfunction connectSocket() {\n    Toastify({\n        text: 'Verbinden met PlaceNL server...',\n        duration: DEFAULT_TOAST_DURATION_MS\n    }).showToast();\n\n    socket = new WebSocket('wss://placenl.noahvdaa.me/api/ws');\n\n    socket.onopen = function () {\n        Toastify({\n            text: 'Verbonden met PlaceNL server!',\n            duration: DEFAULT_TOAST_DURATION_MS\n        }).showToast();\n        socket.send(JSON.stringify({ type: 'getmap' }));\n        socket.send(JSON.stringify({ type: 'brand', brand: 'userscriptV26' }));\n    };\n\n    socket.onmessage = async function (message) {\n        var data;\n        try {\n            data = JSON.parse(message.data);\n        } catch (e) {\n            return;\n        }\n\n        switch (data.type.toLowerCase()) {\n            case 'map':\n                Toastify({\n                    text: `Nieuwe map laden (reden: ${data.reason ? data.reason : 'verbonden met server'})...`,\n                    duration: DEFAULT_TOAST_DURATION_MS\n                }).showToast();\n                currentOrderCtx = await getCanvasFromUrl(`https://placenl.noahvdaa.me/maps/${data.data}`, currentOrderCanvas, 0, 0, true);\n                order = getRealWork(currentOrderCtx.getImageData(0, 0, 2000, 2000).data);\n                Toastify({\n                    text: `Nieuwe map geladen, ${order.length} pixels in totaal`,\n                    duration: DEFAULT_TOAST_DURATION_MS\n                }).showToast();\n                break;\n            case 'toast':\n                Toastify({\n                    text: `Bericht van server: ${data.message}`,\n                    duration: data.duration || DEFAULT_TOAST_DURATION_MS,\n                    style: data.style || {}\n                }).showToast();\n                break;\n            default:\n                break;\n        }\n    };\n\n    socket.onclose = function (e) {\n        Toastify({\n            text: `PlaceNL server heeft de verbinding verbroken: ${e.reason}`,\n            duration: DEFAULT_TOAST_DURATION_MS\n        }).showToast();\n        console.error('Socketfout: ', e.reason);\n        socket.close();\n        setTimeout(connectSocket, 1000);\n    };\n}\n\nasync function attemptPlace() {\n    if (order === undefined) {\n        setTimeout(attemptPlace, 2000); // probeer opnieuw in 2sec.\n        return;\n    }\n    var ctx;\n    try {\n        ctx = await getCanvasFromUrl(await getCurrentImageUrl('0'), currentPlaceCanvas, 0, 0, false);\n        ctx = await getCanvasFromUrl(await getCurrentImageUrl('1'), currentPlaceCanvas, 1000, 0, false)\n        ctx = await getCanvasFromUrl(await getCurrentImageUrl('2'), currentPlaceCanvas, 0, 1000, false)\n        ctx = await getCanvasFromUrl(await getCurrentImageUrl('3'), currentPlaceCanvas, 1000, 1000, false)\n    } catch (e) {\n        console.warn('Fout bij ophalen map: ', e);\n        Toastify({\n            text: 'Fout bij ophalen map. Opnieuw proberen in 10 sec...',\n            duration: DEFAULT_TOAST_DURATION_MS\n        }).showToast();\n        setTimeout(attemptPlace, 10000); // probeer opnieuw in 10sec.\n        return;\n    }\n\n    const rgbaOrder = currentOrderCtx.getImageData(0, 0, 2000, 2000).data;\n    const rgbaCanvas = ctx.getImageData(0, 0, 2000, 2000).data;\n    const work = getPendingWork(order, rgbaOrder, rgbaCanvas);\n\n    if (work.length === 0) {\n        Toastify({\n            text: `Alle pixels staan al op de goede plaats! Opnieuw proberen in 30 sec...`,\n            duration: 30000\n        }).showToast();\n        setTimeout(attemptPlace, 30000); // probeer opnieuw in 30sec.\n        return;\n    }\n\n    const percentComplete = 100 - Math.ceil(work.length * 100 / order.length);\n    const workRemaining = work.length;\n    const idx = Math.floor(Math.random() * work.length);\n    const i = work[idx];\n    const x = i % 2000;\n    const y = Math.floor(i / 2000);\n    const hex = rgbaOrderToHex(i, rgbaOrder);\n\n    Toastify({\n        text: `Proberen pixel te plaatsen op ${x}, ${y}... (${percentComplete}% compleet, nog ${workRemaining} over)`,\n        duration: DEFAULT_TOAST_DURATION_MS\n    }).showToast();\n\n    const res = await place(x, y, COLOR_MAPPINGS[hex]);\n    const data = await res.json();\n    try {\n        if (data.errors) {\n            const error = data.errors[0];\n            const nextPixel = error.extensions.nextAvailablePixelTs + 3000;\n            const nextPixelDate = new Date(nextPixel);\n            const delay = nextPixelDate.getTime() - Date.now();\n            const toast_duration = delay > 0 ? delay : DEFAULT_TOAST_DURATION_MS;\n            Toastify({\n                text: `Pixel te snel geplaatst! Volgende pixel wordt geplaatst om ${nextPixelDate.toLocaleTimeString()}.`,\n                duration: toast_duration\n            }).showToast();\n            setTimeout(attemptPlace, delay);\n        } else {\n            const nextPixel = data.data.act.data[0].data.nextAvailablePixelTimestamp + 3000 + Math.floor(Math.random() * 10000); // Random tijd toevoegen tussen 0 en 10 sec om detectie te voorkomen en te spreiden na server herstart.\n            const nextPixelDate = new Date(nextPixel);\n            const delay = nextPixelDate.getTime() - Date.now(); \n            const toast_duration = delay > 0 ? delay : DEFAULT_TOAST_DURATION_MS;\n            Toastify({\n                text: `Pixel geplaatst op ${x}, ${y}! Volgende pixel wordt geplaatst om ${nextPixelDate.toLocaleTimeString()}.`,\n                duration: toast_duration\n            }).showToast();\n            setTimeout(attemptPlace, delay);\n        }\n    } catch (e) {\n        console.warn('Fout bij response analyseren', e);\n        Toastify({\n            text: `Fout bij response analyseren: ${e}.`,\n            duration: DEFAULT_TOAST_DURATION_MS\n        }).showToast();\n        setTimeout(attemptPlace, 10000);\n    }\n}\n\nfunction place(x, y, color) {\n    socket.send(JSON.stringify({ type: 'placepixel', x, y, color }));\n    return fetch('https://gql-realtime-2.reddit.com/query', {\n        method: 'POST',\n        body: JSON.stringify({\n            'operationName': 'setPixel',\n            'variables': {\n                'input': {\n                    'actionName': 'r/replace:set_pixel',\n                    'PixelMessageData': {\n                        'coordinate': {\n                            'x': x % 1000,\n                            'y': y % 1000\n                        },\n                        'colorIndex': color,\n                        'canvasIndex': getCanvas(x, y)\n                    }\n                }\n            },\n            'query': 'mutation setPixel($input: ActInput!) {\\n  act(input: $input) {\\n    data {\\n      ... on BasicMessage {\\n        id\\n        data {\\n          ... on GetUserCooldownResponseMessageData {\\n            nextAvailablePixelTimestamp\\n            __typename\\n          }\\n          ... on SetPixelResponseMessageData {\\n            timestamp\\n            __typename\\n          }\\n          __typename\\n        }\\n        __typename\\n      }\\n      __typename\\n    }\\n    __typename\\n  }\\n}\\n'\n        }),\n        headers: {\n            'origin': 'https://hot-potato.reddit.com',\n            'referer': 'https://hot-potato.reddit.com/',\n            'apollographql-client-name': 'mona-lisa',\n            'Authorization': `Bearer ${accessToken}`,\n            'Content-Type': 'application/json'\n        }\n    });\n}\n\nfunction getCanvas(x, y) {\n    if (x <= 999) {\n        return y <= 999 ? 0 : 2;\n    } else {\n        return y <= 999 ? 1 : 3;\n    }\n}\n\nasync function getAccessToken() {\n    const usingOldReddit = window.location.href.includes('new.reddit.com');\n    const url = usingOldReddit ? 'https://new.reddit.com/r/place/' : 'https://www.reddit.com/r/place/';\n    const response = await fetch(url);\n    const responseText = await response.text();\n\n    // TODO: ew\n    return responseText.split('\\\"accessToken\\\":\\\"')[1].split('\"')[0];\n}\n\nasync function getCurrentImageUrl(id = '0') {\n    return new Promise((resolve, reject) => {\n        const ws = new WebSocket('wss://gql-realtime-2.reddit.com/query', 'graphql-ws');\n\n        ws.onopen = () => {\n            ws.send(JSON.stringify({\n                'type': 'connection_init',\n                'payload': {\n                    'Authorization': `Bearer ${accessToken}`\n                }\n            }));\n            ws.send(JSON.stringify({\n                'id': '1',\n                'type': 'start',\n                'payload': {\n                    'variables': {\n                        'input': {\n                            'channel': {\n                                'teamOwner': 'AFD2022',\n                                'category': 'CANVAS',\n                                'tag': id\n                            }\n                        }\n                    },\n                    'extensions': {},\n                    'operationName': 'replace',\n                    'query': 'subscription replace($input: SubscribeInput!) {\\n  subscribe(input: $input) {\\n    id\\n    ... on BasicMessage {\\n      data {\\n        __typename\\n        ... on FullFrameMessageData {\\n          __typename\\n          name\\n          timestamp\\n        }\\n      }\\n      __typename\\n    }\\n    __typename\\n  }\\n}'\n                }\n            }));\n        };\n\n        ws.onmessage = (message) => {\n            const { data } = message;\n            const parsed = JSON.parse(data);\n\n            // TODO: ew\n            if (!parsed.payload || !parsed.payload.data || !parsed.payload.data.subscribe || !parsed.payload.data.subscribe.data) return;\n\n            ws.close();\n            resolve(parsed.payload.data.subscribe.data.name + `?noCache=${Date.now() * Math.random()}`);\n        }\n\n        ws.onerror = reject;\n    });\n}\n\nfunction getCanvasFromUrl(url, canvas, x = 0, y = 0, clearCanvas = false) {\n    return new Promise((resolve, reject) => {\n        let loadImage = ctx => {\n        GM.xmlHttpRequest({\n            method: \"GET\",\n            url: url,\n            responseType: 'blob',\n            onload: function(response) {\n            var urlCreator = window.URL || window.webkitURL;\n            var imageUrl = urlCreator.createObjectURL(this.response);\n            var img = new Image();\n            img.onload = () => {\n                if (clearCanvas) {\n                    ctx.clearRect(0, 0, canvas.width, canvas.height);\n                }\n                ctx.drawImage(img, x, y);\n                resolve(ctx);\n            };\n            img.onerror = () => {\n                Toastify({\n                    text: 'Fout bij ophalen map. Opnieuw proberen in 3 sec...',\n                    duration: 3000\n                }).showToast();\n                setTimeout(() => loadImage(ctx), 3000);\n            };\n            img.src = imageUrl;\n  }\n})\n        };\n        loadImage(canvas.getContext('2d'));\n    });\n}\n\nfunction rgbToHex(r, g, b) {\n    return '#' + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).toUpperCase();\n}\n\nlet rgbaOrderToHex = (i, rgbaOrder) =>\n    rgbToHex(rgbaOrder[i * 4], rgbaOrder[i * 4 + 1], rgbaOrder[i * 4 + 2]);\n"
  }
]