diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0d9bcd5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.env* +!.env.example \ No newline at end of file diff --git a/node/.dockerignore b/node/.dockerignore new file mode 100644 index 0000000..b512c09 --- /dev/null +++ b/node/.dockerignore @@ -0,0 +1 @@ +node_modules \ No newline at end of file diff --git a/node/.env.example b/node/.env.example new file mode 100644 index 0000000..e7cc0d6 --- /dev/null +++ b/node/.env.example @@ -0,0 +1,10 @@ +// 1. öffne die Entwicklerwerkzeuge ([F12] oder [links click, Q]) +# 2. führe folgenden Code aus: 'fetch("https://www.reddit.com/r/place/").then(res => res.text()).then(res => console.log(res.match(/"accessToken":"(\\"|[^"]*)"/)[1]));' +# 3. kopiere Zeichenkette (sowas wie "eyJhbGciOiJSUzI1NiIsImtpZCI6IlNIQTI1Nj...") in zs_accessToken +# WICHTIG: GIB NIEMANDEN DIESE ZEICHENKETTE, DIESER ERLAUBT ZUGRIFF AUF DEINEN ACCOUNT + +# 1. open developer tools ([F12] or [left click, Q]) +# 2. run following code: 'fetch("https://www.reddit.com/r/place/").then(res => res.text()).then(res => console.log(res.match(/"accessToken":"(\\"|[^"]*)"/)[1]));' +# 3. copy string (something like "eyJhbGciOiJSUzI1NiIsImtpZCI6IlNIQTI1Nj...") to zs_accessToken +# IMPORTANT: DONT GIVE THIS STRING TO ANYBODY, IT GIVES ACCESS TO YOUR ACCOUNT +ZN_TOKEN=*** \ No newline at end of file diff --git a/node/Dockerfile b/node/Dockerfile new file mode 100644 index 0000000..7a7234d --- /dev/null +++ b/node/Dockerfile @@ -0,0 +1,16 @@ +FROM node:20.5.0-bullseye-slim + +WORKDIR /app + +COPY package-lock.json . +COPY package.json . +RUN npm install + +COPY . . + +# Modify the code to use the env var +RUN sed -i 's/const zs_accessToken = "";/const zs_accessToken = process.env.ZN_TOKEN;/g' index.js +RUN cat index.js +USER 1000:1000 + +ENTRYPOINT [ "node", "index.js" ] \ No newline at end of file diff --git a/node/README.md b/node/README.md new file mode 100644 index 0000000..aa46fe7 --- /dev/null +++ b/node/README.md @@ -0,0 +1,30 @@ +# Der kopflose Zinnsoldat +Der kopflose Zinnsoldat übernimmt die selben Aufgaben wie der Zinnsoldat, braucht aber keinen offenen Browsertab. + +## Benutzung +Es gibt zwei Möglichkeiten, den Zinnsoldaten zu verwenden: + +### NodeJS + NPM +Für diese Installationsvariante benötigt man lediglich NodeJS und NPM. + +Zuerst muss die `index.js`-Datei mit dem eigenen Account personalisiert werden, danach müssen folgende Kommandos ausgeführt werden: + +```sh +npm install + +# Einfach starten +node index.js + +# Im Hintergrund ausführen +chmod +x ./start.sh +./start.sh +``` + +### Docker +Hierfür wird Docker + Docker-Compose oder vergleichbares benötigt. + +Kopieren Sie zuerst die `.env.example`-Datei in eine `.env`-Datei, und personalisieren Sie diese. Führen Sie danach den folgenden Befehl aus: + +```sh +docker compose up # [-d] für einen Hintergrundprozess +``` diff --git a/node/all.log b/node/all.log new file mode 100755 index 0000000..e69de29 diff --git a/node/docker-compose.yml b/node/docker-compose.yml new file mode 100644 index 0000000..8f7d8b5 --- /dev/null +++ b/node/docker-compose.yml @@ -0,0 +1,6 @@ +version: '3' + +services: + zinnsoldat: + build: . + env_file: .env \ No newline at end of file diff --git a/node/index.js b/node/index.js new file mode 100755 index 0000000..9fcc7c1 --- /dev/null +++ b/node/index.js @@ -0,0 +1,358 @@ +// SOURCE: https://github.com/PlaceDE-Official/zinnsoldat/raw/main/output/placebot.user.js +const WebSocket = require("ws"); + +(async () => { + // ---------------------------------------- + // Basics + // ---------------------------------------- + + // 1. öffne die Entwicklerwerkzeuge ([F12] oder [links click, Q]) + // 2. führe folgenden Code aus: 'fetch("https://www.reddit.com/r/place/").then(res => res.text()).then(res => console.log(res.match(/"accessToken":"(\\"|[^"]*)"/)[1]));' + // 3. kopiere Zeichenkette (sowas wie "eyJhbGciOiJSUzI1NiIsImtpZCI6IlNIQTI1Nj...") in zs_accessToken + // WICHTIG: GIB NIEMANDEN DIESE ZEICHENKETTE, DIESER ERLAUBT ZUGRIFF AUF DEINEN ACCOUNT + + // 1. open developer tools ([F12] or [left click, Q]) + // 2. run following code: 'fetch("https://www.reddit.com/r/place/").then(res => res.text()).then(res => console.log(res.match(/"accessToken":"(\\"|[^"]*)"/)[1]));' + // 3. copy string (something like "eyJhbGciOiJSUzI1NiIsImtpZCI6IlNIQTI1Nj...") to zs_accessToken + // IMPORTANT: DONT GIVE THIS STRING TO ANYBODY, IT GIVES ACCESS TO YOUR ACCOUNT + const zs_accessToken = ""; + + const zs_version = "1.3"; + let c2; + + // ---------------------------------------- + // Toaster + // ---------------------------------------- + + class Toaster { + static log = (conf, msg) => { + console.log(`%s: ${conf}`, new Date().toISOString(), msg); + } + + static info = (msg) => { + this.log("\x1b[37m%s\x1b[0m", msg); + } + + static warn = (msg) => { + this.log("\x1b[33m%s\x1b[0m", msg); + } + + static error = (msg) => { + this.log("\x1b[31m%s\x1b[0m", msg); + } + + static success = (msg) => { + this.log("\x1b[32m%s\x1b[0m", msg); + } + + static place = (msg, x, y) => { + this.info(msg); + } + } + + // ---------------------------------------- + // Timer + // ---------------------------------------- + + // Override setTimeout to allow getting the time left + let placeTimeout; + const _setTimeout = setTimeout; + const _clearTimeout = clearTimeout; + const zs_allTimeouts = {}; + + setTimeout = (callback, delay) => { + let id = _setTimeout(callback, delay); + zs_allTimeouts[id] = Date.now() + delay; + return id; + }; + + clearTimeout = (id) => { + _clearTimeout(id); + zs_allTimeouts[id] = undefined; + } + + // ---------------------------------------- + // Canvas + // ---------------------------------------- + + class Canvas { + static getCanvasId = (x, y) => { + if (y < 0 && x < -500) { + return 0 + } else if (y < 0 && x < 500 && x >= -500) { + return 1; + } else if (y < 0 && x >= 500) { + return 2; + } else if (y >= 0 && x < -500) { + return 3; + } else if (y >= 0 && x < 500 && x >= -500) { + return 4; + } else if (y >= 0 && x >= 500) { + return 5; + } + console.error("Unknown canvas!"); + return 0; + } + + static getCanvasX = (x, y) => { + return Math.abs((x + 1500) % 1000); + } + + static getCanvasY = (x, y) => { + return Canvas.getCanvasId(x, y) < 3 ? y + 1000 : y; + } + + static placePixel = async (x, y, color) => { + // console.log("Trying to place pixel at %s, %s in %s", x, y, color); + const response = await fetch("https://gql-realtime-2.reddit.com/query", { + method: "POST", + body: JSON.stringify({ + "operationName": "setPixel", + "variables": { + "input": { + "actionName": "r/replace:set_pixel", + "PixelMessageData": { + "coordinate": { + "x": Canvas.getCanvasX(x, y), + "y": Canvas.getCanvasY(x, y) + }, + "colorIndex": color, + "canvasIndex": Canvas.getCanvasId(x, y) + } + } + }, + "query": `mutation setPixel($input: ActInput!) { + act(input: $input) { + data { + ... on BasicMessage { + id + data { + ... on GetUserCooldownResponseMessageData { + nextAvailablePixelTimestamp + __typename + } + ... on SetPixelResponseMessageData { + timestamp + __typename + } + __typename + } + __typename + } + __typename + } + __typename + } + } + ` + }), + headers: { + "origin": "https://garlic-bread.reddit.com", + "referer": "https://garlic-bread.reddit.com/", + "apollographql-client-name": "garlic-bread", + "Authorization": `Bearer ${zs_accessToken}`, + "Content-Type": "application/json" + } + }); + const data = await response.json() + if (data.errors !== undefined) { + if (data.errors[0].message === "Ratelimited") { + // console.log("Could not place pixel at %s, %s in %s - Ratelimit", x, y, color); + const timestamp = data.errors[0].extensions?.nextAvailablePixelTs; + const timeLeft = new Date(timestamp - Date.now()) + .toISOString() + .match(/T(.*)Z/)[1]; + Toaster.warn(`Du hast noch Abklingzeit! (${timeLeft})`); + return {status: "Failture", timestamp: timestamp}; + } + // console.log("Could not place pixel at %s, %s in %s - Response error", x, y, color); + // console.error(data.errors); + Toaster.error("Fehler beim Platzieren des Pixels"); + return {status: "Failture", timestamp: null}; + } + // console.log("Did place pixel at %s, %s in %s", x, y, color); + Toaster.place(`Pixel (${x}, ${y}) platziert!`, x, y); + const timestamp = data?.data?.act?.data?.[0]?.data?.nextAvailablePixelTimestamp; + return {status: "Success", timestamp: timestamp}; + } + + static requestCooldown = async () => { + const response = await fetch("https://gql-realtime-2.reddit.com/query", { + method: "POST", + body: JSON.stringify({ + "operationName": "getUserCooldown", + "variables": { + "input": { + "actionName": "r/replace:get_user_cooldown" + } + }, + "query": `mutation getUserCooldown($input: ActInput!) { + act(input: $input) { + data { + ... on BasicMessage { + id + data { + ... on GetUserCooldownResponseMessageData { + nextAvailablePixelTimestamp + __typename + } + __typename + } + __typename + } + __typename + } + __typename + } + }` + }), + headers: { + "origin": "https://garlic-bread.reddit.com", + "referer": "https://garlic-bread.reddit.com/", + "apollographql-client-name": "garlic-bread", + "Authorization": `Bearer ${zs_accessToken}`, + "Content-Type": "application/json" + } + }); + const data = await response.json(); + if (data.errors !== undefined) { + // console.error(data.errors); + return null; + } + const timestamp = data?.data?.act?.data?.[0]?.data?.nextAvailablePixelTimestamp; + if (timestamp) { + const timeLeft = new Date(timestamp - Date.now()) + .toISOString() + .match(/T(.*)Z/)[1]; + Toaster.warn(`Du hast noch Abklingzeit! (${timeLeft})`); + return timestamp; + } + return null; + } + } + + // ---------------------------------------- + // RedditAPI + // ---------------------------------------- + + class RedditApi { + static getAccessToken = async () => { + return zs_accessToken; + } + } + + // ---------------------------------------- + // CarpetBomber + // ---------------------------------------- + + class CarpetBomber { + static getTokens = () => { + return ["Wololo"]; + } + + static requestJob = () => { + if (c2.readyState !== c2.OPEN) { + Toaster.error("Verbindung zum \"Carpetbomber\" abgebrochen. Verbinde..."); + CarpetBomber.initCarpetbomberConnection(); + return; + } + c2.send(JSON.stringify({ type: "RequestJobs", tokens: CarpetBomber.getTokens() })); + } + + static processJobResponse = (jobs) => { + if (!jobs || jobs === {}) { + Toaster.warn("Kein verfügbarer Auftrag. Versuche in 60s erneut"); + clearTimeout(placeTimeout); + placeTimeout = setTimeout(() => { + CarpetBomber.requestJob(); + }, 60000); + return; + } + let [token, [job, code]] = Object.entries(jobs)[0]; + if (!job) { + // Check if ratelimited and schedule retry + const ratelimit = code?.Ratelimited?.until; + if (ratelimit) { + clearTimeout(placeTimeout); + placeTimeout = setTimeout(() => { + CarpetBomber.requestJob(); + }, Math.max(5000, Date.parse(ratelimit) + 2000 - Date.now())); + return; + } + // Other error. No jobs left? + Toaster.warn("Kein verfügbarer Auftrag. Versuche in 20s erneut"); + clearTimeout(placeTimeout); + placeTimeout = setTimeout(() => { + CarpetBomber.requestJob(); + }, 20000); + return; + } + // Execute job + Canvas.placePixel(job.x, job.y, job.color - 1).then((placeResult) => { + const { status, timestamp } = placeResult; + // Replay acknoledgement + const token = CarpetBomber.getTokens()[0]; + c2.send(JSON.stringify({ type: "JobStatusReport", tokens: { [token]: status }})); + // Schedule next job + let nextTry = timestamp ? timestamp - Date.now() : 5*60*1000 + 2000 + Math.floor(Math.random()*8000); + clearTimeout(placeTimeout); + placeTimeout = setTimeout(() => { + CarpetBomber.requestJob(); + }, nextTry); + }); + } + + static startRequestLoop = () => { + Canvas.requestCooldown().then((nextTry) => { + if(nextTry) { + clearTimeout(placeTimeout); + placeTimeout = setTimeout(() => { + CarpetBomber.requestJob(); + }, Math.max(5000, nextTry + 2000 - Date.now())); + return + } + CarpetBomber.requestJob(); + }); + } + + static initCarpetbomberConnection = () => { + c2 = new WebSocket("wss://carpetbomber.place.army"); + + c2.onopen = () => { + Toaster.info("Verbinde mit \"Carpetbomber\"..."); + c2.send(JSON.stringify({ type: "Handshake", version: zs_version })); + setInterval(() => c2.send(JSON.stringify({ type: "Wakeup"})), 40*1000); + } + + c2.onerror = (error) => { + Toaster.error("Verbindung zum \"Carpetbomber\" fehlgeschlagen! Versuche in 5s erneut"); + // console.error(error); + setTimeout(() => { + CarpetBomber.initCarpetbomberConnection(); + }, 5000); + } + + c2.onmessage = (event) => { + const data = JSON.parse(event.data) + // console.log("received: %s", JSON.stringify(data)); + + if (data.type === "UpdateVersion") { + if(!data.version || data.version == "Unsupported") { + Toaster.error("Neue Version verfürgbar!!!"); + return; + } + Toaster.success("Verbindung aufgebaut!"); + CarpetBomber.startRequestLoop(); + } else if (data.type == "Jobs") { + CarpetBomber.processJobResponse(data.jobs); + } + } + } + } + + // ---------------------------------------- + // Run + // ---------------------------------------- + + CarpetBomber.initCarpetbomberConnection(); +})(); \ No newline at end of file diff --git a/node/package-lock.json b/node/package-lock.json new file mode 100644 index 0000000..53dd293 --- /dev/null +++ b/node/package-lock.json @@ -0,0 +1,36 @@ +{ + "name": "zinnsoldat", + "version": "1.3.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "zinnsoldat", + "version": "1.3.0", + "license": "MIT", + "dependencies": { + "ws": ">=8.13.0" + } + }, + "node_modules/ws": { + "version": "8.13.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.13.0.tgz", + "integrity": "sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/node/package.json b/node/package.json new file mode 100644 index 0000000..7cb8f0f --- /dev/null +++ b/node/package.json @@ -0,0 +1,14 @@ +{ + "name": "zinnsoldat", + "version": "1.3.0", + "description": "Lasse den Zinnsoldat für dich kämpfen", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "Play_it", + "license": "MIT", + "dependencies": { + "ws": ">=8.13.0" + } +} diff --git a/node/start.sh b/node/start.sh new file mode 100755 index 0000000..5aaabae --- /dev/null +++ b/node/start.sh @@ -0,0 +1,2 @@ +#!/bin/sh +node ./index.js > ./all.log 2>&1 & \ No newline at end of file diff --git a/node/stop.sh b/node/stop.sh new file mode 100755 index 0000000..bd5fcab --- /dev/null +++ b/node/stop.sh @@ -0,0 +1,2 @@ +#!/bin/sh +kill $(pidof "node") \ No newline at end of file