From a656a18ce8cd001c1fd3efeddaf8d3933150844e Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Sun, 26 Apr 2026 20:16:00 +0700 Subject: [PATCH] feat: initial JavaScript port of store-scraper-bot Node.js 20+ ESM port mirroring Java/Go implementations. - 13 Telegram commands matching Java identifiers - MongoDB schema parity (common, group, apple_app, google_app collections) - Apple/Google scrapers calling store-scraper.vercel.app with 10-min cache - Daily 7am Vietnam-time cron with weekend-silent mode - HTML table renderer matching Java/Go output - Docker + Compose (prod and dev) Untested end-to-end against live Telegram or upstream API. --- .dockerignore | 10 ++ .env.example | 18 +++ .gitignore | 17 ++ Dockerfile | 13 ++ LICENSE | 201 ++++++++++++++++++++++++ README.md | 82 ++++++++++ docker-compose.dev.yml | 56 +++++++ docker-compose.yml | 39 +++++ package.json | 25 +++ src/api/apple-scraper.js | 57 +++++++ src/api/google-scraper.js | 55 +++++++ src/bot/bot.js | 118 ++++++++++++++ src/bot/commands/add-apple-app.js | 48 ++++++ src/bot/commands/add-google-app.js | 42 +++++ src/bot/commands/add-group.js | 35 +++++ src/bot/commands/check-app-scores.js | 58 +++++++ src/bot/commands/check-app.js | 84 ++++++++++ src/bot/commands/command-utils.js | 37 +++++ src/bot/commands/delete-apple-app.js | 24 +++ src/bot/commands/delete-google-app.js | 24 +++ src/bot/commands/delete-group.js | 33 ++++ src/bot/commands/info.js | 12 ++ src/bot/commands/list-app.js | 31 ++++ src/bot/commands/list-group.js | 25 +++ src/bot/commands/raw-apple-app.js | 32 ++++ src/bot/commands/raw-google-app.js | 27 ++++ src/config.js | 87 ++++++++++ src/index.js | 43 +++++ src/logger.js | 16 ++ src/models/admin.js | 23 +++ src/models/apple-app.js | 8 + src/models/google-app.js | 8 + src/models/group.js | 46 ++++++ src/repository/admin-repository.js | 47 ++++++ src/repository/apple-app-repository.js | 22 +++ src/repository/google-app-repository.js | 22 +++ src/repository/group-repository.js | 59 +++++++ src/repository/mongodb.js | 30 ++++ src/scheduler/scheduler.js | 136 ++++++++++++++++ src/util/table.js | 66 ++++++++ src/util/time.js | 36 +++++ 41 files changed, 1852 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 docker-compose.dev.yml create mode 100644 docker-compose.yml create mode 100644 package.json create mode 100644 src/api/apple-scraper.js create mode 100644 src/api/google-scraper.js create mode 100644 src/bot/bot.js create mode 100644 src/bot/commands/add-apple-app.js create mode 100644 src/bot/commands/add-google-app.js create mode 100644 src/bot/commands/add-group.js create mode 100644 src/bot/commands/check-app-scores.js create mode 100644 src/bot/commands/check-app.js create mode 100644 src/bot/commands/command-utils.js create mode 100644 src/bot/commands/delete-apple-app.js create mode 100644 src/bot/commands/delete-google-app.js create mode 100644 src/bot/commands/delete-group.js create mode 100644 src/bot/commands/info.js create mode 100644 src/bot/commands/list-app.js create mode 100644 src/bot/commands/list-group.js create mode 100644 src/bot/commands/raw-apple-app.js create mode 100644 src/bot/commands/raw-google-app.js create mode 100644 src/config.js create mode 100644 src/index.js create mode 100644 src/logger.js create mode 100644 src/models/admin.js create mode 100644 src/models/apple-app.js create mode 100644 src/models/google-app.js create mode 100644 src/models/group.js create mode 100644 src/repository/admin-repository.js create mode 100644 src/repository/apple-app-repository.js create mode 100644 src/repository/google-app-repository.js create mode 100644 src/repository/group-repository.js create mode 100644 src/repository/mongodb.js create mode 100644 src/scheduler/scheduler.js create mode 100644 src/util/table.js create mode 100644 src/util/time.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3f9412e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +node_modules +npm-debug.log +.env +.env.local +.git +.gitignore +README.md +docker-compose*.yml +Dockerfile +.dockerignore diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c3aab54 --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +# Telegram Configuration +TELEGRAM_BOT_TOKEN=your_bot_token_here +TELEGRAM_BOT_USERNAME=your_bot_username + +# MongoDB Configuration (MONGODB_CONNECTION_STRING preferred; MONGO_URI fallback) +MONGODB_CONNECTION_STRING=mongodb://localhost:27017 +MONGO_DATABASE=store_scraper_bot +MONGO_TIMEOUT_SECONDS=10 + +# Application Configuration +ENV=DEVELOPMENT +ADMIN_IDS=123456789,987654321 +SOURCE_COMMIT=unknown + +# Optional overrides +APP_CACHE_SECONDS=600 +NUM_DAYS_WARNING_NOT_UPDATED=30 +SCHEDULE_CHECK_APP_TIME=0 7 * * * diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2790ba8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +.env +.env.local +.env.*.local + +# Editor / IDE +# .idea/ +# .vscode/ + +# OS +.DS_Store +Thumbs.db diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2b5e4e2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM node:20-alpine + +RUN apk --no-cache add tzdata ca-certificates + +WORKDIR /app + +COPY package.json ./ +RUN npm install --omit=dev + +COPY src ./src + +# Bot uses long polling — no ports exposed. +CMD ["node", "src/index.js"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d57fac7 --- /dev/null +++ b/README.md @@ -0,0 +1,82 @@ +# js-store-scraper-bot + +JavaScript (Node.js) port of [store-scraper-bot](https://github.com/tiennm99/store-scraper-bot). + +> ⚠️ **Preview / unstable — use at your own risk.** +> This port was produced largely with AI assistance and has **not** been tested +> end-to-end against a live Telegram bot or the upstream Java implementation. +> Behavior parity is intended but unverified. Do not run against a production database. + +The Java version remains the reference implementation. + +## Status + +- Mongo schema matches Java/Go (collections: `common`, `group`, `apple_app`, + `google_app`; string `_id`; `class` discriminator). +- Telegram command identifiers match Java exactly: `/info`, `/addgroup`, + `/delgroup`, `/listgroup`, `/addapple`, `/delapple`, `/addgoogle`, + `/delgoogle`, `/listapp`, `/checkapp`, `/checkappscore`, `/rawappleapp`, + `/rawgoogleapp`. +- HTML parse mode; weekend-silent daily report; configurable API cache (default 10 min). + +## Requirements + +- Node.js 20+ (uses built-in `fetch`) +- MongoDB 4.4+ + +## Configuration + +See `.env.example`: + +| Name | Notes | +|---|---| +| `TELEGRAM_BOT_TOKEN` | Telegram bot token (required) | +| `TELEGRAM_BOT_USERNAME` | Bot username (required) | +| `MONGODB_CONNECTION_STRING` | Preferred (Java parity); falls back to `MONGO_URI` | +| `MONGO_DATABASE` | Optional; inferred from URI path if omitted | +| `ADMIN_IDS` | Comma-separated Telegram user IDs (required) | +| `ENV` | `DEVELOPMENT` or `PRODUCTION` | +| `SOURCE_COMMIT` | Optional; shown on startup | +| `APP_CACHE_SECONDS` | Cache TTL for upstream API responses (default 600) | +| `NUM_DAYS_WARNING_NOT_UPDATED` | Threshold for daily warning (default 30) | +| `SCHEDULE_CHECK_APP_TIME` | Cron expression in Vietnam timezone (default `0 7 * * *`) | + +## Run + +```sh +npm install +cp .env.example .env # then edit credentials +npm start +``` + +Or via Docker Compose: + +```sh +docker compose up --build +``` + +## Project Layout + +``` +src/ +├── index.js # entry point: wire up config, mongo, scrapers, bot, scheduler +├── config.js +├── logger.js +├── api/ +│ ├── apple-scraper.js +│ └── google-scraper.js +├── models/ # plain object factories matching Mongo docs +├── repository/ # Mongo collection wrappers (admin / group / cached app) +├── bot/ +│ ├── bot.js # Telegram polling, command dispatch, sender +│ └── commands/ # one file per /command +├── scheduler/scheduler.js # daily 7am Vietnam-time check +└── util/ # table renderer, time helpers +``` + +## Differences vs Go / Java + +- Group / admin / chat IDs are JS `number`s. Telegram chat IDs fit in safe-int + range, so this is intentional and matches Telegram's documented limits. +- Pino logging instead of Java/Go's structured loggers; semantics equivalent. +- HTTP via Node 20's built-in `fetch` (no extra dependency). diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..d495beb --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,56 @@ +version: '3.8' + +services: + bot: + build: . + container_name: js-store-scraper-bot-dev + restart: unless-stopped + environment: + - TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN} + - TELEGRAM_BOT_USERNAME=${TELEGRAM_BOT_USERNAME} + - MONGODB_CONNECTION_STRING=mongodb://mongodb:27017 + - MONGO_DATABASE=store_scraper_bot_dev + - ENV=DEVELOPMENT + - ADMIN_IDS=${ADMIN_IDS} + - SOURCE_COMMIT=${SOURCE_COMMIT:-dev} + depends_on: + - mongodb + networks: + - bot-network + volumes: + - ./src:/app/src + + mongodb: + image: mongo:7.0 + container_name: js-store-scraper-mongodb-dev + restart: unless-stopped + environment: + - MONGO_INITDB_DATABASE=store_scraper_bot_dev + volumes: + - mongodb_data_dev:/data/db + networks: + - bot-network + ports: + - "27017:27017" + + mongo-express: + image: mongo-express:latest + container_name: js-store-mongo-express-dev + restart: unless-stopped + environment: + - ME_CONFIG_MONGODB_URL=mongodb://mongodb:27017 + - ME_CONFIG_BASICAUTH_USERNAME=admin + - ME_CONFIG_BASICAUTH_PASSWORD=admin + depends_on: + - mongodb + networks: + - bot-network + ports: + - "8081:8081" + +networks: + bot-network: + driver: bridge + +volumes: + mongodb_data_dev: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ed56753 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,39 @@ +version: '3.8' + +services: + bot: + build: . + container_name: js-store-scraper-bot + restart: unless-stopped + environment: + - TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN} + - TELEGRAM_BOT_USERNAME=${TELEGRAM_BOT_USERNAME} + - MONGODB_CONNECTION_STRING=mongodb://mongodb:27017 + - MONGO_DATABASE=store_scraper_bot + - ENV=${ENV:-PRODUCTION} + - ADMIN_IDS=${ADMIN_IDS} + - SOURCE_COMMIT=${SOURCE_COMMIT:-unknown} + depends_on: + - mongodb + networks: + - bot-network + + mongodb: + image: mongo:7.0 + container_name: js-store-scraper-mongodb + restart: unless-stopped + environment: + - MONGO_INITDB_DATABASE=store_scraper_bot + volumes: + - mongodb_data:/data/db + networks: + - bot-network + ports: + - "27017:27017" + +networks: + bot-network: + driver: bridge + +volumes: + mongodb_data: diff --git a/package.json b/package.json new file mode 100644 index 0000000..adc06be --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "js-store-scraper-bot", + "version": "0.1.0", + "description": "JavaScript port of store-scraper-bot — Telegram bot for tracking Apple/Google Play app updates.", + "type": "module", + "private": true, + "engines": { + "node": ">=20" + }, + "main": "src/index.js", + "scripts": { + "start": "node src/index.js", + "dev": "node --watch src/index.js", + "lint": "node --check src/index.js" + }, + "dependencies": { + "dotenv": "^16.4.5", + "mongodb": "^6.10.0", + "node-cron": "^3.0.3", + "node-telegram-bot-api": "^0.66.0", + "pino": "^9.5.0", + "pino-pretty": "^11.3.0" + }, + "license": "Apache-2.0" +} diff --git a/src/api/apple-scraper.js b/src/api/apple-scraper.js new file mode 100644 index 0000000..0034428 --- /dev/null +++ b/src/api/apple-scraper.js @@ -0,0 +1,57 @@ +import { getCachedAppleApp, saveAppleApp } from '../repository/apple-app-repository.js'; +import { newAppleApp } from '../models/apple-app.js'; + +// Mirrors Java AppStoreScraper (api/apple/AppStoreScraper.java). +const BASE_URL = 'https://store-scraper.vercel.app/apple'; + +export function buildAppleRequestByTrackId(id, country) { + return { id, country, ratings: true }; +} + +export function buildAppleRequestByBundleId(appId, country) { + return { appId, country, ratings: true }; +} + +export function createAppleScraper(config) { + const { logger, appCacheSeconds } = config; + + async function rawApp(req) { + const res = await fetch(`${BASE_URL}/app`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(req), + }); + if (!res.ok) throw new Error(`apple HTTP status ${res.status}`); + return await res.text(); + } + + async function app(req) { + const text = await rawApp(req); + return JSON.parse(text); + } + + async function cache(resp) { + if (!resp || !resp.appId) return; + try { + await saveAppleApp(newAppleApp(resp.appId, resp, Date.now())); + } catch (err) { + logger.warn({ appId: resp.appId, err: err.message }, 'failed to cache apple app'); + } + } + + async function getApp(appId, country) { + const cached = await getCachedAppleApp(appId, appCacheSeconds); + if (cached) return cached.app; + const resp = await app(buildAppleRequestByBundleId(appId, country)); + await cache(resp); + return resp; + } + + async function fetchAndCache(req) { + const resp = await app(req); + await cache(resp); + return resp; + } + + return { rawApp, app, getApp, fetchAndCache }; +} diff --git a/src/api/google-scraper.js b/src/api/google-scraper.js new file mode 100644 index 0000000..f6d2277 --- /dev/null +++ b/src/api/google-scraper.js @@ -0,0 +1,55 @@ +import { getCachedGoogleApp, saveGoogleApp } from '../repository/google-app-repository.js'; +import { newGoogleApp } from '../models/google-app.js'; + +// Mirrors Java GooglePlayScraper (api/google/GooglePlayScraper.java). +const BASE_URL = 'https://store-scraper.vercel.app/google'; + +export function buildGoogleRequest(appId, country) { + return { appId, country: country || 'vn' }; +} + +export function createGoogleScraper(config) { + const { logger, appCacheSeconds } = config; + + async function rawApp(req) { + const res = await fetch(`${BASE_URL}/app`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(req), + }); + if (!res.ok) throw new Error(`google HTTP status ${res.status}`); + return await res.text(); + } + + async function app(req) { + const text = await rawApp(req); + return JSON.parse(text); + } + + async function cache(resp, fallbackId) { + if (!resp) return; + const id = resp.appId || fallbackId; + if (!id) return; + try { + await saveGoogleApp(newGoogleApp(id, resp, Date.now())); + } catch (err) { + logger.warn({ appId: id, err: err.message }, 'failed to cache google app'); + } + } + + async function getApp(appId, country) { + const cached = await getCachedGoogleApp(appId, appCacheSeconds); + if (cached) return cached.app; + const resp = await app(buildGoogleRequest(appId, country)); + await cache(resp, appId); + return resp; + } + + async function fetchAndCache(req) { + const resp = await app(req); + await cache(resp, req.appId); + return resp; + } + + return { rawApp, app, getApp, fetchAndCache }; +} diff --git a/src/bot/bot.js b/src/bot/bot.js new file mode 100644 index 0000000..0c7cd9c --- /dev/null +++ b/src/bot/bot.js @@ -0,0 +1,118 @@ +import TelegramBot from 'node-telegram-bot-api'; +import { createInfoCommand } from './commands/info.js'; +import { createAddGroupCommand } from './commands/add-group.js'; +import { createDeleteGroupCommand } from './commands/delete-group.js'; +import { createListGroupCommand } from './commands/list-group.js'; +import { createAddAppleAppCommand } from './commands/add-apple-app.js'; +import { createDeleteAppleAppCommand } from './commands/delete-apple-app.js'; +import { createAddGoogleAppCommand } from './commands/add-google-app.js'; +import { createDeleteGoogleAppCommand } from './commands/delete-google-app.js'; +import { createListAppCommand } from './commands/list-app.js'; +import { createCheckAppCommand } from './commands/check-app.js'; +import { createCheckAppScoresCommand } from './commands/check-app-scores.js'; +import { createRawAppleAppCommand } from './commands/raw-apple-app.js'; +import { createRawGoogleAppCommand } from './commands/raw-google-app.js'; + +// HTML parse mode for all messages (Java parity). +const PARSE_MODE = 'HTML'; + +export function createBot(config, appleScraper, googleScraper) { + const tg = new TelegramBot(config.telegramBotToken, { polling: true }); + const logger = config.logger; + + const sender = { + async sendMessage(chatId, html) { + try { + await tg.sendMessage(chatId, html, { + parse_mode: PARSE_MODE, + disable_web_page_preview: true, + }); + } catch (err) { + logger.warn({ chatId, err: err.message }, 'send message failed'); + } + }, + async sendMessageSilent(chatId, html) { + try { + await tg.sendMessage(chatId, html, { + parse_mode: PARSE_MODE, + disable_web_page_preview: true, + disable_notification: true, + }); + } catch (err) { + logger.warn({ chatId, err: err.message }, 'send silent message failed'); + } + }, + async sendDocument(chatId, filename, body) { + try { + await tg.sendDocument( + chatId, + Buffer.from(body, 'utf8'), + {}, + { filename, contentType: 'application/json' }, + ); + } catch (err) { + logger.warn({ chatId, err: err.message }, 'send document failed'); + } + }, + }; + + // Java command identifiers — keep names matching exactly. + const commands = { + info: createInfoCommand(), + addgroup: createAddGroupCommand(config), + delgroup: createDeleteGroupCommand(config), + listgroup: createListGroupCommand(config), + addapple: createAddAppleAppCommand(appleScraper), + delapple: createDeleteAppleAppCommand(), + addgoogle: createAddGoogleAppCommand(googleScraper), + delgoogle: createDeleteGoogleAppCommand(), + listapp: createListAppCommand(), + checkapp: createCheckAppCommand(config, appleScraper, googleScraper), + checkappscore: createCheckAppScoresCommand(appleScraper, googleScraper), + rawappleapp: createRawAppleAppCommand(appleScraper), + rawgoogleapp: createRawGoogleAppCommand(googleScraper), + }; + + tg.on('message', async (msg) => { + const name = parseCommandName(msg.text, config.telegramBotUsername); + if (!name) return; + const handler = commands[name]; + if (!handler) { + logger.debug({ command: name }, 'Unknown command'); + return; + } + logger.info( + { command: name, userId: msg.from?.id, chatId: msg.chat.id }, + 'Executing command', + ); + try { + await handler(msg, sender); + } catch (err) { + logger.error({ err: err.message, command: name }, 'panic in command'); + await sender.sendMessage(msg.chat.id, 'Internal server error'); + } + }); + + tg.on('polling_error', (err) => { + logger.warn({ err: err.message }, 'polling error'); + }); + + tg.getMe() + .then((me) => logger.info({ username: me.username }, 'Authorized on account')) + .catch((err) => logger.error({ err: err.message }, 'getMe failed')); + + return { sender, telegram: tg }; +} + +// Extracts "info" from "/info", "/info arg", "/info@bot", "/info@bot arg". +function parseCommandName(text, botUsername) { + if (!text || text[0] !== '/') return null; + const space = text.indexOf(' '); + const head = space < 0 ? text.slice(1) : text.slice(1, space); + const at = head.indexOf('@'); + if (at < 0) return head; + const cmd = head.slice(0, at); + const target = head.slice(at + 1); + if (botUsername && target && target.toLowerCase() !== botUsername.toLowerCase()) return null; + return cmd; +} diff --git a/src/bot/commands/add-apple-app.js b/src/bot/commands/add-apple-app.js new file mode 100644 index 0000000..d48e78e --- /dev/null +++ b/src/bot/commands/add-apple-app.js @@ -0,0 +1,48 @@ +import { buildAppleRequestByBundleId, buildAppleRequestByTrackId } from '../../api/apple-scraper.js'; +import * as groupRepo from '../../repository/group-repository.js'; +import { authorizeGroup, getCommandArguments, splitArgs } from './command-utils.js'; + +// /addapple [country=vn] — Java AddAppleAppCommand. +export function createAddAppleAppCommand(appleScraper) { + return async (msg, sender) => { + if (!(await authorizeGroup(msg.chat.id, sender))) return; + const args = splitArgs(getCommandArguments(msg.text)); + if (args.length < 1 || args.length > 2) { + await sender.sendMessage(msg.chat.id, 'Invalid arguments'); + return; + } + const country = args.length === 2 ? args[1] : 'vn'; + + // Java: try parsing arg[0] as Long (trackId); else treat as bundleId. + const trackId = Number.parseInt(args[0], 10); + const req = + Number.isFinite(trackId) && String(trackId) === args[0] + ? buildAppleRequestByTrackId(trackId, country) + : buildAppleRequestByBundleId(args[0], country); + + let resp; + try { + resp = await appleScraper.fetchAndCache(req); + } catch { + resp = null; + } + if (!resp || !resp.appId) { + await sender.sendMessage(msg.chat.id, 'Error when request app info'); + return; + } + + try { + const added = await groupRepo.addAppleApp(msg.chat.id, resp.appId, country); + if (!added) { + await sender.sendMessage(msg.chat.id, `Apple app ${resp.appId} is already added`); + return; + } + await sender.sendMessage( + msg.chat.id, + `Apple app ${resp.appId}, country ${country} added successfully`, + ); + } catch { + await sender.sendMessage(msg.chat.id, 'Internal server error'); + } + }; +} diff --git a/src/bot/commands/add-google-app.js b/src/bot/commands/add-google-app.js new file mode 100644 index 0000000..d09104d --- /dev/null +++ b/src/bot/commands/add-google-app.js @@ -0,0 +1,42 @@ +import { buildGoogleRequest } from '../../api/google-scraper.js'; +import * as groupRepo from '../../repository/group-repository.js'; +import { authorizeGroup, getCommandArguments, splitArgs } from './command-utils.js'; + +// /addgoogle [country=vn] — Java AddGoogleAppCommand. +export function createAddGoogleAppCommand(googleScraper) { + return async (msg, sender) => { + if (!(await authorizeGroup(msg.chat.id, sender))) return; + const args = splitArgs(getCommandArguments(msg.text)); + if (args.length < 1 || args.length > 2) { + await sender.sendMessage(msg.chat.id, 'Invalid arguments'); + return; + } + const appId = args[0]; + const country = args.length === 2 ? args[1] : 'vn'; + + let resp; + try { + resp = await googleScraper.fetchAndCache(buildGoogleRequest(appId, country)); + } catch { + resp = null; + } + if (!resp) { + await sender.sendMessage(msg.chat.id, 'Error when request app info'); + return; + } + + try { + const added = await groupRepo.addGoogleApp(msg.chat.id, appId, country); + if (!added) { + await sender.sendMessage(msg.chat.id, `Google app ${appId} is already added`); + return; + } + await sender.sendMessage( + msg.chat.id, + `Google app ${appId}, country ${country} added successfully`, + ); + } catch { + await sender.sendMessage(msg.chat.id, 'Internal server error'); + } + }; +} diff --git a/src/bot/commands/add-group.js b/src/bot/commands/add-group.js new file mode 100644 index 0000000..bbb2e51 --- /dev/null +++ b/src/bot/commands/add-group.js @@ -0,0 +1,35 @@ +import * as adminRepo from '../../repository/admin-repository.js'; +import * as groupRepo from '../../repository/group-repository.js'; +import { getCommandArguments, requireAdminUser, splitArgs } from './command-utils.js'; + +// /addgroup [groupId] — Java AddGroupCommand. Admin-only. +export function createAddGroupCommand(config) { + return async (msg, sender) => { + if (!(await requireAdminUser(msg.from.id, msg.chat.id, config, sender))) return; + const args = splitArgs(getCommandArguments(msg.text)); + if (args.length > 1) { + await sender.sendMessage(msg.chat.id, 'Invalid arguments'); + return; + } + let groupId = msg.chat.id; + if (args.length === 1) { + const parsed = Number.parseInt(args[0], 10); + if (!Number.isFinite(parsed)) { + await sender.sendMessage(msg.chat.id, 'Invalid arguments'); + return; + } + groupId = parsed; + } + try { + const added = await adminRepo.addGroup(groupId); + if (!added) { + await sender.sendMessage(msg.chat.id, 'Group is already added'); + return; + } + await groupRepo.initGroup(groupId); + await sender.sendMessage(msg.chat.id, 'Group added successfully'); + } catch { + await sender.sendMessage(msg.chat.id, 'Internal server error'); + } + }; +} diff --git a/src/bot/commands/check-app-scores.js b/src/bot/commands/check-app-scores.js new file mode 100644 index 0000000..96c6b93 --- /dev/null +++ b/src/bot/commands/check-app-scores.js @@ -0,0 +1,58 @@ +import * as groupRepo from '../../repository/group-repository.js'; +import { buildTable } from '../../util/table.js'; +import { authorizeGroup, getCommandArguments, splitArgs } from './command-utils.js'; + +// /checkappscore — Java CheckAppScoreCommand. Reports score + ratings. +// Score rounded to 1 decimal (Java Precision.round(score, 1) parity). +export function createCheckAppScoresCommand(appleScraper, googleScraper) { + return async (msg, sender) => { + if (!(await authorizeGroup(msg.chat.id, sender))) return; + if (splitArgs(getCommandArguments(msg.text)).length !== 0) { + await sender.sendMessage(msg.chat.id, 'Invalid arguments'); + return; + } + try { + const group = await groupRepo.getGroup(msg.chat.id); + const headers = ['AppId', 'Score', 'Ratings']; + const appleRows = await scoreRowsFor(group.appleApps, appleScraper); + const googleRows = await scoreRowsFor(group.googleApps, googleScraper); + + const out = + 'Apple Apps\n' + + renderTable(appleRows, headers) + + '\nGoogle Apps\n' + + renderTable(googleRows, headers); + await sender.sendMessage(msg.chat.id, out); + } catch { + await sender.sendMessage(msg.chat.id, 'Internal server error'); + } + }; +} + +async function scoreRowsFor(apps, scraper) { + const rows = []; + for (const a of apps) { + try { + const resp = await scraper.getApp(a.appId, a.country); + if (!resp) { + rows.push([a.appId, '?', '?']); + continue; + } + rows.push([a.appId, formatScore(resp.score), String(resp.ratings ?? 0)]); + } catch { + rows.push([a.appId, '?', '?']); + } + } + return rows; +} + +function renderTable(rows, headers) { + if (rows.length === 0) return '(none)\n'; + return `
${buildTable(headers, rows)}
\n`; +} + +function formatScore(score) { + const v = Number(score); + if (!Number.isFinite(v)) return '?'; + return (Math.round(v * 10) / 10).toFixed(1); +} diff --git a/src/bot/commands/check-app.js b/src/bot/commands/check-app.js new file mode 100644 index 0000000..4004783 --- /dev/null +++ b/src/bot/commands/check-app.js @@ -0,0 +1,84 @@ +import * as groupRepo from '../../repository/group-repository.js'; +import { buildTable } from '../../util/table.js'; +import { daysBetween, formatDateInTz } from '../../util/time.js'; +import { authorizeGroup, getCommandArguments, splitArgs } from './command-utils.js'; + +// /checkapp — Java CheckAppCommand. Reports update status per app, per store. +export function createCheckAppCommand(config, appleScraper, googleScraper) { + return async (msg, sender) => { + if (!(await authorizeGroup(msg.chat.id, sender))) return; + if (splitArgs(getCommandArguments(msg.text)).length !== 0) { + await sender.sendMessage(msg.chat.id, 'Invalid arguments'); + return; + } + try { + const group = await groupRepo.getGroup(msg.chat.id); + const nowMs = Date.now(); + const threshold = config.numDaysWarningNotUpdated; + const headers = ['AppId', 'Updated', 'Days', 'OK']; + + const appleRows = await appleRowsFor(group.appleApps, appleScraper, nowMs, threshold, config.timezone); + const googleRows = await googleRowsFor(group.googleApps, googleScraper, nowMs, threshold, config.timezone); + + const out = + 'Apple Apps\n' + + renderTable(appleRows, headers) + + '\nGoogle Apps\n' + + renderTable(googleRows, headers); + await sender.sendMessage(msg.chat.id, out); + } catch { + await sender.sendMessage(msg.chat.id, 'Internal server error'); + } + }; +} + +async function appleRowsFor(apps, scraper, nowMs, threshold, timezone) { + const rows = []; + for (const a of apps) { + try { + const resp = await scraper.getApp(a.appId, a.country); + if (!resp) { + rows.push([a.appId, '?', '?', mark(false)]); + continue; + } + const updatedMs = Date.parse(resp.updated); + if (Number.isNaN(updatedMs)) { + rows.push([a.appId, resp.updated || '?', '?', mark(false)]); + continue; + } + const days = daysBetween(updatedMs, nowMs); + rows.push([a.appId, formatDateInTz(new Date(updatedMs), timezone), String(days), mark(days <= threshold)]); + } catch { + rows.push([a.appId, '?', '?', mark(false)]); + } + } + return rows; +} + +async function googleRowsFor(apps, scraper, nowMs, threshold, timezone) { + const rows = []; + for (const a of apps) { + try { + const resp = await scraper.getApp(a.appId, a.country); + if (!resp) { + rows.push([a.appId, '?', '?', mark(false)]); + continue; + } + const updatedMs = Number(resp.updated); + const days = daysBetween(updatedMs, nowMs); + rows.push([a.appId, formatDateInTz(new Date(updatedMs), timezone), String(days), mark(days <= threshold)]); + } catch { + rows.push([a.appId, '?', '?', mark(false)]); + } + } + return rows; +} + +function renderTable(rows, headers) { + if (rows.length === 0) return '(none)\n'; + return `
${buildTable(headers, rows)}
\n`; +} + +function mark(ok) { + return ok ? '✅' : '❌'; +} diff --git a/src/bot/commands/command-utils.js b/src/bot/commands/command-utils.js new file mode 100644 index 0000000..c33c902 --- /dev/null +++ b/src/bot/commands/command-utils.js @@ -0,0 +1,37 @@ +import * as adminRepo from '../../repository/admin-repository.js'; + +export function splitArgs(text) { + if (!text) return []; + return text.trim().split(/\s+/).filter((s) => s.length > 0); +} + +// Strips the "/" or "/@botname" prefix from message.text. +export function getCommandArguments(text) { + if (!text) return ''; + const trimmed = text.trim(); + const space = trimmed.indexOf(' '); + if (space < 0) return ''; + return trimmed.slice(space + 1).trim(); +} + +export async function authorizeGroup(chatId, sender) { + try { + const ok = await adminRepo.hasGroup(chatId); + if (!ok) { + await sender.sendMessage(chatId, 'Group is not allowed to use bot'); + return false; + } + return true; + } catch { + await sender.sendMessage(chatId, 'Group is not allowed to use bot'); + return false; + } +} + +export async function requireAdminUser(userId, chatId, config, sender) { + if (!config.isAdmin(userId)) { + await sender.sendMessage(chatId, 'You are not authorized to use this command'); + return false; + } + return true; +} diff --git a/src/bot/commands/delete-apple-app.js b/src/bot/commands/delete-apple-app.js new file mode 100644 index 0000000..d174a1e --- /dev/null +++ b/src/bot/commands/delete-apple-app.js @@ -0,0 +1,24 @@ +import * as groupRepo from '../../repository/group-repository.js'; +import { authorizeGroup, getCommandArguments, splitArgs } from './command-utils.js'; + +// /delapple — Java DeleteAppleAppCommand. +export function createDeleteAppleAppCommand() { + return async (msg, sender) => { + if (!(await authorizeGroup(msg.chat.id, sender))) return; + const args = splitArgs(getCommandArguments(msg.text)); + if (args.length !== 1) { + await sender.sendMessage(msg.chat.id, 'Invalid arguments'); + return; + } + try { + const removed = await groupRepo.removeAppleApp(msg.chat.id, args[0]); + if (!removed) { + await sender.sendMessage(msg.chat.id, 'Apple app is not added'); + return; + } + await sender.sendMessage(msg.chat.id, 'Apple app deleted successfully'); + } catch { + await sender.sendMessage(msg.chat.id, 'Internal server error'); + } + }; +} diff --git a/src/bot/commands/delete-google-app.js b/src/bot/commands/delete-google-app.js new file mode 100644 index 0000000..a4a455f --- /dev/null +++ b/src/bot/commands/delete-google-app.js @@ -0,0 +1,24 @@ +import * as groupRepo from '../../repository/group-repository.js'; +import { authorizeGroup, getCommandArguments, splitArgs } from './command-utils.js'; + +// /delgoogle — Java DeleteGoogleAppCommand. +export function createDeleteGoogleAppCommand() { + return async (msg, sender) => { + if (!(await authorizeGroup(msg.chat.id, sender))) return; + const args = splitArgs(getCommandArguments(msg.text)); + if (args.length !== 1) { + await sender.sendMessage(msg.chat.id, 'Invalid arguments'); + return; + } + try { + const removed = await groupRepo.removeGoogleApp(msg.chat.id, args[0]); + if (!removed) { + await sender.sendMessage(msg.chat.id, 'Google app is not added'); + return; + } + await sender.sendMessage(msg.chat.id, 'Google app deleted successfully'); + } catch { + await sender.sendMessage(msg.chat.id, 'Internal server error'); + } + }; +} diff --git a/src/bot/commands/delete-group.js b/src/bot/commands/delete-group.js new file mode 100644 index 0000000..e251e70 --- /dev/null +++ b/src/bot/commands/delete-group.js @@ -0,0 +1,33 @@ +import * as adminRepo from '../../repository/admin-repository.js'; +import { getCommandArguments, requireAdminUser, splitArgs } from './command-utils.js'; + +// /delgroup [groupId] — Java DeleteGroupCommand. Admin-only. +export function createDeleteGroupCommand(config) { + return async (msg, sender) => { + if (!(await requireAdminUser(msg.from.id, msg.chat.id, config, sender))) return; + const args = splitArgs(getCommandArguments(msg.text)); + if (args.length > 1) { + await sender.sendMessage(msg.chat.id, 'Invalid arguments'); + return; + } + let groupId = msg.chat.id; + if (args.length === 1) { + const parsed = Number.parseInt(args[0], 10); + if (!Number.isFinite(parsed)) { + await sender.sendMessage(msg.chat.id, 'Invalid arguments'); + return; + } + groupId = parsed; + } + try { + const removed = await adminRepo.removeGroup(groupId); + if (!removed) { + await sender.sendMessage(msg.chat.id, 'Group is not added'); + return; + } + await sender.sendMessage(msg.chat.id, 'Group deleted successfully'); + } catch { + await sender.sendMessage(msg.chat.id, 'Internal server error'); + } + }; +} diff --git a/src/bot/commands/info.js b/src/bot/commands/info.js new file mode 100644 index 0000000..0caa790 --- /dev/null +++ b/src/bot/commands/info.js @@ -0,0 +1,12 @@ +import { getCommandArguments, splitArgs } from './command-utils.js'; + +// /info — Java InfoCommand. Reports the chat (group) ID. +export function createInfoCommand() { + return async (msg, sender) => { + if (splitArgs(getCommandArguments(msg.text)).length !== 0) { + await sender.sendMessage(msg.chat.id, 'Invalid arguments'); + return; + } + await sender.sendMessage(msg.chat.id, `Id của nhóm là ${msg.chat.id}\n`); + }; +} diff --git a/src/bot/commands/list-app.js b/src/bot/commands/list-app.js new file mode 100644 index 0000000..775c94b --- /dev/null +++ b/src/bot/commands/list-app.js @@ -0,0 +1,31 @@ +import * as groupRepo from '../../repository/group-repository.js'; +import { buildTable } from '../../util/table.js'; +import { authorizeGroup, getCommandArguments, splitArgs } from './command-utils.js'; + +// /listapp — Java ListAppCommand. Two tables (Apple / Google) of tracked apps. +export function createListAppCommand() { + return async (msg, sender) => { + if (!(await authorizeGroup(msg.chat.id, sender))) return; + if (splitArgs(getCommandArguments(msg.text)).length !== 0) { + await sender.sendMessage(msg.chat.id, 'Invalid arguments'); + return; + } + try { + const group = await groupRepo.getGroup(msg.chat.id); + const out = + 'Apple Apps\n' + + formatAppTable(group.appleApps) + + '\nGoogle Apps\n' + + formatAppTable(group.googleApps); + await sender.sendMessage(msg.chat.id, out); + } catch { + await sender.sendMessage(msg.chat.id, 'Internal server error'); + } + }; +} + +function formatAppTable(apps) { + if (apps.length === 0) return '(none)\n'; + const rows = apps.map((a, i) => [String(i + 1), a.appId, a.country]); + return `
${buildTable(['#', 'AppId', 'Country'], rows)}
\n`; +} diff --git a/src/bot/commands/list-group.js b/src/bot/commands/list-group.js new file mode 100644 index 0000000..f5f072f --- /dev/null +++ b/src/bot/commands/list-group.js @@ -0,0 +1,25 @@ +import * as adminRepo from '../../repository/admin-repository.js'; +import { getCommandArguments, requireAdminUser, splitArgs } from './command-utils.js'; + +// /listgroup — Java ListGroupCommand. Admin-only. +export function createListGroupCommand(config) { + return async (msg, sender) => { + if (!(await requireAdminUser(msg.from.id, msg.chat.id, config, sender))) return; + if (splitArgs(getCommandArguments(msg.text)).length !== 0) { + await sender.sendMessage(msg.chat.id, 'Invalid arguments'); + return; + } + try { + const groups = await adminRepo.getAllGroups(); + if (groups.length === 0) { + await sender.sendMessage(msg.chat.id, 'No groups found'); + return; + } + const lines = [`Authorized groups (${groups.length}):`]; + groups.forEach((gid, i) => lines.push(`${i + 1}. ${gid}`)); + await sender.sendMessage(msg.chat.id, lines.join('\n') + '\n'); + } catch { + await sender.sendMessage(msg.chat.id, 'Internal server error'); + } + }; +} diff --git a/src/bot/commands/raw-apple-app.js b/src/bot/commands/raw-apple-app.js new file mode 100644 index 0000000..2a2342a --- /dev/null +++ b/src/bot/commands/raw-apple-app.js @@ -0,0 +1,32 @@ +import { buildAppleRequestByBundleId, buildAppleRequestByTrackId } from '../../api/apple-scraper.js'; +import { getCommandArguments, splitArgs } from './command-utils.js'; + +// /rawappleapp [country=vn] — Java RawAppleAppCommand. +// Sends raw upstream JSON as a Telegram document attachment. +export function createRawAppleAppCommand(appleScraper) { + return async (msg, sender) => { + const args = splitArgs(getCommandArguments(msg.text)); + if (args.length < 1 || args.length > 2) { + await sender.sendMessage(msg.chat.id, 'Invalid arguments'); + return; + } + const country = args.length === 2 ? args[1] : 'vn'; + const trackId = Number.parseInt(args[0], 10); + const req = + Number.isFinite(trackId) && String(trackId) === args[0] + ? buildAppleRequestByTrackId(trackId, country) + : buildAppleRequestByBundleId(args[0], country); + + let raw; + try { + raw = await appleScraper.rawApp(req); + } catch { + raw = ''; + } + if (!raw) { + await sender.sendMessage(msg.chat.id, 'Error when request app info'); + return; + } + await sender.sendDocument(msg.chat.id, `${args[0]}.json`, raw); + }; +} diff --git a/src/bot/commands/raw-google-app.js b/src/bot/commands/raw-google-app.js new file mode 100644 index 0000000..73dd2c3 --- /dev/null +++ b/src/bot/commands/raw-google-app.js @@ -0,0 +1,27 @@ +import { buildGoogleRequest } from '../../api/google-scraper.js'; +import { getCommandArguments, splitArgs } from './command-utils.js'; + +// /rawgoogleapp [country=vn] — Java RawGoogleAppCommand. +export function createRawGoogleAppCommand(googleScraper) { + return async (msg, sender) => { + const args = splitArgs(getCommandArguments(msg.text)); + if (args.length < 1 || args.length > 2) { + await sender.sendMessage(msg.chat.id, 'Invalid arguments'); + return; + } + const appId = args[0]; + const country = args.length === 2 ? args[1] : 'vn'; + + let raw; + try { + raw = await googleScraper.rawApp(buildGoogleRequest(appId, country)); + } catch { + raw = ''; + } + if (!raw) { + await sender.sendMessage(msg.chat.id, 'Error when request app info'); + return; + } + await sender.sendDocument(msg.chat.id, `${appId}.json`, raw); + }; +} diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..85a6c5c --- /dev/null +++ b/src/config.js @@ -0,0 +1,87 @@ +import 'dotenv/config'; +import { createLogger } from './logger.js'; + +const DEFAULT_DATABASE_NAME = 'store-scraper-bot'; + +function getEnv(key, fallback = '') { + const v = process.env[key]; + return v && v.length > 0 ? v : fallback; +} + +function getEnvInt(key, fallback) { + const v = process.env[key]; + if (!v) return fallback; + const n = Number.parseInt(v, 10); + return Number.isFinite(n) ? n : fallback; +} + +function parseAdminIds(raw) { + return raw + .split(',') + .map((s) => s.trim()) + .filter((s) => s.length > 0) + .map((s) => { + try { + return BigInt(s); + } catch { + return null; + } + }) + .filter((v) => v !== null); +} + +// Mirrors Java/Go databaseFromURI: extract db name from connection string. +function databaseFromUri(uri) { + const idx = uri.indexOf('://'); + let rest = idx >= 0 ? uri.slice(idx + 3) : uri; + const slash = rest.indexOf('/'); + if (slash < 0) return DEFAULT_DATABASE_NAME; + let tail = rest.slice(slash + 1); + const q = tail.indexOf('?'); + if (q >= 0) tail = tail.slice(0, q); + tail = tail.trim(); + return tail.length > 0 ? tail : DEFAULT_DATABASE_NAME; +} + +export function loadConfig() { + const telegramBotToken = getEnv('TELEGRAM_BOT_TOKEN'); + if (!telegramBotToken) throw new Error('TELEGRAM_BOT_TOKEN is required'); + const telegramBotUsername = getEnv('TELEGRAM_BOT_USERNAME'); + if (!telegramBotUsername) throw new Error('TELEGRAM_BOT_USERNAME is required'); + + // Java parity: prefer MONGODB_CONNECTION_STRING, fall back to MONGO_URI. + const mongoUri = getEnv('MONGODB_CONNECTION_STRING', getEnv('MONGO_URI', 'mongodb://localhost:27017')); + const mongoDatabase = getEnv('MONGO_DATABASE') || databaseFromUri(mongoUri); + const mongoTimeoutMs = getEnvInt('MONGO_TIMEOUT_SECONDS', 10) * 1000; + + const env = getEnv('ENV', 'DEVELOPMENT') === 'PRODUCTION' ? 'PRODUCTION' : 'DEVELOPMENT'; + + const adminIdsRaw = getEnv('ADMIN_IDS'); + if (!adminIdsRaw) throw new Error('ADMIN_IDS is required'); + const adminIds = parseAdminIds(adminIdsRaw); + if (adminIds.length === 0) throw new Error('at least one admin ID is required'); + + const config = { + telegramBotToken, + telegramBotUsername, + mongoUri, + mongoDatabase, + mongoTimeoutMs, + env, + adminIds, + creatorId: adminIds[0], + sourceCommit: getEnv('SOURCE_COMMIT', 'unknown'), + appCacheSeconds: getEnvInt('APP_CACHE_SECONDS', 600), + numDaysWarningNotUpdated: getEnvInt('NUM_DAYS_WARNING_NOT_UPDATED', 30), + scheduleCheckAppTime: getEnv('SCHEDULE_CHECK_APP_TIME', '0 7 * * *'), + timezone: 'Asia/Ho_Chi_Minh', + logger: createLogger(env), + }; + + config.isAdmin = (userId) => { + const id = typeof userId === 'bigint' ? userId : BigInt(userId); + return config.adminIds.some((a) => a === id); + }; + + return config; +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..bbb47ce --- /dev/null +++ b/src/index.js @@ -0,0 +1,43 @@ +import { loadConfig } from './config.js'; +import { closeMongoDB, initMongoDB } from './repository/mongodb.js'; +import { initAdmin } from './repository/admin-repository.js'; +import { createAppleScraper } from './api/apple-scraper.js'; +import { createGoogleScraper } from './api/google-scraper.js'; +import { createBot } from './bot/bot.js'; +import { createScheduler } from './scheduler/scheduler.js'; + +async function main() { + const config = loadConfig(); + const logger = config.logger; + logger.info({ env: config.env, commit: config.sourceCommit }, 'Starting Store Scraper Bot'); + + await initMongoDB(config); + await initAdmin(); // Java parity: ensure singleton "common/admin" doc exists. + + const appleScraper = createAppleScraper(config); + const googleScraper = createGoogleScraper(config); + + const bot = createBot(config, appleScraper, googleScraper); + const scheduler = createScheduler(config, bot.sender, appleScraper, googleScraper); + scheduler.start(); + + logger.info('Starting Telegram bot polling'); + + const shutdown = async (signal) => { + logger.info({ signal }, 'Received shutdown signal, stopping bot...'); + try { + scheduler.stop(); + await bot.telegram.stopPolling(); + await closeMongoDB(); + } finally { + process.exit(0); + } + }; + process.on('SIGINT', () => shutdown('SIGINT')); + process.on('SIGTERM', () => shutdown('SIGTERM')); +} + +main().catch((err) => { + console.error('Fatal:', err); + process.exit(1); +}); diff --git a/src/logger.js b/src/logger.js new file mode 100644 index 0000000..26a46e0 --- /dev/null +++ b/src/logger.js @@ -0,0 +1,16 @@ +import { pino } from 'pino'; + +export function createLogger(env) { + const isDev = env !== 'PRODUCTION'; + return pino( + isDev + ? { + level: 'debug', + transport: { + target: 'pino-pretty', + options: { colorize: true, translateTime: 'SYS:HH:MM:ss', ignore: 'pid,hostname' }, + }, + } + : { level: 'info' }, + ); +} diff --git a/src/models/admin.js b/src/models/admin.js new file mode 100644 index 0000000..2193e6a --- /dev/null +++ b/src/models/admin.js @@ -0,0 +1,23 @@ +// Admin singleton document — Java parity (_id="admin", class="Admin"). +export const ADMIN_ID = 'admin'; + +export function newAdmin() { + return { _id: ADMIN_ID, class: 'Admin', groups: [] }; +} + +export function adminAddGroup(admin, groupId) { + if (admin.groups.includes(groupId)) return false; + admin.groups.push(groupId); + return true; +} + +export function adminRemoveGroup(admin, groupId) { + const i = admin.groups.indexOf(groupId); + if (i < 0) return false; + admin.groups.splice(i, 1); + return true; +} + +export function adminHasGroup(admin, groupId) { + return admin.groups.includes(groupId); +} diff --git a/src/models/apple-app.js b/src/models/apple-app.js new file mode 100644 index 0000000..9c0fc77 --- /dev/null +++ b/src/models/apple-app.js @@ -0,0 +1,8 @@ +// AppleApp cache entry — Java parity (_id=appId, class="AppleApp"). +export function newAppleApp(appId, response, millis) { + return { _id: appId, class: 'AppleApp', app: response, millis }; +} + +export function isAppleAppExpired(entry, nowMillis, cacheMillis) { + return nowMillis - entry.millis > cacheMillis; +} diff --git a/src/models/google-app.js b/src/models/google-app.js new file mode 100644 index 0000000..b022eec --- /dev/null +++ b/src/models/google-app.js @@ -0,0 +1,8 @@ +// GoogleApp cache entry — Java parity (_id=appId, class="GoogleApp"). +export function newGoogleApp(appId, response, millis) { + return { _id: appId, class: 'GoogleApp', app: response, millis }; +} + +export function isGoogleAppExpired(entry, nowMillis, cacheMillis) { + return nowMillis - entry.millis > cacheMillis; +} diff --git a/src/models/group.js b/src/models/group.js new file mode 100644 index 0000000..00038c8 --- /dev/null +++ b/src/models/group.js @@ -0,0 +1,46 @@ +// Group document — Java parity (_id is string form of Telegram chat ID). +export function groupIdToKey(groupId) { + return String(groupId); +} + +export function groupKeyToId(key) { + return Number(key); +} + +export function newGroup(groupId) { + return { + _id: groupIdToKey(groupId), + class: 'Group', + appleApps: [], + googleApps: [], + }; +} + +function addApp(list, appId, country) { + if (list.some((a) => a.appId === appId)) return false; + list.push({ appId, country }); + return true; +} + +function removeApp(list, appId) { + const i = list.findIndex((a) => a.appId === appId); + if (i < 0) return false; + list.splice(i, 1); + return true; +} + +export function groupAddAppleApp(group, appId, country) { + return addApp(group.appleApps, appId, country); +} + +export function groupRemoveAppleApp(group, appId) { + return removeApp(group.appleApps, appId); +} + +export function groupAddGoogleApp(group, appId, country) { + return addApp(group.googleApps, appId, country); +} + +export function groupRemoveGoogleApp(group, appId) { + return removeApp(group.googleApps, appId); +} diff --git a/src/repository/admin-repository.js b/src/repository/admin-repository.js new file mode 100644 index 0000000..15312f1 --- /dev/null +++ b/src/repository/admin-repository.js @@ -0,0 +1,47 @@ +import { getCollection } from './mongodb.js'; +import { ADMIN_ID, adminAddGroup, adminHasGroup, adminRemoveGroup, newAdmin } from '../models/admin.js'; + +// Stored in "common" collection at _id="admin" (Java parity). +function collection() { + return getCollection('common'); +} + +export async function initAdmin() { + const c = collection(); + const count = await c.countDocuments({ _id: ADMIN_ID }); + if (count > 0) return; + await save(newAdmin()); +} + +export async function getAdmin() { + const doc = await collection().findOne({ _id: ADMIN_ID }); + return doc ?? newAdmin(); +} + +export async function save(admin) { + await collection().replaceOne({ _id: ADMIN_ID }, admin, { upsert: true }); +} + +export async function addGroup(groupId) { + const admin = await getAdmin(); + if (!adminAddGroup(admin, groupId)) return false; + await save(admin); + return true; +} + +export async function removeGroup(groupId) { + const admin = await getAdmin(); + if (!adminRemoveGroup(admin, groupId)) return false; + await save(admin); + return true; +} + +export async function hasGroup(groupId) { + const admin = await getAdmin(); + return adminHasGroup(admin, groupId); +} + +export async function getAllGroups() { + const admin = await getAdmin(); + return admin.groups; +} diff --git a/src/repository/apple-app-repository.js b/src/repository/apple-app-repository.js new file mode 100644 index 0000000..8453e7c --- /dev/null +++ b/src/repository/apple-app-repository.js @@ -0,0 +1,22 @@ +import { getCollection } from './mongodb.js'; +import { isAppleAppExpired } from '../models/apple-app.js'; + +function collection() { + return getCollection('apple_app'); +} + +export async function getAppleApp(appId) { + return collection().findOne({ _id: appId }); +} + +export async function saveAppleApp(entry) { + await collection().replaceOne({ _id: entry._id }, entry, { upsert: true }); +} + +export async function getCachedAppleApp(appId, appCacheSeconds) { + const entry = await getAppleApp(appId); + if (!entry) return null; + const cacheMillis = appCacheSeconds * 1000; + if (isAppleAppExpired(entry, Date.now(), cacheMillis)) return null; + return entry; +} diff --git a/src/repository/google-app-repository.js b/src/repository/google-app-repository.js new file mode 100644 index 0000000..1ddbfa3 --- /dev/null +++ b/src/repository/google-app-repository.js @@ -0,0 +1,22 @@ +import { getCollection } from './mongodb.js'; +import { isGoogleAppExpired } from '../models/google-app.js'; + +function collection() { + return getCollection('google_app'); +} + +export async function getGoogleApp(appId) { + return collection().findOne({ _id: appId }); +} + +export async function saveGoogleApp(entry) { + await collection().replaceOne({ _id: entry._id }, entry, { upsert: true }); +} + +export async function getCachedGoogleApp(appId, appCacheSeconds) { + const entry = await getGoogleApp(appId); + if (!entry) return null; + const cacheMillis = appCacheSeconds * 1000; + if (isGoogleAppExpired(entry, Date.now(), cacheMillis)) return null; + return entry; +} diff --git a/src/repository/group-repository.js b/src/repository/group-repository.js new file mode 100644 index 0000000..a740840 --- /dev/null +++ b/src/repository/group-repository.js @@ -0,0 +1,59 @@ +import { getCollection } from './mongodb.js'; +import { + groupAddAppleApp, + groupAddGoogleApp, + groupIdToKey, + groupRemoveAppleApp, + groupRemoveGoogleApp, + newGroup, +} from '../models/group.js'; + +function collection() { + return getCollection('group'); +} + +export async function exists(groupId) { + const count = await collection().countDocuments({ _id: groupIdToKey(groupId) }); + return count > 0; +} + +export async function getGroup(groupId) { + const doc = await collection().findOne({ _id: groupIdToKey(groupId) }); + return doc ?? newGroup(groupId); +} + +export async function saveGroup(group) { + await collection().replaceOne({ _id: group._id }, group, { upsert: true }); +} + +export async function initGroup(groupId) { + if (await exists(groupId)) return; + await saveGroup(newGroup(groupId)); +} + +export async function deleteGroup(groupId) { + await collection().deleteOne({ _id: groupIdToKey(groupId) }); +} + +async function mutateAndSave(groupId, mutator) { + const group = await getGroup(groupId); + if (!mutator(group)) return false; + await saveGroup(group); + return true; +} + +export function addAppleApp(groupId, appId, country) { + return mutateAndSave(groupId, (g) => groupAddAppleApp(g, appId, country)); +} + +export function removeAppleApp(groupId, appId) { + return mutateAndSave(groupId, (g) => groupRemoveAppleApp(g, appId)); +} + +export function addGoogleApp(groupId, appId, country) { + return mutateAndSave(groupId, (g) => groupAddGoogleApp(g, appId, country)); +} + +export function removeGoogleApp(groupId, appId) { + return mutateAndSave(groupId, (g) => groupRemoveGoogleApp(g, appId)); +} diff --git a/src/repository/mongodb.js b/src/repository/mongodb.js new file mode 100644 index 0000000..0bbe55a --- /dev/null +++ b/src/repository/mongodb.js @@ -0,0 +1,30 @@ +import { MongoClient } from 'mongodb'; + +let client; +let database; + +export async function initMongoDB(config) { + client = new MongoClient(config.mongoUri, { + serverSelectionTimeoutMS: config.mongoTimeoutMs, + }); + await client.connect(); + await client.db(config.mongoDatabase).command({ ping: 1 }); + database = client.db(config.mongoDatabase); + config.logger.info( + { database: config.mongoDatabase, uri: config.mongoUri }, + 'Connected to MongoDB', + ); +} + +export async function closeMongoDB() { + if (client) await client.close(); +} + +export function getDatabase() { + if (!database) throw new Error('MongoDB not initialized'); + return database; +} + +export function getCollection(name) { + return getDatabase().collection(name); +} diff --git a/src/scheduler/scheduler.js b/src/scheduler/scheduler.js new file mode 100644 index 0000000..c94d4b2 --- /dev/null +++ b/src/scheduler/scheduler.js @@ -0,0 +1,136 @@ +import cron from 'node-cron'; +import * as adminRepo from '../repository/admin-repository.js'; +import * as groupRepo from '../repository/group-repository.js'; +import { buildTable, formatNumber, truncateString } from '../util/table.js'; +import { daysBetween, formatDateInTz, formatDateTimeInTz, weekdayInTz } from '../util/time.js'; + +export function createScheduler(config, sender, appleScraper, googleScraper) { + const logger = config.logger; + let task; + + function start() { + task = cron.schedule(config.scheduleCheckAppTime, runDailyCheck, { + scheduled: true, + timezone: config.timezone, + }); + logger.info( + { schedule: config.scheduleCheckAppTime, timezone: config.timezone }, + 'Scheduler started', + ); + } + + function stop() { + if (task) task.stop(); + logger.info('Scheduler stopped'); + } + + async function runDailyCheck() { + const now = new Date(); + const dow = weekdayInTz(now, config.timezone); + const silent = dow === 0 || dow === 6; + logger.info({ silent }, 'Running daily check job'); + + let groups; + try { + groups = await adminRepo.getAllGroups(); + } catch (err) { + logger.error({ err: err.message }, 'Failed to get groups'); + return; + } + for (const gid of groups) { + try { + await checkGroup(gid, silent, now); + } catch (err) { + logger.error({ err: err.message, groupId: gid }, 'check group failed'); + } + } + logger.info({ groupsChecked: groups.length }, 'Daily check job completed'); + } + + async function checkGroup(groupId, silent, now) { + const group = await groupRepo.getGroup(groupId); + if (group.appleApps.length === 0 && group.googleApps.length === 0) { + logger.info({ groupId }, 'Group has no apps, skipping'); + return; + } + const threshold = config.numDaysWarningNotUpdated; + const stale = []; + + for (const info of group.appleApps) { + try { + const app = await appleScraper.getApp(info.appId, info.country); + if (!app) continue; + const updatedMs = Date.parse(app.updated); + if (Number.isNaN(updatedMs)) continue; + const days = daysBetween(updatedMs, now.getTime()); + if (days > threshold) { + stale.push({ + appId: info.appId, + title: app.title, + days, + updated: formatDateInTz(new Date(updatedMs), config.timezone), + score: app.score, + reviews: Number(app.reviews ?? 0), + ratings: Number(app.ratings ?? 0), + isApple: true, + }); + } + } catch (err) { + logger.error({ err: err.message, appId: info.appId }, 'Apple fetch failed'); + } + } + + for (const info of group.googleApps) { + try { + const app = await googleScraper.getApp(info.appId, info.country); + if (!app) continue; + const updatedMs = Number(app.updated); + const days = daysBetween(updatedMs, now.getTime()); + if (days > threshold) { + stale.push({ + appId: info.appId, + title: app.title, + days, + updated: formatDateInTz(new Date(updatedMs), config.timezone), + score: app.score, + reviews: Number(app.reviews ?? 0), + ratings: Number(app.ratings ?? 0), + isApple: false, + }); + } + } catch (err) { + logger.error({ err: err.message, appId: info.appId }, 'Google fetch failed'); + } + } + + if (stale.length === 0) { + logger.info({ groupId }, 'All apps up-to-date'); + return; + } + const message = buildReport(groupId, stale, now); + if (silent) await sender.sendMessageSilent(groupId, message); + else await sender.sendMessage(groupId, message); + } + + function buildReport(groupId, apps, now) { + const headers = ['App', 'Store', 'Days', 'Updated', 'Score', 'Reviews', 'Ratings']; + const rows = apps.map((a) => [ + truncateString(a.title || '', 30), + a.isApple ? 'Apple' : 'Google', + String(a.days), + a.updated, + Number(a.score ?? 0).toFixed(1), + String(a.reviews), + formatNumber(a.ratings), + ]); + return ( + `Daily App Check Report\n` + + `Date: ${formatDateTimeInTz(now, config.timezone)}\n` + + `Group: ${groupId}\n` + + `Apps not updated in >${config.numDaysWarningNotUpdated} days: ${apps.length}\n\n` + + `
${buildTable(headers, rows)}
` + ); + } + + return { start, stop, runDailyCheck }; +} diff --git a/src/util/table.js b/src/util/table.js new file mode 100644 index 0000000..77c7b37 --- /dev/null +++ b/src/util/table.js @@ -0,0 +1,66 @@ +// Mirrors Java bot/table/Table.java: +// - left-aligned columns padded to max(header, cell) width +// - "│" column separator +// - row separator inserted every 5 rows using "─" cells joined by "─┼─" +// Output is intended to be wrapped in
 for Telegram HTML rendering.
+export function buildTable(headers, rows) {
+  const widths = computeWidths(headers, rows);
+  const parts = [];
+  parts.push(writeRow(headers, widths));
+  parts.push('\n');
+  parts.push(writeSeparator(widths));
+  for (let i = 0; i < rows.length; i++) {
+    parts.push('\n');
+    if (i > 0 && i % 5 === 0) {
+      parts.push(writeSeparator(widths));
+      parts.push('\n');
+    }
+    parts.push(writeRow(rows[i], widths));
+  }
+  return parts.join('');
+}
+
+function computeWidths(headers, rows) {
+  const widths = headers.map((h) => h.length);
+  for (const row of rows) {
+    for (let i = 0; i < row.length && i < widths.length; i++) {
+      if (row[i].length > widths[i]) widths[i] = row[i].length;
+    }
+  }
+  return widths;
+}
+
+function writeRow(cells, widths) {
+  const out = [];
+  for (let i = 0; i < widths.length; i++) {
+    out.push(padRight(cells[i] ?? '', widths[i]));
+    if (i < widths.length - 1) out.push(' │ ');
+  }
+  return out.join('');
+}
+
+function writeSeparator(widths) {
+  const out = [];
+  for (let i = 0; i < widths.length; i++) {
+    out.push('─'.repeat(widths[i]));
+    if (i < widths.length - 1) out.push('─┼─');
+  }
+  return out.join('');
+}
+
+function padRight(s, len) {
+  return s.length >= len ? s : s + ' '.repeat(len - s.length);
+}
+
+export function truncateString(s, maxLen) {
+  if (s.length <= maxLen) return s;
+  if (maxLen <= 3) return s.slice(0, maxLen);
+  return s.slice(0, maxLen - 3) + '...';
+}
+
+export function formatNumber(n) {
+  const v = Number(n);
+  if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
+  if (v >= 1_000) return `${(v / 1_000).toFixed(1)}K`;
+  return String(v);
+}
diff --git a/src/util/time.js b/src/util/time.js
new file mode 100644
index 0000000..8980cc2
--- /dev/null
+++ b/src/util/time.js
@@ -0,0 +1,36 @@
+// Format date as YYYY-MM-DD in given IANA timezone.
+export function formatDateInTz(date, timezone) {
+  const fmt = new Intl.DateTimeFormat('en-CA', {
+    timeZone: timezone,
+    year: 'numeric',
+    month: '2-digit',
+    day: '2-digit',
+  });
+  return fmt.format(date);
+}
+
+// Format datetime as "YYYY-MM-DD HH:MM" in given IANA timezone.
+export function formatDateTimeInTz(date, timezone) {
+  const fmt = new Intl.DateTimeFormat('en-CA', {
+    timeZone: timezone,
+    year: 'numeric',
+    month: '2-digit',
+    day: '2-digit',
+    hour: '2-digit',
+    minute: '2-digit',
+    hour12: false,
+  });
+  // en-CA produces "YYYY-MM-DD, HH:MM" — strip the comma.
+  return fmt.format(date).replace(', ', ' ');
+}
+
+// Day-of-week (0=Sun..6=Sat) for `date` in given timezone.
+export function weekdayInTz(date, timezone) {
+  const fmt = new Intl.DateTimeFormat('en-US', { timeZone: timezone, weekday: 'short' });
+  const map = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
+  return map[fmt.format(date)];
+}
+
+export function daysBetween(fromMs, toMs) {
+  return Math.floor((toMs - fromMs) / (24 * 60 * 60 * 1000));
+}