├── .dockerignore ├── .gitignore ├── .github ├── dependabot.yml └── workflows │ ├── lint.yml │ ├── dockerbuild.yml │ └── codeql-analysis.yml ├── .eslintrc.yml ├── Dockerfile ├── .vscode └── launch.json ├── package.json ├── README.md └── index.js /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | package-lock.json 3 | .env* -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | env: 2 | browser: false 3 | es2021: true 4 | node: true 5 | extends: "eslint:recommended" 6 | parserOptions: 7 | ecmaVersion: 12 8 | sourceType: module 9 | rules: {} 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:15-alpine AS build 2 | 3 | COPY . /app 4 | 5 | WORKDIR /app 6 | 7 | RUN npm i --only=prod 8 | 9 | FROM node:15-alpine 10 | 11 | COPY --from=build /app /app 12 | 13 | USER node 14 | 15 | CMD ["node", "/app/index.js"] 16 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Launch Program", 11 | "skipFiles": ["/**"], 12 | "program": "${workspaceFolder}/index.js", 13 | "envFile": "${workspaceFolder}/.env" 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: push 4 | 5 | jobs: 6 | run-linters: 7 | name: Run linters 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - name: Check out Git repository 12 | uses: actions/checkout@v2 13 | 14 | - name: Set up Node.js 15 | uses: actions/setup-node@v1 16 | with: 17 | node-version: 12 18 | 19 | - name: Install linting dependencies 20 | run: npm install eslint prettier 21 | 22 | - name: Install package dependencies 23 | run: npm install 24 | 25 | - name: Run linters 26 | uses: wearerequired/lint-action@v1 27 | with: 28 | github_token: ${{ secrets.github_token }} 29 | eslint: true 30 | prettier: true 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "telegram-bot-amazon", 3 | "version": "1.0.0", 4 | "description": "Telegram bot to replace Amazon affiliate tags in links", 5 | "main": "index.js", 6 | "dependencies": { 7 | "node-fetch": "^2.6.1", 8 | "node-telegram-bot-api": "^0.51.0" 9 | }, 10 | "devDependencies": { 11 | "eslint": "^7.14.0", 12 | "prettier": "^2.2.0" 13 | }, 14 | "scripts": { 15 | "test": "echo \"Error: no test specified\" && exit 1", 16 | "start": "node index.js" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/LucaTNT/telegram-bot-amazon.git" 21 | }, 22 | "keywords": [ 23 | "telegram", 24 | "bot", 25 | "amazon" 26 | ], 27 | "author": "LucaTNT", 28 | "license": "MIT", 29 | "bugs": { 30 | "url": "https://github.com/LucaTNT/telegram-bot-amazon/issues" 31 | }, 32 | "homepage": "https://github.com/LucaTNT/telegram-bot-amazon#readme" 33 | } 34 | -------------------------------------------------------------------------------- /.github/workflows/dockerbuild.yml: -------------------------------------------------------------------------------- 1 | name: Docker Build/Publish Image 2 | 3 | on: 4 | push: 5 | branches: 6 | - "main" 7 | tags: 8 | - '[0-9]+\.[0-9]+' 9 | 10 | jobs: 11 | docker: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v2 16 | - name: Docker meta 17 | id: docker_meta 18 | uses: crazy-max/ghaction-docker-meta@v1 19 | with: 20 | images: lucatnt/telegram-bot-amazon 21 | tag-match: '[0-9]+\.[0-9]+' 22 | - name: Set up QEMU 23 | uses: docker/setup-qemu-action@v1 24 | - name: Set up Docker Buildx 25 | id: buildx 26 | uses: docker/setup-buildx-action@v1 27 | - name: Available platforms 28 | run: echo ${{ steps.buildx.outputs.platforms }} 29 | - name: Login to DockerHub 30 | if: github.ref != 'refs/heads/main' # Only on tag, not regular push 31 | uses: docker/login-action@v1 32 | with: 33 | username: ${{ secrets.DOCKERHUB_USERNAME }} 34 | password: ${{ secrets.DOCKERHUB_TOKEN }} 35 | - name: Build and push 36 | id: docker_build 37 | uses: docker/build-push-action@v2 38 | with: 39 | context: . 40 | file: ./Dockerfile 41 | platforms: linux/amd64,linux/arm64,linux/arm/v6,linux/arm/v7,linux/ppc64le,linux/s390x 42 | push: ${{ github.ref != 'refs/heads/main' }} # Only on tag, not regular push 43 | tags: ${{ steps.docker_meta.outputs.tags }} 44 | labels: ${{ steps.docker_meta.outputs.labels }} 45 | - name: Update repo description 46 | uses: peter-evans/dockerhub-description@v2 47 | with: 48 | username: ${{ secrets.DOCKERHUB_USERNAME }} 49 | password: ${{ secrets.DOCKERHUB_PASSWORD }} 50 | repository: lucatnt/telegram-bot-amazon 51 | if: github.ref != 'refs/heads/main' # Only on tag, not regular push 52 | 53 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # ******** NOTE ******** 12 | 13 | name: "CodeQL" 14 | 15 | on: 16 | push: 17 | branches: [main] 18 | pull_request: 19 | # The branches below must be a subset of the branches above 20 | branches: [main] 21 | schedule: 22 | - cron: "27 20 * * 0" 23 | 24 | jobs: 25 | analyze: 26 | name: Analyze 27 | runs-on: ubuntu-latest 28 | 29 | strategy: 30 | fail-fast: false 31 | matrix: 32 | language: ["javascript"] 33 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 34 | # Learn more: 35 | # https://docs.github.com/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 36 | 37 | steps: 38 | - name: Checkout repository 39 | uses: actions/checkout@v2 40 | 41 | # Initializes the CodeQL tools for scanning. 42 | - name: Initialize CodeQL 43 | uses: github/codeql-action/init@v1 44 | with: 45 | languages: ${{ matrix.language }} 46 | # If you wish to specify custom queries, you can do so here or in a config file. 47 | # By default, queries listed here will override any specified in a config file. 48 | # Prefix the list here with "+" to use these queries and those in the config file. 49 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 50 | 51 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 52 | # If this step fails, then you should remove it and run the build manually (see below) 53 | - name: Autobuild 54 | uses: github/codeql-action/autobuild@v1 55 | 56 | # ℹ️ Command-line programs to run using the OS shell. 57 | # 📚 https://git.io/JvXDl 58 | 59 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 60 | # and modify them (or add more) to build your code if your project 61 | # uses a compiled language 62 | 63 | #- run: | 64 | # make bootstrap 65 | # make release 66 | 67 | - name: Perform CodeQL Analysis 68 | uses: github/codeql-action/analyze@v1 69 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![](https://img.shields.io/github/issues/lucatnt/telegram-bot-amazon.svg)](https://github.com/LucaTNT/telegram-bot-amazon/issues) [![](https://img.shields.io/github/issues-pr-raw/lucatnt/telegram-bot-amazon.svg)](https://github.com/LucaTNT/telegram-bot-amazon/pulls) [![](https://img.shields.io/docker/pulls/lucatnt/telegram-bot-amazon.svg)](https://hub.docker.com/repository/docker/lucatnt/telegram-bot-amazon) [![](https://img.shields.io/docker/cloud/build/lucatnt/telegram-bot-amazon.svg)](https://hub.docker.com/repository/docker/lucatnt/telegram-bot-amazon) [![](https://img.shields.io/docker/image-size/lucatnt/telegram-bot-amazon/latest.svg)](https://hub.docker.com/repository/docker/lucatnt/telegram-bot-amazon) 2 | 3 | This is a Telegram bot that, if made admin of a group, will delete any message 4 | containing an Amazon link and re-post it tagged with the specified affiliate tag. 5 | 6 | It can be either messaged directly, or added **as an administrator** to a group or supergroup. 7 | 8 | If messaged directly, it replies with the affiliate link, while in a group it will delete any message containing an Amazon link and replace it with a new message, with a format that is customizable through the `GROUP_REPLACEMENT_MESSAGE` environment variables. 9 | 10 | ## Configuration 11 | 12 | It requires two parameters through environment variables: 13 | 14 | - `TELEGRAM_BOT_TOKEN` (required) is the token obtained from [@Botfather](https://t.me/botfather). 15 | - `AMAZON_TAG` (required) is the Amazon affiliate tag to be used when rewriting URLs. 16 | 17 | You can set two optional parameters through environment variables: 18 | 19 | - `SHORTEN_LINKS`: if set to `"true"`, all the sponsored links generated by the bot will be passed through the bitly shortener, which generates amzn.to links. 20 | - `BITLY_TOKEN` (required if `SHORTEN_LINKS` is `"true"`) is the [Generic Access Token](https://bitly.is/accesstoken) you can get from bitly. 21 | - `AMAZON_TLD` is the Amazon TLD for affiliate links (it defaults to "com", but you can set it to "it", "de", "fr" or whatever). 22 | - `GROUP_REPLACEMENT_MESSAGE` specifies the format for the message that gets posted to groups after deleting the original one. If not set, it will default to `Message by {USER} with Amazon affiliate link:\n\n{MESSAGE}`. In the following table you'll find variables you can use. 23 | - `RAW_LINKS`: if set to `"true"` disables this bot's "URL beautifier" (which removes all the URL parameters aside from the ASIN and the affiliate tag) and just adds/replaces the tag to the URL. This allows to link to arbitrary pages on Amazon, even non-product ones (e.g. search pages, category pages, etc.) 24 | - `IGNORE_USERS`: a comma-separated list of usernames (starting with the "@" character) and numeric user IDs whose messages won't be acted upon by the bot, even if they contain matching Amazon links. A valid list would be "@Yourusername,12345678,@IgnoreMeAsWell123". Numeric user IDs are useful for users who do not have Telegram user names defined. You can get yours by contacting [userinfobot](https://t.me/useridinfobot). 25 | 26 | | String | Replacement | 27 | | -------------------- | -------------------------------------------------------------------------------------------------------------------------- | 28 | | `{USER}` | The user that posted the message, as `@username` if they created a Telegram username, as `first_name last_name` otherwise. | 29 | | `{ORIGINAL_MESSAGE}` | The user's original message, with no replacements (so it will contain the non-affiliated Amazon link). | 30 | | `{MESSAGE}` | The user's message, with the affiliated Amazon link the bot created. | 31 | 32 | ## Running the app 33 | 34 | You can either run the app directly through NodeJS 35 | 36 | TELEGRAM_BOT_TOKEN=your-token AMAZON_TAG=your-tag node index.js 37 | 38 | Or you can run it in Docker 39 | 40 | docker run -e TELEGRAM_BOT_TOKEN=your-token -e AMAZON_TAG=your-tag --init lucatnt/telegram-bot-amazon 41 | 42 | Note that the `--init` option is highly recommended because it allows you to stop the container through a simple Ctrl+C when running in the foreground. Without it you need to use `docker stop`. 43 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /* 2 | telegram-bot-amazon 3 | 4 | Author: Luca Zorzi (@LucaTNT) 5 | License: MIT 6 | */ 7 | 8 | const TelegramBot = require("node-telegram-bot-api"); 9 | const fetch = require("node-fetch"); 10 | 11 | const fullURLRegex = /https?:\/\/(([^\s]*)\.)?amazon\.([a-z.]{2,5})(\/d\/([^\s]*)|\/([^\s]*)\/?(?:dp|o|gp|-)\/)(aw\/d\/|product\/)?(B[0-9]{2}[0-9A-Z]{7}|[0-9]{9}(?:X|[0-9]))([^\s]*)/gi; 12 | const shortURLRegex = /https?:\/\/(([^\s]*)\.)?amzn\.to\/([0-9A-Za-z]+)/gi; 13 | 14 | if (!process.env.TELEGRAM_BOT_TOKEN) { 15 | console.log("Missing TELEGRAM_BOT_TOKEN env variable"); 16 | process.exit(1); 17 | } 18 | 19 | if (!process.env.AMAZON_TAG) { 20 | console.log("Missing AMAZON_TAG env variable"); 21 | process.exit(1); 22 | } 23 | 24 | const shorten_links = 25 | process.env.SHORTEN_LINKS && process.env.SHORTEN_LINKS == "true"; 26 | const bitly_token = process.env.BITLY_TOKEN; 27 | if (shorten_links && !bitly_token) { 28 | console.log( 29 | "Missing BITLY_TOKEN env variable (required when SHORTEN_LINKS is true)" 30 | ); 31 | process.exit(1); 32 | } 33 | 34 | const raw_links = process.env.RAW_LINKS && process.env.RAW_LINKS == "true"; 35 | 36 | var group_replacement_message; 37 | 38 | if (!process.env.GROUP_REPLACEMENT_MESSAGE) { 39 | console.log( 40 | "Missing GROUP_REPLACEMENT_MESSAGE env variable, using the default one" 41 | ); 42 | group_replacement_message = 43 | "Message by {USER} with Amazon affiliate link:\n\n{MESSAGE}"; 44 | } else { 45 | group_replacement_message = process.env.GROUP_REPLACEMENT_MESSAGE; 46 | } 47 | 48 | var amazon_tld; 49 | 50 | if (!process.env.AMAZON_TLD) { 51 | console.log("Missing AMAZON_TLD env variable, using the default one (.com)"); 52 | amazon_tld = "com"; 53 | } else { 54 | amazon_tld = process.env.AMAZON_TLD; 55 | } 56 | 57 | const token = process.env.TELEGRAM_BOT_TOKEN; 58 | const amazon_tag = process.env.AMAZON_TAG; 59 | const rawUrlRegex = new RegExp( 60 | `https?://(([^\\s]*)\\.)?amazon\\.${amazon_tld}/?([^\\s]*)`, 61 | "ig" 62 | ); 63 | 64 | var usernames_to_ignore = []; 65 | var user_ids_to_ignore = []; 66 | 67 | if (process.env.IGNORE_USERS) { 68 | const usernameRegex = /@([^\s]+)/gi; 69 | const userIdRegex = /([0-9]+)/gi; 70 | let to_ignore = process.env.IGNORE_USERS.split(","); 71 | to_ignore.forEach((ignore) => { 72 | let usernameResult = usernameRegex.exec(ignore.trim()); 73 | if (usernameResult) { 74 | usernames_to_ignore.push(usernameResult[1].toLowerCase()); 75 | } else { 76 | let userIdResult = userIdRegex.exec(ignore.trim()); 77 | if (userIdResult) { 78 | user_ids_to_ignore.push(parseInt(userIdResult[1])); 79 | } 80 | } 81 | }); 82 | } 83 | 84 | const bot = new TelegramBot(token, { polling: true }); 85 | 86 | function log(msg) { 87 | const date = new Date().toISOString().replace(/T/, " ").replace(/\..+/, ""); 88 | console.log(date + " " + msg); 89 | } 90 | 91 | async function shortenURL(url) { 92 | const headers = { 93 | Authorization: `Bearer ${bitly_token}`, 94 | "Content-Type": "application/json", 95 | }; 96 | const body = { long_url: url, domain: "bit.ly" }; 97 | try { 98 | const res = await fetch("https://api-ssl.bitly.com/v4/shorten", { 99 | method: "post", 100 | headers: headers, 101 | body: JSON.stringify(body), 102 | }); 103 | const result = await res.json(); 104 | if (result.link) { 105 | return result.link; 106 | } else { 107 | log("Error in bitly response " + JSON.stringify(result)); 108 | return url; 109 | } 110 | } catch (err) { 111 | log(`Error in bitly response ${err}`); 112 | return url; 113 | } 114 | } 115 | 116 | function buildAmazonUrl(asin) { 117 | return `https://www.amazon.${amazon_tld}/dp/${asin}?tag=${amazon_tag}`; 118 | } 119 | 120 | function buildRawAmazonUrl(element) { 121 | const url = element.expanded_url ? element.expanded_url : element.fullURL; 122 | const strucutredURL = new URL(url); 123 | strucutredURL.searchParams.set("tag", amazon_tag); 124 | 125 | return strucutredURL.toString(); 126 | } 127 | 128 | async function getAmazonURL(element) { 129 | const url = 130 | element.asin != null 131 | ? buildAmazonUrl(element.asin) 132 | : buildRawAmazonUrl(element); 133 | return shorten_links ? await shortenURL(url) : url; 134 | } 135 | 136 | function buildMention(user) { 137 | return user.username 138 | ? "@" + user.username 139 | : user.first_name + (user.last_name ? " " + user.last_name : ""); 140 | } 141 | 142 | async function buildMessage(chat, message, replacements, user) { 143 | if (isGroup(chat)) { 144 | var affiliate_message = message; 145 | for await (const element of replacements) { 146 | const sponsored_url = await getAmazonURL(element); 147 | affiliate_message = affiliate_message.replace( 148 | element.fullURL, 149 | sponsored_url 150 | ); 151 | } 152 | 153 | return group_replacement_message 154 | .replace(/\\n/g, "\n") 155 | .replace("{USER}", buildMention(user)) 156 | .replace("{MESSAGE}", affiliate_message) 157 | .replace("{ORIGINAL_MESSAGE}", message); 158 | } else { 159 | var text = ""; 160 | if (replacements.length > 1) { 161 | for await (const element of replacements) { 162 | text += "• " + (await getAmazonURL(element)) + "\n"; 163 | } 164 | } else { 165 | text = await getAmazonURL(replacements[0]); 166 | } 167 | 168 | return text; 169 | } 170 | } 171 | 172 | function isGroup(chat) { 173 | return chat.type == "group" || chat.type == "supergroup"; 174 | } 175 | 176 | function deleteAndSend(msg, text) { 177 | const chat = msg.chat; 178 | const messageId = msg.message_id; 179 | const chatId = chat.id; 180 | var deleted = false; 181 | 182 | if (isGroup(chat)) { 183 | bot.deleteMessage(chatId, messageId); 184 | deleted = true; 185 | } 186 | const options = msg.reply_to_message 187 | ? { reply_to_message_id: msg.reply_to_message.message_id } 188 | : {}; 189 | 190 | bot.sendMessage(chatId, text, options); 191 | 192 | return deleted; 193 | } 194 | 195 | function getASINFromFullUrl(url) { 196 | const match = fullURLRegex.exec(url); 197 | 198 | return match != null ? match[8] : url; 199 | } 200 | 201 | async function getLongUrl(shortURL) { 202 | try { 203 | let res = await fetch(shortURL, { redirect: "manual" }); 204 | return { fullURL: res.headers.get("location"), shortURL: shortURL }; 205 | } catch (err) { 206 | log("Short URL " + shortURL + " -> ERROR"); 207 | return null; 208 | } 209 | } 210 | 211 | bot.on("message", async (msg) => { 212 | try { 213 | let from_username = msg.from.username 214 | ? msg.from.username.toLowerCase() 215 | : ""; 216 | let from_id = msg.from.id; 217 | if ( 218 | (!usernames_to_ignore.includes(from_username) && 219 | !user_ids_to_ignore.includes(from_id)) || 220 | !isGroup(msg.chat) 221 | ) { 222 | shortURLRegex.lastIndex = 0; 223 | var replacements = []; 224 | var match; 225 | if (raw_links) { 226 | rawUrlRegex.lastIndex = 0; 227 | 228 | while ((match = rawUrlRegex.exec(msg.text)) !== null) { 229 | const fullURL = match[0]; 230 | 231 | replacements.push({ asin: null, fullURL: fullURL }); 232 | } 233 | } else { 234 | fullURLRegex.lastIndex = 0; 235 | 236 | while ((match = fullURLRegex.exec(msg.text)) !== null) { 237 | const asin = match[8]; 238 | const fullURL = match[0]; 239 | replacements.push({ asin: asin, fullURL: fullURL }); 240 | } 241 | } 242 | 243 | while ((match = shortURLRegex.exec(msg.text)) !== null) { 244 | const shortURL = match[0]; 245 | fullURLRegex.lastIndex = 0; // Otherwise sometimes getASINFromFullUrl won't succeed 246 | const url = await getLongUrl(shortURL); 247 | 248 | if (url != null) { 249 | if (raw_links) { 250 | replacements.push({ 251 | asin: null, 252 | expanded_url: url.fullURL, 253 | fullURL: shortURL, 254 | }); 255 | } else { 256 | replacements.push({ 257 | asin: getASINFromFullUrl(url.fullURL), 258 | fullURL: shortURL, 259 | }); 260 | } 261 | } 262 | } 263 | 264 | if (replacements.length > 0) { 265 | const text = await buildMessage( 266 | msg.chat, 267 | msg.text, 268 | replacements, 269 | msg.from 270 | ); 271 | const deleted = deleteAndSend(msg, text); 272 | 273 | if (replacements.length > 1) { 274 | replacements.forEach((element) => { 275 | log( 276 | "Long URL " + 277 | element.fullURL + 278 | " -> ASIN " + 279 | element.asin + 280 | " from " + 281 | buildMention(msg.from) + 282 | (deleted ? " (original message deleted)" : "") 283 | ); 284 | }); 285 | } else { 286 | log( 287 | "Long URL " + 288 | replacements[0].fullURL + 289 | " -> ASIN " + 290 | replacements[0].asin + 291 | " from " + 292 | buildMention(msg.from) + 293 | (deleted ? " (original message deleted)" : "") 294 | ); 295 | } 296 | } 297 | } else { 298 | log( 299 | `Ignored message from ${buildMention( 300 | msg.from 301 | )} because it is included in the IGNORE_USERS env variable` 302 | ); 303 | } 304 | } catch (e) { 305 | log( 306 | "ERROR, please file a bug report at https://github.com/LucaTNT/telegram-bot-amazon" 307 | ); 308 | console.log(e); 309 | } 310 | }); 311 | --------------------------------------------------------------------------------