├── .nvmrc ├── lib └── version.js ├── rsstt.png ├── .dockerignore ├── .gitattributes ├── img ├── help.png └── help_narrow.png ├── nest-cli.json ├── src ├── util │ ├── toBoolean.ts │ ├── uniqueItems.ts │ ├── axios.ts │ ├── mdLoader.ts │ ├── constants.ts │ └── config.ts ├── app.service.ts ├── context.interface.ts ├── rss │ ├── rss.events.ts │ ├── rss.event.handler.ts │ ├── rss.module.ts │ ├── rss.processor.ts │ ├── rss.service.ts │ └── rss.service.spec.ts ├── logger │ ├── logger.module.ts │ └── logger.service.ts ├── events.ts ├── statistic │ ├── statistic.module.ts │ └── statistic.service.ts ├── prisma.service.ts ├── setting │ ├── setting.module.ts │ └── setting.service.ts ├── telegram │ ├── telegram.module.ts │ ├── telegram.service.ts │ └── telegram.processor.ts ├── main.ts ├── doc │ └── help.md ├── app.module.ts ├── winston.ts └── app.update.ts ├── tsconfig.build.json ├── prisma ├── migrations │ ├── migration_lock.toml │ ├── python_to_js_migration.sql │ ├── 20230313224127_statistic_migration │ │ └── migration.sql │ ├── 2022052203000_init │ │ └── migration.sql │ ├── 20220605145537_disable_rss_feed │ │ └── migration.sql │ ├── 20230916211731_failures │ │ └── migration.sql │ └── 20220522034158_multichat │ │ └── migration.sql └── schema.prisma ├── .env.example ├── .release-it.json ├── tsconfig.json ├── Dockerfile ├── start_bot.sh ├── carbon-config-for-website-only.json ├── .gitignore ├── .github └── workflows │ ├── develop.yaml │ └── master.yaml ├── scripts └── generate_image.ts ├── README.md ├── carbon-config.json ├── package.json └── LICENSE /.nvmrc: -------------------------------------------------------------------------------- 1 | v16.14.2 -------------------------------------------------------------------------------- /lib/version.js: -------------------------------------------------------------------------------- 1 | // Generated by genversion. 2 | module.exports = '2.0.2' 3 | -------------------------------------------------------------------------------- /rsstt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoKKeR/RSS-to-Telegram-Bot/HEAD/rsstt.png -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | infrastructure/* 4 | config/* 5 | .env -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /img/help.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoKKeR/RSS-to-Telegram-Bot/HEAD/img/help.png -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "sourceRoot": "src" 4 | } 5 | -------------------------------------------------------------------------------- /img/help_narrow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoKKeR/RSS-to-Telegram-Bot/HEAD/img/help_narrow.png -------------------------------------------------------------------------------- /src/util/toBoolean.ts: -------------------------------------------------------------------------------- 1 | export function toBoolean(myValue: string) { 2 | return myValue === "true"; 3 | } 4 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "sqlite" -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | TOKEN=telegram:token 2 | CHATID=-1234 3 | DEBUG=true 4 | REDIS_HOST=127.0.0.1 5 | REDIS_PORT=6379 6 | REDIS_PASSWORD=secret 7 | REDIS_USER=default 8 | REDIS_MUTEX=random-value 9 | -------------------------------------------------------------------------------- /src/app.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@nestjs/common"; 2 | 3 | @Injectable() 4 | export class AppService { 5 | getHello(): string { 6 | return "Hello World!"; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/context.interface.ts: -------------------------------------------------------------------------------- 1 | import { Scenes } from "telegraf"; 2 | 3 | // eslint-disable-next-line @typescript-eslint/no-empty-interface 4 | export interface Context extends Scenes.SceneContext {} 5 | -------------------------------------------------------------------------------- /.release-it.json: -------------------------------------------------------------------------------- 1 | { 2 | "git": { 3 | "commitMessage": "chore: release v${version}", 4 | "tag": true 5 | }, 6 | "github": { 7 | "release": true, 8 | "releaseName": "Release ${version}" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/rss/rss.events.ts: -------------------------------------------------------------------------------- 1 | export interface RssEvents { 2 | disableAllFeeds: { chatId: number; disable: boolean }; 3 | disableFeed: { chatId: number; name: string; disable: boolean }; 4 | migrateChat: { chatId: number; newChatId: number }; 5 | } 6 | -------------------------------------------------------------------------------- /src/util/uniqueItems.ts: -------------------------------------------------------------------------------- 1 | const uniqueItems = (list, keyFn) => 2 | list.reduce( 3 | (resultSet, item) => 4 | resultSet.add(typeof keyFn === "string" ? item[keyFn] : keyFn(item)), 5 | new Set() 6 | ).size; 7 | 8 | export default uniqueItems; 9 | -------------------------------------------------------------------------------- /src/logger/logger.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common"; 2 | import { CustomLoggerService } from "./logger.service"; 3 | 4 | @Module({ 5 | providers: [CustomLoggerService], 6 | exports: [CustomLoggerService] 7 | }) 8 | export class CustomLoggerModule {} 9 | -------------------------------------------------------------------------------- /src/util/axios.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosResponse } from "axios"; 2 | 3 | export const axiosInstance = axios.create(); 4 | 5 | export const getFeedData = async ( 6 | url: string 7 | ): Promise> => { 8 | const { data } = await axios.get(url); 9 | return data; 10 | }; 11 | -------------------------------------------------------------------------------- /prisma/migrations/python_to_js_migration.sql: -------------------------------------------------------------------------------- 1 | create table rss_copy(id integer unique primary key autoincrement, link text NOT NULL unique, last text NOT NULL, name text NOT NULL unique); 2 | insert into rss_copy(link, last, name) select link, last, name from rss; 3 | drop table rss; 4 | alter table rss_copy rename to rss; 5 | -------------------------------------------------------------------------------- /src/events.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "events"; 2 | import { StrictEventEmitter } from "nest-emitter"; 3 | import { RssEvents } from "./rss/rss.events"; 4 | 5 | // import and add events to the AppEvents type 6 | type AppEvents = RssEvents; 7 | 8 | export type EventEmitterType = StrictEventEmitter; 9 | -------------------------------------------------------------------------------- /src/statistic/statistic.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common"; 2 | import { PrismaService } from "src/prisma.service"; 3 | import { StatisticService } from "./statistic.service"; 4 | 5 | @Module({ 6 | controllers: [], 7 | providers: [StatisticService, PrismaService], 8 | exports: [StatisticService] 9 | }) 10 | export class StatisticModule {} 11 | -------------------------------------------------------------------------------- /prisma/migrations/20230313224127_statistic_migration/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "statistic" ( 3 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 4 | "chat_id" INTEGER NOT NULL, 5 | "count" INTEGER NOT NULL, 6 | "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP 7 | ); 8 | 9 | -- CreateIndex 10 | CREATE UNIQUE INDEX "statistic_id_key" ON "statistic"("id"); 11 | -------------------------------------------------------------------------------- /src/util/mdLoader.ts: -------------------------------------------------------------------------------- 1 | const fs = require("fs").promises; 2 | import * as path from "path"; 3 | 4 | const mdLoader = async (filename: string) => { 5 | try { 6 | const md = await fs.readFile( 7 | path.join(__dirname, "../doc", filename + ".md"), 8 | "utf-8" 9 | ); 10 | 11 | return md; 12 | } catch (error) { 13 | return "placeholder help"; 14 | } 15 | }; 16 | 17 | export default mdLoader; 18 | -------------------------------------------------------------------------------- /src/prisma.service.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication, Injectable, OnModuleInit } from "@nestjs/common"; 2 | import { PrismaClient } from "@prisma/client"; 3 | 4 | @Injectable() 5 | export class PrismaService extends PrismaClient implements OnModuleInit { 6 | async onModuleInit() { 7 | await this.$connect(); 8 | } 9 | 10 | async enableShutdownHooks(app: INestApplication) { 11 | this.$on("beforeExit", async () => { 12 | await app.close(); 13 | }); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/util/constants.ts: -------------------------------------------------------------------------------- 1 | const env = { 2 | REDIS_HOST: process.env.REDIS_HOST, 3 | REDIS_PASSWORD: process.env.REDIS_PASSWORD, 4 | REDIS_PORT: parseInt(process.env.REDIS_PORT), 5 | REDIS_ENV: process.env.REDIS_ENV, 6 | REDIS_USER: process.env.REDIS_USER, 7 | REDIS_MUTEX: process.env.REDIS_MUTEX 8 | }; 9 | 10 | const queue = { 11 | messages: `messages_${env.REDIS_MUTEX}`, 12 | repeatableFeed: `repeatableFeed_${env.REDIS_MUTEX}` 13 | }; 14 | 15 | export default { env, queue }; 16 | -------------------------------------------------------------------------------- /src/logger/logger.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Scope, ConsoleLogger } from "@nestjs/common"; 2 | import { getLogger } from "src/winston"; 3 | 4 | const winston = getLogger(); 5 | 6 | @Injectable({ scope: Scope.TRANSIENT }) 7 | export class CustomLoggerService extends ConsoleLogger { 8 | debug(message: any, ...optionalParams: any[]) { 9 | if (process.env.DEBUG === "true") { 10 | winston.debug(message); 11 | super.debug(message, ...optionalParams); 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "module": "commonjs", 5 | "declaration": true, 6 | "removeComments": true, 7 | "emitDecoratorMetadata": true, 8 | "experimentalDecorators": true, 9 | "allowSyntheticDefaultImports": true, 10 | "target": "es2021", 11 | "sourceMap": true, 12 | "outDir": "./dist", 13 | "baseUrl": "./", 14 | "incremental": true, 15 | "skipLibCheck": true, 16 | "resolveJsonModule": true 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/setting/setting.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common"; 2 | import { CustomLoggerModule } from "src/logger/logger.module"; 3 | import { PrismaService } from "../prisma.service"; 4 | import { TelegramModule } from "../telegram/telegram.module"; 5 | import { SettingService } from "./setting.service"; 6 | 7 | @Module({ 8 | imports: [TelegramModule, CustomLoggerModule], 9 | controllers: [], 10 | providers: [SettingService, PrismaService], 11 | exports: [SettingService] 12 | }) 13 | export class SettingModule {} 14 | -------------------------------------------------------------------------------- /src/util/config.ts: -------------------------------------------------------------------------------- 1 | import { version } from "../../package.json"; 2 | 3 | export const delay = parseInt(process.env.DELAY) 4 | ? parseInt(process.env.DELAY) 5 | : 120; 6 | 7 | export const chatid = process.env.CHATID && parseInt(process.env.CHATID); 8 | export const adminchatid = 9 | process.env.ADMIN_CHATID && parseInt(process.env.ADMIN_CHATID); 10 | 11 | export const logLevel = 12 | process.env.DEBUG === "true" 13 | ? ["error", "warn", "debug", "log"] 14 | : ["error", "warn", "log"]; 15 | 16 | export const packageVersion = version ? version : "not_found"; 17 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Docker Parent Image with Node 2 | FROM mhart/alpine-node:16 AS builder 3 | WORKDIR /app 4 | COPY . . 5 | RUN mkdir node_modules 6 | RUN npm ci --omit=dev 7 | RUN npm run build 8 | 9 | FROM mhart/alpine-node:16 10 | WORKDIR /app 11 | RUN mkdir config 12 | COPY --from=builder app/lib/ ./lib/ 13 | COPY --from=builder app/dist/ ./dist/ 14 | COPY --from=builder app/package.json ./package.json 15 | COPY --from=builder app/node_modules/ ./node_modules/ 16 | COPY ./prisma ./prisma/ 17 | COPY ./start_bot.sh . 18 | 19 | # migrate database 20 | RUN apk add sqlite 21 | ENTRYPOINT ["./start_bot.sh"] 22 | -------------------------------------------------------------------------------- /src/telegram/telegram.module.ts: -------------------------------------------------------------------------------- 1 | import { BullModule } from "@nestjs/bull"; 2 | import { Module } from "@nestjs/common"; 3 | import { CustomLoggerModule } from "src/logger/logger.module"; 4 | import constants from "src/util/constants"; 5 | import { TelegramProcessor } from "./telegram.processor"; 6 | import { TelegramService } from "./telegram.service"; 7 | 8 | @Module({ 9 | imports: [ 10 | BullModule.registerQueue({ 11 | name: constants.queue.messages 12 | }), 13 | CustomLoggerModule 14 | ], 15 | controllers: [], 16 | providers: [TelegramService, TelegramProcessor], 17 | exports: [TelegramService] 18 | }) 19 | export class TelegramModule {} 20 | -------------------------------------------------------------------------------- /prisma/migrations/2022052203000_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "rss" ( 3 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 4 | "link" TEXT NOT NULL, 5 | "last" TEXT NOT NULL, 6 | "name" TEXT NOT NULL 7 | ); 8 | 9 | -- CreateIndex 10 | Pragma writable_schema=1; 11 | CREATE UNIQUE INDEX "sqlite_autoindex_rss_1" ON "rss"("id"); 12 | Pragma writable_schema=0; 13 | 14 | -- CreateIndex 15 | Pragma writable_schema=1; 16 | CREATE UNIQUE INDEX "sqlite_autoindex_rss_2" ON "rss"("link"); 17 | Pragma writable_schema=0; 18 | 19 | -- CreateIndex 20 | Pragma writable_schema=1; 21 | CREATE UNIQUE INDEX "sqlite_autoindex_rss_3" ON "rss"("name"); 22 | Pragma writable_schema=0; 23 | 24 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from "@nestjs/core"; 2 | import { AppModule } from "./app.module"; 3 | import { PrismaService } from "./prisma.service"; 4 | import * as fs from "fs"; 5 | import { logLevel } from "./util/config"; 6 | 7 | async function bootstrap() { 8 | const conf_dir = "./config"; 9 | 10 | fs.mkdir(conf_dir, { recursive: true }, (err) => { 11 | if (err) throw err; 12 | }); 13 | 14 | // @ts-ignore 15 | const app = await NestFactory.create(AppModule, { 16 | logger: logLevel 17 | }); 18 | 19 | const prismaService: PrismaService = app.get(PrismaService); 20 | prismaService.enableShutdownHooks(app); 21 | await app.listen(process.env.PORT ? process.env.PORT : 3300); 22 | } 23 | 24 | bootstrap(); 25 | -------------------------------------------------------------------------------- /prisma/migrations/20220605145537_disable_rss_feed/migration.sql: -------------------------------------------------------------------------------- 1 | -- RedefineTables 2 | PRAGMA foreign_keys=OFF; 3 | CREATE TABLE "new_rss" ( 4 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 5 | "link" TEXT NOT NULL, 6 | "last" TEXT NOT NULL, 7 | "name" TEXT NOT NULL, 8 | "chat_id" INTEGER NOT NULL DEFAULT 0, 9 | "disabled" BOOLEAN NOT NULL DEFAULT false 10 | ); 11 | INSERT INTO "new_rss" ("chat_id", "id", "last", "link", "name") SELECT "chat_id", "id", "last", "link", "name" FROM "rss"; 12 | DROP TABLE "rss"; 13 | ALTER TABLE "new_rss" RENAME TO "rss"; 14 | Pragma writable_schema=1; 15 | CREATE UNIQUE INDEX "sqlite_autoindex_rss_1" ON "rss"("id"); 16 | Pragma writable_schema=0; 17 | PRAGMA foreign_key_check; 18 | PRAGMA foreign_keys=ON; 19 | -------------------------------------------------------------------------------- /start_bot.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | 4 | PYTHON_LEGACY_DATABASE=config/rss.db 5 | PYTHON_LEGACY_DATABASE_BACKUP=config/backup_python_rss.db 6 | 7 | JS_DATABASE=config/rss_bot_database.db 8 | 9 | PRISMA=./node_modules/.bin/prisma 10 | 11 | if test -f "$PYTHON_LEGACY_DATABASE"; then 12 | echo "INFO: Old database exists:$PYTHON_LEGACY_DATABASE, backing it up as backup_python_rss.db" 13 | 14 | cp $PYTHON_LEGACY_DATABASE $PYTHON_LEGACY_DATABASE_BACKUP 15 | 16 | sqlite3 $PYTHON_LEGACY_DATABASE < prisma/migrations/python_to_js_migration.sql 17 | 18 | mv $PYTHON_LEGACY_DATABASE $JS_DATABASE 19 | 20 | $PRISMA migrate resolve --applied init 21 | $PRISMA migrate depoy 22 | fi 23 | 24 | 25 | $PRISMA migrate deploy 26 | 27 | node --max_old_space_size=1024 dist/src/main.js -------------------------------------------------------------------------------- /prisma/migrations/20230916211731_failures/migration.sql: -------------------------------------------------------------------------------- 1 | -- RedefineTables 2 | PRAGMA foreign_keys=OFF; 3 | CREATE TABLE "new_rss" ( 4 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 5 | "link" TEXT NOT NULL, 6 | "last" TEXT NOT NULL, 7 | "name" TEXT NOT NULL, 8 | "chat_id" INTEGER NOT NULL DEFAULT 0, 9 | "disabled" BOOLEAN NOT NULL DEFAULT false, 10 | "failures" TEXT NOT NULL DEFAULT '[]' 11 | ); 12 | INSERT INTO "new_rss" ("chat_id", "disabled", "id", "last", "link", "name") SELECT "chat_id", "disabled", "id", "last", "link", "name" FROM "rss"; 13 | DROP TABLE "rss"; 14 | ALTER TABLE "new_rss" RENAME TO "rss"; 15 | Pragma writable_schema=1; 16 | CREATE UNIQUE INDEX "sqlite_autoindex_rss_1" ON "rss"("id"); 17 | Pragma writable_schema=0; 18 | PRAGMA foreign_key_check; 19 | PRAGMA foreign_keys=ON; 20 | -------------------------------------------------------------------------------- /carbon-config-for-website-only.json: -------------------------------------------------------------------------------- 1 | { 2 | "paddingVertical": "30px", 3 | "paddingHorizontal": "39px", 4 | "backgroundImage": null, 5 | "backgroundImageSelection": null, 6 | "backgroundMode": "image", 7 | "backgroundColor": "rgba(74,144,226,1)", 8 | "dropShadow": false, 9 | "dropShadowOffsetY": "20px", 10 | "dropShadowBlurRadius": "68px", 11 | "theme": "theme:1u9hekzm6o1", 12 | "windowTheme": "none", 13 | "language": "markdown", 14 | "fontFamily": "OpenSans-VariableFont_wdth,wght", 15 | "fontSize": "14.5px", 16 | "lineHeight": "140%", 17 | "windowControls": false, 18 | "widthAdjustment": false, 19 | "lineNumbers": false, 20 | "firstLineNumber": 1, 21 | "exportSize": "2x", 22 | "watermark": false, 23 | "squaredImage": false, 24 | "hiddenCharacters": false, 25 | "name": "", 26 | "width": 789, 27 | "highlights": null 28 | } 29 | -------------------------------------------------------------------------------- /src/rss/rss.event.handler.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, OnModuleInit } from "@nestjs/common"; 2 | import { InjectEventEmitter } from "nest-emitter"; 3 | import { EventEmitterType } from "../events"; 4 | import { RssService } from "./rss.service"; 5 | 6 | @Injectable() 7 | export class RssEventHandler implements OnModuleInit { 8 | constructor( 9 | @InjectEventEmitter() private readonly emitter: EventEmitterType, 10 | private readonly rssService: RssService 11 | ) {} 12 | 13 | onModuleInit() { 14 | this.emitter.on("disableAllFeeds", async (dto) => { 15 | await this.rssService.disableAllFeeds(dto); 16 | }); 17 | 18 | this.emitter.on("disableFeed", async (dto) => { 19 | await this.rssService.disableFeed(dto); 20 | }); 21 | 22 | this.emitter.on("migrateChat", async (dto) => { 23 | await this.rssService.migrateChat(dto); 24 | }); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | lib-cov 2 | *.seed 3 | *.log 4 | *.csv 5 | *.dat 6 | *.out 7 | *.pid 8 | *.gz 9 | *.swp 10 | 11 | pids 12 | logs 13 | results 14 | tmp 15 | 16 | # Build 17 | public/css/main.css 18 | 19 | # Coverage reports 20 | coverage 21 | 22 | # Dependency directory 23 | node_modules 24 | bower_components 25 | 26 | # Editors 27 | .idea 28 | *.iml 29 | 30 | # OS metadata 31 | .DS_Store 32 | Thumbs.db 33 | 34 | # Ignore built ts files 35 | dist/**/* 36 | 37 | # ignore yarn.lock 38 | yarn.lock 39 | 40 | 41 | yarn-error.log/ 42 | vendor/ 43 | tmp/ 44 | dist/ 45 | .DS_Store 46 | .idea 47 | tmp-resolvers 48 | package-lock.json 49 | flow-typed 50 | yarn.lock 51 | javascript/**/migrations/ 52 | typescript/**/migrations/ 53 | misc/**/migrations/ 54 | databases/**/migrations/ 55 | experimental/**/*.db 56 | .next 57 | .vscode/ 58 | .nuxt 59 | config/* 60 | *.env 61 | *.env.production 62 | *.env.staging 63 | *.env.local -------------------------------------------------------------------------------- /src/doc/help.md: -------------------------------------------------------------------------------- 1 | RSS to Telegram bot *vVERSION_PLACEHOLDER* 2 | 3 | After successfully adding a RSS link, the bot starts fetching the feed in a given interval. (This can be changed with */settings*) 4 | 5 | commands: 6 | */help* shows this help message 7 | */add* title rss-link to add a new link 8 | */remove* link-name removes the RSS link, multiple links can be removed with one command 9 | */list* Lists all the titles and the RSS links from the DB 10 | */settings* Lists all the settings and allows you to change them 11 | */disable* title boolean disable/disable specific feed. 12 | */disable_all* boolean disable/disable all feeds. 13 | */test* Inbuilt command that fetches a post from Reddits RSS. 14 | 15 | The current chatId is: *CHATID_PLACEHOLDER* 16 | 17 | If you like the project, ⭐ it on [DockerHub](https://hub.docker.com/r/bokker/rss.to.telegram) / [GitHub](https://www.github.com/BoKKeR/RSS-to-Telegram-Bot) -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | binaryTargets = ["native", "debian-openssl-1.1.x"] 4 | output = "../node_modules/.prisma/client" 5 | } 6 | 7 | datasource db { 8 | provider = "sqlite" 9 | url = "file:./../config/rss_bot_database.db" 10 | } 11 | 12 | model rss { 13 | id Int @id @unique(map: "sqlite_autoindex_rss_1") @default(autoincrement()) 14 | link String 15 | last String 16 | name String 17 | chat_id Int @default(0) 18 | disabled Boolean @default(false) 19 | failures String @default("[]") 20 | } 21 | 22 | model setting { 23 | id Int @id @unique @default(autoincrement()) 24 | show_changelog Boolean @default(true) 25 | last_version String 26 | chat_id Int @unique 27 | delay Int 28 | } 29 | 30 | model statistic { 31 | id Int @id @unique @default(autoincrement()) 32 | chat_id Int 33 | count Int 34 | created_at DateTime @default(now()) 35 | } 36 | -------------------------------------------------------------------------------- /src/rss/rss.module.ts: -------------------------------------------------------------------------------- 1 | import { BullModule } from "@nestjs/bull"; 2 | import { Module } from "@nestjs/common"; 3 | import { CustomLoggerModule } from "src/logger/logger.module"; 4 | import { StatisticModule } from "src/statistic/statistic.module"; 5 | import constants from "src/util/constants"; 6 | import { PrismaService } from "../prisma.service"; 7 | import { TelegramModule } from "../telegram/telegram.module"; 8 | import { RssEventHandler } from "./rss.event.handler"; 9 | import { RssService } from "./rss.service"; 10 | import { RssProcessor } from "./rss.processor"; 11 | 12 | @Module({ 13 | imports: [ 14 | TelegramModule, 15 | StatisticModule, 16 | CustomLoggerModule, 17 | BullModule.registerQueue({ 18 | name: constants.queue.messages 19 | }), 20 | BullModule.registerQueue({ 21 | name: constants.queue.repeatableFeed 22 | }) 23 | ], 24 | controllers: [], 25 | providers: [RssService, PrismaService, RssEventHandler, RssProcessor], 26 | exports: [RssService] 27 | }) 28 | export class RssModule {} 29 | -------------------------------------------------------------------------------- /prisma/migrations/20220522034158_multichat/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "setting" ( 3 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 4 | "show_changelog" BOOLEAN NOT NULL DEFAULT true, 5 | "last_version" TEXT NOT NULL, 6 | "chat_id" INTEGER NOT NULL, 7 | "delay" INTEGER NOT NULL 8 | ); 9 | 10 | -- RedefineTables 11 | PRAGMA foreign_keys=OFF; 12 | CREATE TABLE "new_rss" ( 13 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 14 | "link" TEXT NOT NULL, 15 | "last" TEXT NOT NULL, 16 | "name" TEXT NOT NULL, 17 | "chat_id" INTEGER NOT NULL DEFAULT 0 18 | ); 19 | INSERT INTO "new_rss" ("id", "last", "link", "name") SELECT "id", "last", "link", "name" FROM "rss"; 20 | DROP TABLE "rss"; 21 | ALTER TABLE "new_rss" RENAME TO "rss"; 22 | Pragma writable_schema=1; 23 | CREATE UNIQUE INDEX "sqlite_autoindex_rss_1" ON "rss"("id"); 24 | Pragma writable_schema=0; 25 | PRAGMA foreign_key_check; 26 | PRAGMA foreign_keys=ON; 27 | 28 | -- CreateIndex 29 | CREATE UNIQUE INDEX "setting_id_key" ON "setting"("id"); 30 | 31 | -- CreateIndex 32 | CREATE UNIQUE INDEX "setting_chat_id_key" ON "setting"("chat_id"); 33 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common"; 2 | import { AppService } from "./app.service"; 3 | import { ConfigModule } from "@nestjs/config"; 4 | import { TelegrafModule } from "nestjs-telegraf"; 5 | import { AppUpdate } from "./app.update"; 6 | import { RssModule } from "./rss/rss.module"; 7 | import { ScheduleModule } from "@nestjs/schedule"; 8 | import { SettingModule } from "./setting/setting.module"; 9 | import { NestEmitterModule } from "nest-emitter"; 10 | import { EventEmitter } from "events"; 11 | import { BullModule } from "@nestjs/bull"; 12 | import { StatisticModule } from "./statistic/statistic.module"; 13 | import constants from "./util/constants"; 14 | 15 | @Module({ 16 | imports: [ 17 | NestEmitterModule.forRoot(new EventEmitter()), 18 | ConfigModule.forRoot(), 19 | ScheduleModule.forRoot(), 20 | TelegrafModule.forRoot({ 21 | token: process.env.TOKEN 22 | }), 23 | RssModule, 24 | SettingModule, 25 | StatisticModule, 26 | BullModule.forRoot({ 27 | redis: { 28 | host: constants.env.REDIS_HOST, 29 | port: constants.env.REDIS_PORT, 30 | password: constants.env.REDIS_PASSWORD, 31 | username: constants.env.REDIS_USER 32 | ? constants.env.REDIS_USER 33 | : "default" 34 | } 35 | }) 36 | ], 37 | controllers: [], 38 | providers: [AppService, AppUpdate] 39 | }) 40 | export class AppModule {} 41 | -------------------------------------------------------------------------------- /src/winston.ts: -------------------------------------------------------------------------------- 1 | import { createLogger, transports, format, Logger } from "winston"; 2 | import LokiTransport from "winston-loki"; 3 | 4 | let logger: Logger; 5 | 6 | const initializeLogger = () => { 7 | if (logger) { 8 | return; 9 | } 10 | 11 | const baseTransports: any = [ 12 | new transports.Console({ 13 | format: format.combine( 14 | format.errors({ stack: true }), 15 | format.colorize(), 16 | format.timestamp(), 17 | format.printf((info) => { 18 | const { timestamp, level, message, ...args } = info; 19 | 20 | const ts = timestamp.slice(0, 19).replace("T", " "); 21 | return `${ts} [${level}]: ${message} ${ 22 | Object.keys(args).length ? JSON.stringify(args, null, 2) : "" 23 | }`; 24 | }) 25 | ) 26 | }) 27 | ]; 28 | 29 | if (process.env.LOKI_HOST && process.env.LOKI_APP) { 30 | baseTransports.push( 31 | new LokiTransport({ 32 | host: process.env.LOKI_HOST, 33 | labels: { app: process.env.LOKI_APP }, 34 | json: true, 35 | format: format.json(), 36 | replaceTimestamp: true, 37 | onConnectionError: (err) => console.error(err) 38 | }) 39 | ); 40 | } 41 | 42 | logger = createLogger({ 43 | transports: baseTransports 44 | }); 45 | 46 | if (process.env.DEBUG === "true") { 47 | logger.level = "debug"; 48 | } else { 49 | logger.level = "info"; 50 | } 51 | }; 52 | 53 | export const getLogger = () => { 54 | initializeLogger(); 55 | return logger; 56 | }; 57 | -------------------------------------------------------------------------------- /.github/workflows/develop.yaml: -------------------------------------------------------------------------------- 1 | name: develop 2 | 3 | on: 4 | push: 5 | branches: 6 | - "develop" 7 | 8 | jobs: 9 | setup: 10 | runs-on: ubuntu-latest 11 | env: 12 | GITHUB_TOKEN: ${{secrets.RUNNER_GITHUB_TOKEN}} 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: actions/setup-node@v3 16 | with: 17 | node-version: "16.x" 18 | - run: | 19 | git config --global user.email "bokker11@hotmail.com" 20 | git config --global user.name "Norbert Takács" 21 | - run: npm ci 22 | # - run: npm test 23 | # - run: node node_modules/.bin/release-it --ci --preRelease=alpha 24 | 25 | - name: get-npm-version 26 | id: package-version 27 | uses: martinbeentjes/npm-get-version-action@master 28 | 29 | - name: Set up QEMU 30 | uses: docker/setup-qemu-action@v2.1.0 31 | 32 | - name: Set up Docker Buildx 33 | uses: docker/setup-buildx-action@v2.4.1 34 | 35 | - name: Login to DockerHub 36 | uses: docker/login-action@v2.1.0 37 | with: 38 | username: ${{ secrets.DOCKERHUB_USERNAME }} 39 | password: ${{ secrets.DOCKERHUB_TOKEN }} 40 | 41 | - name: Build and push 42 | uses: docker/build-push-action@v4 43 | with: 44 | push: true 45 | tags: bokker/rss.to.telegram:develop,bokker/rss.to.telegram:${{ steps.package-version.outputs.current-version}}-alpha 46 | 47 | update-stack: 48 | needs: setup 49 | runs-on: ubuntu-latest 50 | steps: 51 | - name: Update deployed stack 52 | run: | 53 | curl --fail --insecure --request POST --url ${{ secrets.DEV_HOOK }} 54 | -------------------------------------------------------------------------------- /.github/workflows/master.yaml: -------------------------------------------------------------------------------- 1 | name: master 2 | 3 | on: 4 | push: 5 | branches: 6 | - "master" 7 | 8 | jobs: 9 | setup: 10 | runs-on: ubuntu-latest 11 | env: 12 | GITHUB_TOKEN: ${{secrets.RUNNER_GITHUB_TOKEN}} 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: actions/setup-node@v3 16 | with: 17 | node-version: "16.x" 18 | - run: | 19 | git config --global user.email "bokker11@hotmail.com" 20 | git config --global user.name "Norbert Takács" 21 | - run: npm install 22 | # - run: npm test 23 | - run: node node_modules/.bin/release-it --ci 24 | 25 | - name: get-npm-version 26 | id: package-version 27 | uses: martinbeentjes/npm-get-version-action@master 28 | 29 | - name: Set up QEMU 30 | uses: docker/setup-qemu-action@v2.1.0 31 | 32 | - name: Set up Docker Buildx 33 | uses: docker/setup-buildx-action@v2.4.1 34 | 35 | - name: Login to DockerHub 36 | uses: docker/login-action@v2.1.0 37 | with: 38 | username: ${{ secrets.DOCKERHUB_USERNAME }} 39 | password: ${{ secrets.DOCKERHUB_TOKEN }} 40 | 41 | - name: Build and push 42 | uses: docker/build-push-action@v4 43 | with: 44 | push: true 45 | tags: bokker/rss.to.telegram:latest,bokker/rss.to.telegram:${{ steps.package-version.outputs.current-version}} 46 | - name: Docker Hub Description 47 | uses: peter-evans/dockerhub-description@v3 48 | with: 49 | username: ${{ secrets.DOCKERHUB_USERNAME }} 50 | password: ${{ secrets.DOCKERHUB_TOKEN }} 51 | repository: peterevans/dockerhub-description 52 | -------------------------------------------------------------------------------- /src/statistic/statistic.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@nestjs/common"; 2 | import { PrismaService } from "src/prisma.service"; 3 | import { statistic, Prisma } from "@prisma/client"; 4 | import { getLogger } from "src/winston"; 5 | 6 | const winston = getLogger(); 7 | @Injectable() 8 | export class StatisticService { 9 | constructor(private prisma: PrismaService) {} 10 | 11 | dateStringSuffix = (dateOffsetDays?: number) => { 12 | const date = dateOffsetDays 13 | ? new Date(new Date().setDate(new Date().getDate() - dateOffsetDays)) 14 | : new Date(); 15 | const iso = date.toISOString(); 16 | return iso.substring(0, iso.indexOf("T")); 17 | }; 18 | 19 | async create(data: Prisma.statisticCreateInput): Promise { 20 | const startDate = new Date(); 21 | startDate.setUTCHours(0, 0, 0, 0); 22 | 23 | const endDate = new Date(); 24 | endDate.setUTCHours(23, 59, 59, 999); 25 | 26 | const record = await this.prisma.statistic.findFirst({ 27 | where: { 28 | chat_id: data.chat_id, 29 | AND: [ 30 | { created_at: { gte: startDate.toISOString() } }, 31 | { created_at: { lte: endDate.toISOString() } } 32 | ] 33 | } 34 | }); 35 | 36 | try { 37 | if (record) { 38 | await this.prisma.statistic.update({ 39 | where: { id: record.id }, 40 | data: { count: record.count + data.count } 41 | }); 42 | } else { 43 | return this.prisma.statistic.create({ 44 | data 45 | }); 46 | } 47 | } catch (error) { 48 | winston.error(error); 49 | } 50 | } 51 | 52 | async getStats() { 53 | return this.prisma.statistic.groupBy({ 54 | by: ["chat_id"], 55 | _sum: { 56 | count: true 57 | } 58 | }); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /scripts/generate_image.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import { exec } from "child_process"; 3 | 4 | const MARKDOWN_FOLDER = "src/doc"; 5 | const OUTPUT_FOLDER = "img"; 6 | 7 | const TEMP_FOLDER = "tmp"; 8 | 9 | const PRESET_NARROW = "narrow"; 10 | const PRESET_WIDE = "wide"; 11 | 12 | fs.readdir(MARKDOWN_FOLDER, (err, files) => { 13 | files.forEach(async (file) => { 14 | const INPUT_FILE_PATH = `${MARKDOWN_FOLDER}/${file}`; 15 | const INPUT_FILE = file.split(".")[0]; 16 | const INPUT_FILE_EXTENSTION = file.split(".")[1]; 17 | 18 | const TEMP_FILE_READ = fs 19 | .readFileSync(INPUT_FILE_PATH) 20 | .toString() 21 | .replaceAll("*", "**") 22 | .replace("vVERSION_PLACEHOLDER", "v2.10") 23 | .replace("(https://hub.docker.com/r/bokker/rss.to.telegram)", "") 24 | .replace("(https://www.github.com/BoKKeR/RSS-to-Telegram-Bot)", "") 25 | .replace("**CHATID_PLACEHOLDER**", "1234"); 26 | 27 | const TEMP_FILE = `${TEMP_FOLDER}/${INPUT_FILE}.${INPUT_FILE_EXTENSTION}`; 28 | fs.writeFileSync(TEMP_FILE, TEMP_FILE_READ); 29 | 30 | renderImage(TEMP_FILE, INPUT_FILE, PRESET_WIDE); 31 | renderImage(TEMP_FILE, `${INPUT_FILE}_narrow`, PRESET_NARROW); 32 | }); 33 | }); 34 | 35 | const renderImage = (TEMP_FILE: string, INPUT_FILE: string, PRESET: string) => { 36 | exec( 37 | `./node_modules/.bin/carbon-now \ 38 | --headless \ 39 | --config ./carbon-config.json \ 40 | -p ${PRESET} ${TEMP_FILE} \ 41 | -t ${INPUT_FILE} \ 42 | --save-to ${OUTPUT_FOLDER} \ 43 | --skip-display \ 44 | --save-as ${INPUT_FILE}`, 45 | (error, stdout, stderr) => { 46 | if (error) { 47 | console.log(`error: ${error.message}`); 48 | return; 49 | } 50 | if (stderr) { 51 | console.log(`stderr: ${stderr}`); 52 | return; 53 | } 54 | console.log(`stdout: ${stdout}`); 55 | } 56 | ); 57 | }; 58 | -------------------------------------------------------------------------------- /src/rss/rss.processor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Process, 3 | Processor, 4 | OnQueueFailed, 5 | OnQueueStalled, 6 | OnGlobalQueuePaused, 7 | InjectQueue 8 | } from "@nestjs/bull"; 9 | import { DoneCallback, FailedEventCallback, Job, Queue } from "bull"; 10 | import constants from "src/util/constants"; 11 | 12 | import { RssService } from "./rss.service"; 13 | import { getLogger } from "src/winston"; 14 | import { rss } from "@prisma/client"; 15 | 16 | const winston = getLogger(); 17 | 18 | @Processor(constants.queue.repeatableFeed) 19 | export class RssProcessor { 20 | constructor( 21 | private readonly rssService: RssService, 22 | @InjectQueue(constants.queue.repeatableFeed) 23 | private repeatableFeedQueue: Queue 24 | ) {} 25 | 26 | @Process("feed") 27 | async processName( 28 | job: Job, 29 | done: DoneCallback, 30 | fail: FailedEventCallback 31 | ) { 32 | winston.debug( 33 | `@RepeatableProcess id:${job.id} attempts:${job.attemptsMade} feedlink:${job.data.link}` 34 | ); 35 | 36 | try { 37 | await this.rssService.processFeedJob(job.data); 38 | done(); 39 | } catch (error) { 40 | winston.error(error); 41 | 42 | if (error?.message === "FEED_FAILURE") { 43 | fail(job, error.message); 44 | } 45 | winston.error(error); 46 | done(new Error(error)); 47 | } 48 | } 49 | 50 | @OnQueueFailed() 51 | async failedProcess( 52 | job: Job<{ message: string; chatId: number }>, 53 | error: Error 54 | ) { 55 | winston.debug( 56 | `@OnQueueFailed ${job.id} ${job.attemptsMade} ${job.data.message}` 57 | ); 58 | winston.error(error); 59 | job.retry(); 60 | } 61 | 62 | @OnQueueStalled() 63 | async stalled(job: Job<{ message: string; chatId: number }>) { 64 | winston.debug( 65 | `@OnQueueStalled ${job.id} ${job.attemptsMade} ${job.data.message}` 66 | ); 67 | } 68 | 69 | @OnGlobalQueuePaused() 70 | async paused() { 71 | winston.debug("@OnGlobalQueuePaused"); 72 | await new Promise((r) => setTimeout(r, 60000)); 73 | this.repeatableFeedQueue.resume(); 74 | winston.debug("Resumed Queue"); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/telegram/telegram.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@nestjs/common"; 2 | import { InjectBot } from "nestjs-telegraf"; 3 | import { adminchatid } from "../util/config"; 4 | import { Telegraf } from "telegraf"; 5 | import { InjectEventEmitter } from "nest-emitter"; 6 | import { EventEmitterType } from "../events"; 7 | import mdLoader from "../util/mdLoader"; 8 | 9 | @Injectable() 10 | export class TelegramService { 11 | constructor( 12 | @InjectBot() private bot: Telegraf, 13 | @InjectEventEmitter() 14 | private readonly eventEmitter: EventEmitterType 15 | ) {} 16 | 17 | async onApplicationBootstrap() { 18 | const commands = (await mdLoader("help")) 19 | .split("\n") 20 | .map((line: string) => { 21 | if (line.startsWith("*/")) { 22 | const command = line.replace("* ", "*/ ").split("*/"); 23 | const description = line.split("* "); 24 | return { command: command[1], description: description[1] }; 25 | } 26 | }) 27 | .filter((anyValue) => typeof anyValue !== "undefined"); 28 | 29 | await this.bot.telegram.setMyCommands(commands); 30 | } 31 | 32 | async sendRss(chatId: number, link: string) { 33 | try { 34 | await this.bot.telegram.sendMessage(chatId, link); 35 | } catch (error) { 36 | if (error.response.error_code === 429) { 37 | throw error; 38 | } 39 | if ( 40 | error.response.error_code === 403 || 41 | error.response.description === "Bad Request: chat not found" 42 | ) { 43 | this.eventEmitter.emit("disableAllFeeds", { 44 | chatId: chatId, 45 | disable: true 46 | }); 47 | return await this.sendAdminMessage("Disabling all feeds for " + chatId); 48 | } else if (error.response.parameters?.migrate_to_chat_id) { 49 | const newChatId = error.response.parameters.migrate_to_chat_id; 50 | this.eventEmitter.emit("migrateChat", { 51 | chatId: chatId, 52 | newChatId: newChatId 53 | }); 54 | await this.sendAdminMessage( 55 | `Migrated chat from ${chatId} to ${newChatId}` 56 | ); 57 | } else { 58 | await this.sendAdminMessage(JSON.stringify(error)); 59 | } 60 | } 61 | } 62 | 63 | async sendAdminMessage(msg: string) { 64 | if (!adminchatid) return; 65 | await this.sendRss(adminchatid, msg); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/telegram/telegram.processor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Process, 3 | Processor, 4 | OnQueueFailed, 5 | OnQueueStalled, 6 | OnGlobalQueuePaused, 7 | InjectQueue 8 | } from "@nestjs/bull"; 9 | import { DoneCallback, Job, Queue } from "bull"; 10 | import constants from "src/util/constants"; 11 | 12 | import { TelegramService } from "./telegram.service"; 13 | import { Item } from "rss-parser"; 14 | import { getLogger } from "src/winston"; 15 | 16 | const winston = getLogger(); 17 | 18 | @Processor(constants.queue.messages) 19 | export class TelegramProcessor { 20 | constructor( 21 | private readonly telegramService: TelegramService, 22 | @InjectQueue(constants.queue.messages) private messagesQueue: Queue 23 | ) {} 24 | 25 | @Process("message") 26 | async processName( 27 | job: Job<{ feedItem: Item; chatId: number }>, 28 | done: DoneCallback 29 | ) { 30 | winston.debug( 31 | `@Process id:${job.id} attempts:${job.attemptsMade} message:${job.data.feedItem.link}` 32 | ); 33 | 34 | try { 35 | await this.telegramService.sendRss( 36 | job.data.chatId, 37 | job.data.feedItem.link 38 | ); 39 | done(); 40 | } catch (error) { 41 | winston.error(error); 42 | 43 | if (error?.response?.error_code === 429) { 44 | done(new Error(error.response.description)); 45 | winston.debug("Pausing queue"); 46 | return await this.messagesQueue.pause(); 47 | } 48 | winston.error(error); 49 | done(new Error(error)); 50 | } 51 | } 52 | 53 | @Process("__default__") 54 | async proccessDefault( 55 | job: Job<{ feedItem: Item; chatId: number }>, 56 | done: DoneCallback 57 | ) { 58 | console.log(JSON.stringify(job)); 59 | done(); 60 | } 61 | 62 | @OnQueueFailed() 63 | async failedProcess( 64 | job: Job<{ message: string; chatId: number }>, 65 | error: Error 66 | ) { 67 | winston.debug( 68 | `@OnQueueFailed ${job.id} ${job.attemptsMade} ${job.data.message}` 69 | ); 70 | winston.error(error); 71 | job.retry(); 72 | } 73 | 74 | @OnQueueStalled() 75 | async stalled(job: Job<{ message: string; chatId: number }>) { 76 | winston.debug( 77 | `@OnQueueStalled ${job.id} ${job.attemptsMade} ${job.data.message}` 78 | ); 79 | } 80 | 81 | @OnGlobalQueuePaused() 82 | async paused() { 83 | winston.debug("@OnGlobalQueuePaused"); 84 | await new Promise((r) => setTimeout(r, 60000)); 85 | this.messagesQueue.resume(); 86 | winston.debug("Resumed Queue"); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![RSSTT](https://github.com/BoKKeR/RSS-to-Telegram-Bot/raw/master/rsstt.png) 2 | 3 | ![GitHub tag (latest by date)](https://img.shields.io/github/v/tag/bokker/rss-to-telegram-bot?label=release) ![Docker Pulls](https://img.shields.io/docker/pulls/bokker/rss.to.telegram) ![Docker Stars](https://img.shields.io/docker/stars/bokker/rss.to.telegram) ![GitHub Workflow Status (branch)](https://img.shields.io/github/workflow/status/BoKKeR/RSS-to-telegram-Bot/master/master) ![Docker Image Size (latest by date)](https://img.shields.io/docker/image-size/bokker/rss.to.telegram) 4 | # RSS to Telegram bot 5 | 6 | A self-hosted telegram JavaScript/TypeScript bot that dumps posts from RSS feeds to a telegram chat. 7 | This script was created because all the third party services were unreliable. 8 | 9 | The bot is publicly available on telegram: http://t.me/rss_t_bot 10 | 11 | ![help](img/help.png) 12 | 13 | ### Docker 14 | 15 | For the docker image go to: https://hub.docker.com/r/bokker/rss.to.telegram/ 16 | 17 | ### Installation 18 | 19 | Dont forget to use the right node version. `nvm use` or match the version in `.nvmrc` 20 | 21 | ```sh 22 | npm install 23 | cp .env.example .env 24 | npm run dev 25 | ``` 26 | 27 | Dont forget to fill the .env file 28 | 29 | A telegram bot is needed that the script will connect to. https://botsfortelegram.com/project/the-bot-father/ 30 | Running the script and typing in /help will reveal all the commands 31 | 32 | 1. Clone the script 33 | 2. Save and run 34 | 3. Use the telegram commands to manage feeds 35 | 36 | # Known issues 37 | 38 | There are no known issues but the telegram API has limitations on how many messages per chat you can receive 39 | 40 | # Docker 41 | 42 | This container uses [Redis](https://redis.io) and [Bull](https://optimalbits.github.io/bull/) for queuing the messages 43 | 44 | ``` 45 | docker create \ 46 | --name=rss.to.telegram \ 47 | -e TOKEN=chat:token \ 48 | -e DEBUG=false \ 49 | -e REDIS_HOST=127.0.0.1 \ 50 | -e REDIS_PORT=6379 \ 51 | -e REDIS_USER=default \ 52 | -e REDIS_PASSWORD=secret \ 53 | -e REDIS_MUTEX=random-value \ 54 | -v /path/to/host/config:/app/config \ 55 | --restart unless-stopped \ 56 | bokker/rss.to.telegram 57 | ``` 58 | 59 | ## Prisma 60 | 61 | Prisma is used to manage SQLite structure changes and as a ORM 62 | 63 | To create any change you need to do the following: 64 | 65 | 1. Backup current database 66 | 2. Alter the prisma.schema 67 | 3. `npx prisma migrate dev -n migration-name` 68 | 4. test the migration on an old version of the database `npx prisma migrate deploy` 69 | 5. test the migration by having no database `npx prisma migrate deploy` -------------------------------------------------------------------------------- /src/setting/setting.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@nestjs/common"; 2 | import { PrismaService } from "../prisma.service"; 3 | import { setting, Prisma } from "@prisma/client"; 4 | import { CustomLoggerService } from "../logger/logger.service"; 5 | import { delay, packageVersion } from "src/util/config"; 6 | import { getLogger } from "src/winston"; 7 | 8 | const winston = getLogger(); 9 | 10 | @Injectable() 11 | export class SettingService { 12 | constructor( 13 | private prisma: PrismaService, 14 | private logger: CustomLoggerService 15 | ) { 16 | this.logger.setContext("SettingService"); 17 | this.settingsChangelog(); 18 | } 19 | 20 | async getSetting( 21 | userWhereUniqueInput: Prisma.settingWhereUniqueInput 22 | ): Promise { 23 | return this.prisma.setting.findUnique({ 24 | where: userWhereUniqueInput 25 | }); 26 | } 27 | 28 | async getSettingByChatId(chatId: number): Promise { 29 | return this.prisma.setting.findUnique({ 30 | where: { chat_id: chatId } 31 | }); 32 | } 33 | 34 | async getSettings(params: { 35 | skip?: number; 36 | take?: number; 37 | cursor?: Prisma.settingWhereUniqueInput; 38 | where?: Prisma.settingWhereInput; 39 | orderBy?: Prisma.settingOrderByWithRelationInput; 40 | }): Promise { 41 | const { skip, take, cursor, where, orderBy } = params; 42 | return this.prisma.setting.findMany({ 43 | skip, 44 | take, 45 | cursor, 46 | where, 47 | orderBy 48 | }); 49 | } 50 | 51 | async createSetting(data: Prisma.settingCreateInput): Promise { 52 | return this.prisma.setting.create({ 53 | data 54 | }); 55 | } 56 | 57 | async updateSetting(params: { 58 | where: Prisma.settingWhereUniqueInput; 59 | data: Prisma.settingUpdateInput; 60 | }): Promise { 61 | const { where, data } = params; 62 | return this.prisma.setting.update({ 63 | data, 64 | where 65 | }); 66 | } 67 | 68 | async deleteSetting(where: Prisma.settingWhereUniqueInput): Promise { 69 | return this.prisma.setting.delete({ 70 | where 71 | }); 72 | } 73 | 74 | async settingsChangelog() { 75 | const chatSettings = await this.getSettings({}); 76 | for (const chatSetting of chatSettings) { 77 | if (chatSetting.last_version !== packageVersion) { 78 | if (chatSetting.show_changelog) { 79 | // send changelog 80 | } 81 | chatSetting.last_version = packageVersion; 82 | } 83 | } 84 | } 85 | 86 | async intializeTable(chatId: number) { 87 | const chatSetting = await this.getSettingByChatId(chatId); 88 | winston.debug("INITIALIZE SETTINGS"); 89 | 90 | if (!chatSetting) { 91 | winston.debug("settings not found"); 92 | return await this.createSetting({ 93 | chat_id: chatId, 94 | show_changelog: true, 95 | last_version: packageVersion, 96 | delay: delay 97 | }); 98 | } 99 | 100 | // CHAT_ID 101 | // CHANGELOG 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /carbon-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "narrow": { 3 | "paddingVertical": "30px", 4 | "paddingHorizontal": "39px", 5 | "backgroundImage": null, 6 | "backgroundImageSelection": null, 7 | "backgroundMode": "image", 8 | "backgroundColor": "rgba(0,0,0,0)", 9 | "dropShadow": false, 10 | "dropShadowOffsetY": "20px", 11 | "dropShadowBlurRadius": "68px", 12 | "theme": "base16-dark", 13 | "windowTheme": "none", 14 | "language": "markdown", 15 | "fontFamily": "OpenSans-VariableFont_wdth,wght", 16 | "fontSize": "14.5px", 17 | "lineHeight": "135%", 18 | "windowControls": false, 19 | "widthAdjustment": false, 20 | "lineNumbers": false, 21 | "firstLineNumber": 1, 22 | "exportSize": "2x", 23 | "watermark": false, 24 | "squaredImage": false, 25 | "hiddenCharacters": false, 26 | "name": "", 27 | "width": 400, 28 | "highlights": { 29 | "background": "rgba(40,55,66,1)", 30 | "text": "rgba(255,255,255,1)" 31 | }, 32 | "custom": { 33 | "background": "#283742", 34 | "text": "rgba(255,255,255,1)", 35 | "variable": "rgba(0, 255, 0, 1)", 36 | "variable2": "rgba(0, 255, 0, 1)", 37 | "attribute": "rgba(0, 255, 0, 1)", 38 | "definition": "rgba(0, 255, 0, 1)", 39 | "keyword": "rgba(0, 255, 0, 1)", 40 | "operator": "rgba(0, 255, 0, 1)", 41 | "property": "rgba(0, 255, 0, 1)", 42 | "number": "rgba(0, 255, 0, 1)", 43 | "string": "rgba(0, 255, 0, 1)", 44 | "comment": "rgba(0, 255, 0, 1)", 45 | "meta": "rgba(0, 255, 0, 1)", 46 | "tag": "rgba(0, 255, 0, 1)" 47 | }, 48 | "type": "png" 49 | }, 50 | "wide": { 51 | "paddingVertical": "30px", 52 | "paddingHorizontal": "39px", 53 | "backgroundImage": null, 54 | "backgroundImageSelection": null, 55 | "backgroundMode": "image", 56 | "backgroundColor": "rgba(0,0,0,0)", 57 | "dropShadow": false, 58 | "dropShadowOffsetY": "20px", 59 | "dropShadowBlurRadius": "68px", 60 | "theme": "base16-dark", 61 | "windowTheme": "none", 62 | "language": "markdown", 63 | "fontFamily": "OpenSans-VariableFont_wdth,wght", 64 | "fontSize": "14.5px", 65 | "lineHeight": "135%", 66 | "windowControls": false, 67 | "widthAdjustment": false, 68 | "lineNumbers": false, 69 | "firstLineNumber": 1, 70 | "exportSize": "2x", 71 | "watermark": false, 72 | "squaredImage": false, 73 | "hiddenCharacters": false, 74 | "name": "", 75 | "width": 700, 76 | "highlights": { 77 | "background": "rgba(40,55,66,1)", 78 | "text": "rgba(255,255,255,1)" 79 | }, 80 | "custom": { 81 | "background": "#283742", 82 | "text": "rgba(255,255,255,1)", 83 | "variable": "rgba(0, 255, 0, 1)", 84 | "variable2": "rgba(0, 255, 0, 1)", 85 | "attribute": "rgba(0, 255, 0, 1)", 86 | "definition": "rgba(0, 255, 0, 1)", 87 | "keyword": "rgba(0, 255, 0, 1)", 88 | "operator": "rgba(0, 255, 0, 1)", 89 | "property": "rgba(0, 255, 0, 1)", 90 | "number": "rgba(0, 255, 0, 1)", 91 | "string": "rgba(0, 255, 0, 1)", 92 | "comment": "rgba(0, 255, 0, 1)", 93 | "meta": "rgba(0, 255, 0, 1)", 94 | "tag": "rgba(0, 255, 0, 1)" 95 | }, 96 | "type": "png" 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rss-to-telegram-bot", 3 | "private": true, 4 | "version": "2.0.9", 5 | "description": "Open source JS RSS-to-telegram bot", 6 | "license": "MIT", 7 | "scripts": { 8 | "prebuild": "rimraf dist", 9 | "postbuild": "npm run copy-files", 10 | "copy-files": "copyfiles -u 1 src/doc/**/*.md dist/src", 11 | "build": "nest build", 12 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 13 | "start": "nest start", 14 | "start:dev": "nest start --watch", 15 | "dev": "nest start --watch", 16 | "start:debug": "nest start --debug --watch", 17 | "start:prod": "node dist/main", 18 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 19 | "test": "jest", 20 | "test:watch": "jest --watch", 21 | "test:cov": "jest --coverage", 22 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 23 | "test:e2e": "jest --config ./test/jest-e2e.json", 24 | "generate:image": "ts-node scripts/generate_image.ts" 25 | }, 26 | "dependencies": { 27 | "@nestjs/bull": "^0.6.3", 28 | "@nestjs/cli": "^8.1.3", 29 | "@nestjs/common": "^8.1.1", 30 | "@nestjs/config": "^1.2.0", 31 | "@nestjs/core": "^8.1.1", 32 | "@nestjs/mapped-types": "*", 33 | "@nestjs/platform-express": "^8.1.1", 34 | "@nestjs/schedule": "^2.2.0", 35 | "@nestjs/schematics": "^8.0.4", 36 | "@nestjs/testing": "^8.1.1", 37 | "@prisma/client": "^3.15.2", 38 | "axios": "^0.26.1", 39 | "bull": "^4.10.4", 40 | "copyfiles": "^2.4.1", 41 | "luxon": "^3.4.3", 42 | "nest-emitter": "^1.1.1", 43 | "nestjs-telegraf": "^2.4.0", 44 | "pre-push": "^0.1.2", 45 | "reflect-metadata": "^0.1.13", 46 | "release-it": "^14.14.1", 47 | "rimraf": "^3.0.2", 48 | "rss-parser": "^3.12.0", 49 | "rxjs": "^7.4.0", 50 | "telegraf": "^4.7.0", 51 | "uuid": "^9.0.0", 52 | "winston": "^3.10.0", 53 | "winston-loki": "^6.0.6" 54 | }, 55 | "devDependencies": { 56 | "@types/cron": "^1.7.3", 57 | "@types/express": "^4.17.13", 58 | "@types/jest": "^27.0.2", 59 | "@types/luxon": "^3.3.2", 60 | "@types/node": "^16.11.1", 61 | "@types/supertest": "^2.0.11", 62 | "@typescript-eslint/eslint-plugin": "^4.29.2", 63 | "@typescript-eslint/parser": "^4.29.2", 64 | "carbon-now-cli": "^2.0.0", 65 | "eslint": "^7.32.0", 66 | "eslint-config-prettier": "^8.3.0", 67 | "eslint-plugin-prettier": "^3.4.1", 68 | "jest": "^27.3.0", 69 | "prettier": "^2.4.1", 70 | "prisma": "^3.15.2", 71 | "source-map-support": "^0.5.20", 72 | "supertest": "^6.1.6", 73 | "ts-jest": "^27.0.7", 74 | "ts-loader": "^9.2.6", 75 | "ts-node": "^10.3.0", 76 | "tsconfig-paths": "^3.11.0", 77 | "typescript": "^4.4.4" 78 | }, 79 | "jest": { 80 | "moduleFileExtensions": [ 81 | "js", 82 | "json", 83 | "ts" 84 | ], 85 | "rootDir": "./", 86 | "modulePaths": [ 87 | "" 88 | ], 89 | "moduleDirectories": [ 90 | "src", 91 | "node_modules" 92 | ], 93 | "testRegex": ".*\\.spec\\.ts$", 94 | "transform": { 95 | "^.+\\.(t|j)s$": "ts-jest" 96 | }, 97 | "collectCoverageFrom": [ 98 | "**/*.(t|j)s" 99 | ], 100 | "coverageDirectory": "../coverage", 101 | "testEnvironment": "node" 102 | }, 103 | "pre-push": [] 104 | } 105 | -------------------------------------------------------------------------------- /src/app.update.ts: -------------------------------------------------------------------------------- 1 | import { InjectEventEmitter } from "nest-emitter"; 2 | import { Update, Help, Command, Start } from "nestjs-telegraf"; 3 | import { Context } from "./context.interface"; 4 | import { EventEmitterType } from "./events"; 5 | import { RssService } from "./rss/rss.service"; 6 | import { SettingService } from "./setting/setting.service"; 7 | import { adminchatid } from "./util/config"; 8 | import mdLoader from "./util/mdLoader"; 9 | import { toBoolean } from "./util/toBoolean"; 10 | 11 | let Parser = require("rss-parser"); 12 | let parser = new Parser(); 13 | 14 | @Update() 15 | export class AppUpdate { 16 | constructor( 17 | private rssService: RssService, 18 | private settingService: SettingService, 19 | @InjectEventEmitter() private readonly emitter: EventEmitterType 20 | ) {} 21 | 22 | getMessage(ctx: Context) { 23 | // @ts-ignore 24 | return ctx.update.message.text; 25 | } 26 | getFromChatId(ctx: Context) { 27 | return ctx.message.chat.id; 28 | } 29 | 30 | @Command("list") 31 | async startCommand(ctx: Context) { 32 | await this.initializeSettings(ctx); 33 | 34 | const fromId = this.getFromChatId(ctx); 35 | 36 | const list = await this.rssService.feeds({ where: { chat_id: fromId } }); 37 | 38 | if (list.length === 0) { 39 | await ctx.reply("ERROR: The database is empty"); 40 | return; 41 | } 42 | 43 | for (let elementIndex = 0; elementIndex < list.length; elementIndex++) { 44 | const entry = list[elementIndex]; 45 | await ctx.reply( 46 | `Title: ${entry.name}\nRSS URL: ${entry.link}\nLast checked entry: ${entry.last}\nDisabled: ${entry.disabled}`, 47 | { disable_web_page_preview: true } 48 | ); 49 | } 50 | } 51 | 52 | @Command("add") 53 | async onAdd(ctx: Context) { 54 | await this.initializeSettings(ctx); 55 | 56 | const text = this.getMessage(ctx); 57 | const fromId = this.getFromChatId(ctx); 58 | 59 | if (!text || text.split(" ").length === 2) { 60 | await ctx.reply( 61 | "ERROR: wrong input, the format needs to be: /add title_name rss_link_url" 62 | ); 63 | return; 64 | } 65 | 66 | const name = text.split(" ")[1]; 67 | const link = text.split(" ")[2]; 68 | 69 | if (!link || link === "invalid") { 70 | await ctx.reply( 71 | "ERROR: something with the link? correct syntax: \n/add title_name rss_link_url" 72 | ); 73 | return; 74 | } 75 | 76 | try { 77 | let feed = await parser.parseURL(link); 78 | const lastItem = feed.items[0]; 79 | 80 | const duplicateCheck = await this.rssService.findFirst({ 81 | where: { link: link, chat_id: fromId } 82 | }); 83 | 84 | if (duplicateCheck) { 85 | if (duplicateCheck.link === link) { 86 | await ctx.reply("DUPLICATE: duplicate link"); 87 | } 88 | if (duplicateCheck.name === name) { 89 | await ctx.reply("DUPLICATE: duplicate title"); 90 | } 91 | 92 | return; 93 | } 94 | 95 | await this.rssService.createFeed({ 96 | last: lastItem.link, 97 | name: name, 98 | link: link, 99 | chat_id: fromId, 100 | disabled: false 101 | }); 102 | await ctx.reply( 103 | `ADDED: \nRSS: ${lastItem.link}\nTITLE: ${name}\ndisabled: false`, 104 | { 105 | disable_web_page_preview: true 106 | } 107 | ); 108 | } catch (error) { 109 | if (error.code === "P2002") { 110 | await ctx.reply( 111 | "ERROR: Duplicate problem when saving with: " + 112 | JSON.stringify(error.meta.target) 113 | ); 114 | } else if ((error.code = "ECONNREFUSED")) { 115 | await ctx.replyWithMarkdown( 116 | "ERROR: connection refused/not valid RSS link\nif you think this is a mistake [open an issue](https://github.com/BoKKeR/RSS-to-Telegram-Bot/issues) with the given link", 117 | { disable_web_page_preview: true } 118 | ); 119 | } else { 120 | await ctx.reply(error); 121 | } 122 | } 123 | } 124 | 125 | @Command("delete") 126 | @Command("remove") 127 | async onRemove(ctx: Context) { 128 | await this.initializeSettings(ctx); 129 | 130 | const fromId = this.getFromChatId(ctx); 131 | const entries = this.getMessage(ctx) 132 | .replace("/remove ", "") 133 | .replace("/delete ", "") 134 | .split(" "); 135 | 136 | if (!entries) { 137 | await ctx.reply( 138 | "ERROR: wrong input, correct syntax: \n/remove link_name link_name link_name" 139 | ); 140 | return; 141 | } 142 | try { 143 | for (let entryIndex = 0; entryIndex < entries.length; entryIndex++) { 144 | const element = entries[entryIndex]; 145 | await this.rssService.deleteFeed(fromId, element); 146 | await ctx.reply("REMOVED: " + element); 147 | } 148 | } catch (error) { 149 | if (error === "P2025") { 150 | await ctx.reply("ERROR: Record not found"); 151 | return; 152 | } 153 | await ctx.reply("ERROR " + error); 154 | return; 155 | } 156 | } 157 | 158 | @Command("test") 159 | async onTest(ctx: Context) { 160 | await this.initializeSettings(ctx); 161 | 162 | let parser = new Parser(); 163 | let feed = await parser.parseURL("https://www.reddit.com/r/funny/new/.rss"); 164 | 165 | const lastItem = feed.items[0]; 166 | 167 | await ctx.reply(lastItem.link); 168 | } 169 | 170 | @Command("disable_all") 171 | async onDisableAll(ctx: Context) { 172 | const fromId = this.getFromChatId(ctx); 173 | const entries = this.getMessage(ctx).split(" "); 174 | if (!entries.length) { 175 | await ctx.reply( 176 | "ERROR: wrong input, correct syntax: \n/disable_all true/false" 177 | ); 178 | return; 179 | } 180 | 181 | const disable = toBoolean(entries[1]); 182 | 183 | this.emitter.emit("disableAllFeeds", { chatId: fromId, disable: disable }); 184 | await ctx.reply("All feeds set to disable: " + disable); 185 | } 186 | 187 | @Command("disable") 188 | async onDisableFeed(ctx: Context) { 189 | const entries = this.getMessage(ctx).split(" "); 190 | if (entries.length !== 3) { 191 | await ctx.reply( 192 | "ERROR: wrong input, correct syntax: \n/disable feedName true/false" 193 | ); 194 | return; 195 | } 196 | 197 | const feedName = entries[1]; 198 | const disable = toBoolean(entries[2]); 199 | 200 | const chatId = this.getFromChatId(ctx); 201 | this.emitter.emit("disableFeed", { 202 | chatId: chatId, 203 | name: feedName, 204 | disable: disable 205 | }); 206 | await ctx.reply(`Feed: ${feedName} set to disable: ${disable}`); 207 | } 208 | 209 | @Command("settings") 210 | @Command("setting") 211 | async onSettings(ctx: Context) { 212 | await this.initializeSettings(ctx); 213 | 214 | const fromId = this.getFromChatId(ctx); 215 | let setting = await this.settingService.getSettingByChatId(fromId); 216 | 217 | // @ts-ignore 218 | const entries = ctx.update.message.text.split(" "); 219 | 220 | if (entries.length > 2) { 221 | return await ctx.replyWithMarkdown("ERROR: wrong syntax"); 222 | } 223 | 224 | if (entries.length === 2 && entries[1].split("=").length === 2) { 225 | const [key, value] = entries[1].split("="); 226 | if (key === "delay" && typeof parseInt(value) === "number") { 227 | if (parseInt(value) >= 60) { 228 | await this.settingService.updateSetting({ 229 | where: { chat_id: fromId }, 230 | data: { [key]: parseInt(value) } 231 | }); 232 | } else { 233 | ctx.reply("ERROR: delay must be at least 60 seconds"); 234 | return; 235 | } 236 | } 237 | if (key === "show_changelog" && (value === "true" || value === "false")) { 238 | await this.settingService.updateSetting({ 239 | where: { chat_id: fromId }, 240 | data: { [key]: value === "true" ? true : false } 241 | }); 242 | } 243 | 244 | setting = await this.settingService.getSettingByChatId(fromId); 245 | } 246 | const msg = 247 | "*Settings*\n\nto change a setting use te following syntax:\n*/settings name=value* \n\nCurrent settings:" + 248 | "\n\ndelay=" + 249 | setting.delay + 250 | "\nshow_changelog=" + 251 | setting.show_changelog; 252 | 253 | await ctx.replyWithMarkdown(msg.replaceAll("_", "\\_")); 254 | } 255 | 256 | async initializeSettings(ctx: Context) { 257 | const chatId = this.getFromChatId(ctx); 258 | await this.settingService.intializeTable(chatId); 259 | } 260 | 261 | @Start() 262 | @Help() 263 | async help(ctx: Context) { 264 | await this.initializeSettings(ctx); 265 | 266 | const helpMarkdown = (await mdLoader("help")) 267 | .replace("CHATID_PLACEHOLDER", ctx.message.chat.id) 268 | .replace("VERSION_PLACEHOLDER", process.env.npm_package_version); 269 | 270 | try { 271 | await ctx.replyWithMarkdown(helpMarkdown, { 272 | disable_web_page_preview: false 273 | }); 274 | } catch (error) { 275 | await ctx.replyWithMarkdown("ERROR: " + error); 276 | } 277 | } 278 | 279 | @Command("stats") 280 | async stats(ctx: Context) { 281 | const fromId = this.getFromChatId(ctx); 282 | if (fromId !== adminchatid) return; 283 | 284 | await this.rssService.getStats(); 285 | } 286 | } 287 | -------------------------------------------------------------------------------- /src/rss/rss.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NotFoundException, OnModuleInit } from "@nestjs/common"; 2 | import { PrismaService } from "../prisma.service"; 3 | import { rss, Prisma } from "@prisma/client"; 4 | import { chatid, delay } from "../util/config"; 5 | import Parser from "rss-parser"; 6 | import { getFeedData } from "../util/axios"; 7 | import { TelegramService } from "../telegram/telegram.service"; 8 | import { CustomLoggerService } from "../logger/logger.service"; 9 | import uniqueItems from "../util/uniqueItems"; 10 | import { InjectQueue } from "@nestjs/bull"; 11 | import { Queue } from "bull"; 12 | import constants from "src/util/constants"; 13 | import { StatisticService } from "src/statistic/statistic.service"; 14 | import { getLogger } from "src/winston"; 15 | import { DateTime } from "luxon"; 16 | 17 | const winston = getLogger(); 18 | 19 | let parser = new Parser(); 20 | @Injectable() 21 | export class RssService implements OnModuleInit { 22 | constructor( 23 | @InjectQueue(constants.queue.messages) private messagesQueue: Queue, 24 | @InjectQueue(constants.queue.repeatableFeed) 25 | private repeatableFeedQueue: Queue, 26 | private prisma: PrismaService, 27 | private telegramService: TelegramService, 28 | private statisticService: StatisticService, 29 | private logger: CustomLoggerService 30 | ) { 31 | this.logger.setContext("RssService"); 32 | winston.debug("DELAY: " + delay + " seconds"); 33 | } 34 | 35 | async onModuleInit() { 36 | await this.migrateToMultiChat(); 37 | await this.syncRepeatableJobs(); 38 | await this.messagesQueue.resume(); 39 | } 40 | 41 | every = 300000; 42 | 43 | getJobId = (feed: rss) => { 44 | return `feed=${feed.id}&chat_id=${feed.chat_id}`; 45 | }; 46 | 47 | async syncRepeatableJobs() { 48 | let repeatableJobs = await this.repeatableFeedQueue.getRepeatableJobs(); 49 | 50 | const feeds = await this.feeds({ where: { disabled: false } }); 51 | 52 | const wantedFeeds = feeds.map((feed) => ({ 53 | ...feed, 54 | key: this.getJobId(feed) 55 | })); 56 | 57 | const checkedJobs: Parser.Item & { key: string }[] = []; 58 | 59 | // check if existing jobs have unwanted extra job 60 | for (const existingJob of repeatableJobs) { 61 | const isUnwantedJob = !wantedFeeds.some((wantedJob) => { 62 | return existingJob.key.includes(wantedJob.key); 63 | }); 64 | 65 | if (isUnwantedJob) { 66 | this.logger.debug("Removed " + existingJob.key); 67 | await this.repeatableFeedQueue.removeRepeatableByKey(existingJob.key); 68 | } else { 69 | checkedJobs.push(existingJob); 70 | } 71 | } 72 | 73 | // check if wanted jobs are missing 74 | for (const wantedFeed of wantedFeeds) { 75 | const isMissingJob = !checkedJobs.some((job) => 76 | job.key.includes(wantedFeed.key) 77 | ); 78 | 79 | if (isMissingJob) { 80 | this.logger.debug(`Adding missing ${wantedFeed.key}`); 81 | await this.addRepeatableFeedJob(wantedFeed); 82 | } 83 | } 84 | } 85 | 86 | async addRepeatableFeedJob(rss: rss) { 87 | await this.repeatableFeedQueue.add( 88 | "feed", 89 | { ...rss }, 90 | { 91 | jobId: this.getJobId(rss), 92 | repeat: { every: this.every }, 93 | removeOnComplete: true, 94 | removeOnFail: true 95 | } 96 | ); 97 | } 98 | 99 | async feed( 100 | userWhereUniqueInput: Prisma.rssWhereUniqueInput 101 | ): Promise { 102 | return this.prisma.rss.findUnique({ 103 | where: userWhereUniqueInput 104 | }); 105 | } 106 | 107 | async feeds(params: { 108 | skip?: number; 109 | take?: number; 110 | cursor?: Prisma.rssWhereUniqueInput; 111 | where?: Prisma.rssWhereInput; 112 | orderBy?: Prisma.rssOrderByWithRelationInput; 113 | }): Promise { 114 | const { skip, take, cursor, where, orderBy } = params; 115 | return this.prisma.rss.findMany({ 116 | skip, 117 | take, 118 | cursor, 119 | where, 120 | orderBy 121 | }); 122 | } 123 | 124 | async findFirst(params: { where?: Prisma.rssWhereInput }) { 125 | return await this.prisma.rss.findFirst(params); 126 | } 127 | 128 | async createFeed(data: Prisma.rssCreateInput): Promise { 129 | return this.prisma.rss.create({ 130 | data 131 | }); 132 | } 133 | 134 | async updateFeed(params: { 135 | where: Prisma.rssWhereUniqueInput; 136 | data: Prisma.rssUpdateInput; 137 | }): Promise { 138 | const { where, data } = params; 139 | const result = await this.prisma.rss.update({ 140 | data, 141 | where 142 | }); 143 | await this.syncRepeatableJobs(); 144 | return result; 145 | } 146 | 147 | async deleteFeed(chatId: number, name: string): Promise { 148 | const result = await this.prisma.rss.findFirst({ 149 | where: { 150 | chat_id: chatId, 151 | name: name 152 | } 153 | }); 154 | 155 | if (result) { 156 | const deletedFeed = await this.prisma.rss.delete({ 157 | where: { id: result.id } 158 | }); 159 | await this.syncRepeatableJobs(); 160 | return deletedFeed; 161 | } 162 | } 163 | 164 | async migrateChat(dto: { chatId: number; newChatId: number }) { 165 | await this.prisma.rss.updateMany({ 166 | where: { chat_id: dto.chatId }, 167 | data: { chat_id: dto.newChatId } 168 | }); 169 | } 170 | 171 | async disableAllFeeds(dto: { chatId: number; disable: boolean }) { 172 | await this.prisma.rss.updateMany({ 173 | where: { chat_id: dto.chatId }, 174 | data: { disabled: dto.disable } 175 | }); 176 | const activeJobs = await this.messagesQueue.getJobs(["waiting", "delayed"]); 177 | 178 | for (const job of activeJobs) { 179 | if (job.data.chatId === dto.chatId) { 180 | job.remove(); 181 | } 182 | } 183 | await this.syncRepeatableJobs(); 184 | } 185 | 186 | async disableFeed(dto: { name: string; disable: boolean; chatId: number }) { 187 | await this.prisma.rss.updateMany({ 188 | where: { name: dto.name, chat_id: dto.chatId }, 189 | data: { disabled: dto.disable, failures: JSON.stringify([]) } 190 | }); 191 | await this.syncRepeatableJobs(); 192 | } 193 | 194 | async processFeedJob(rss: rss) { 195 | const isDevChat = rss.chat_id === parseInt(process.env.DEV_CHAT); 196 | try { 197 | let feedReq = await getFeedData(rss.link); 198 | 199 | // @ts-ignore 200 | let feed = await parser.parseString(feedReq); 201 | 202 | // workaround for double msges 203 | rss = ( 204 | await this.feeds({ 205 | where: { id: rss.id } 206 | }) 207 | )[0]; 208 | 209 | const feedItems = feed.items; 210 | 211 | const lastItem = feedItems[0]; 212 | if (isDevChat) { 213 | winston.debug("feedItems: " + JSON.stringify(feedItems), { 214 | labels: { chat_id: rss.chat_id } 215 | }); 216 | } 217 | winston.debug(`-------checking feed: ${rss.name}---------- `, { 218 | labels: { chat_id: rss.chat_id } 219 | }); 220 | winston.debug("last: " + lastItem.link, { 221 | labels: { chat_id: rss.chat_id } 222 | }); 223 | if (lastItem.link !== rss.last) { 224 | if (isDevChat) { 225 | winston.debug("current feed last:" + rss.last, { 226 | labels: { chat_id: rss.chat_id } 227 | }); 228 | } 229 | const findSavedItemIndex = 230 | feedItems.findIndex((item) => item.link === rss.last) !== -1 231 | ? feedItems.findIndex((item) => item.link === rss.last) - 1 232 | : feedItems.length - 1; 233 | const newItemsCount = findSavedItemIndex + 1; 234 | winston.debug("new items: " + newItemsCount, { 235 | labels: { chat_id: rss.chat_id } 236 | }); 237 | 238 | this.statisticService.create({ 239 | count: newItemsCount, 240 | chat_id: rss.chat_id 241 | }); 242 | for (let itemIndex = findSavedItemIndex; itemIndex > -1; itemIndex--) { 243 | const gapItem = feedItems[itemIndex]; 244 | if (!gapItem.link) { 245 | if (isDevChat) { 246 | winston.debug("no gapItem link: " + JSON.stringify(gapItem), { 247 | labels: { chat_id: rss.chat_id } 248 | }); 249 | } 250 | return; 251 | } 252 | 253 | winston.debug(`Adding job: ${gapItem.link}`, { 254 | labels: { chat_id: rss.chat_id } 255 | }); 256 | await this.addMessageJob(rss.chat_id, gapItem); 257 | if (itemIndex === 0) { 258 | winston.debug("saving: " + lastItem.link, { 259 | labels: { chat_id: rss.chat_id } 260 | }); 261 | 262 | await this.updateFeed({ 263 | where: { id: rss.id }, 264 | data: { last: lastItem.link } 265 | }); 266 | 267 | if (isDevChat) { 268 | const feed = await this.feeds({ 269 | where: { disabled: false, id: rss.id } 270 | }); 271 | 272 | winston.debug("saved in DB: " + JSON.stringify(feed), { 273 | labels: { chat_id: rss.chat_id } 274 | }); 275 | } 276 | winston.debug("Done! saving checkpoint: " + lastItem.link, { 277 | chaId: { chat_id: rss.chat_id } 278 | }); 279 | } 280 | } 281 | } 282 | winston.debug("-------------done------------------", { 283 | labels: { chat_id: rss.chat_id } 284 | }); 285 | } catch (error) { 286 | await this.handleFeedFailure(rss, error); 287 | winston.error(error, { labels: { chat_id: rss.chat_id } }); 288 | } 289 | } 290 | 291 | async handleFeedFailure(rss: rss, error) { 292 | try { 293 | const updatedRss = ( 294 | await this.feeds({ 295 | where: { id: rss.id } 296 | }) 297 | )[0]; 298 | 299 | let failures = []; 300 | 301 | if (updatedRss.failures) { 302 | failures = JSON.parse(updatedRss.failures); 303 | } 304 | 305 | failures.push({ 306 | [DateTime.now().toFormat("yyyy-MM-dd TT")]: error.message 307 | }); 308 | await this.updateFeed({ 309 | where: { id: updatedRss.id }, 310 | data: { failures: JSON.stringify(failures) } 311 | }); 312 | 313 | if (failures.length >= 10) { 314 | await this.disableFeed({ 315 | chatId: rss.chat_id, 316 | name: rss.name, 317 | disable: true 318 | }); 319 | await this.telegramService.sendRss( 320 | rss.chat_id, 321 | `Feed failure, disabling feed: ${rss.name}` 322 | ); 323 | await this.telegramService.sendRss( 324 | rss.chat_id, 325 | JSON.stringify(failures) 326 | ); 327 | throw new Error("FEED_FAILURE"); 328 | } 329 | } catch (error) { 330 | winston.error(error, { labels: { chat_id: rss.chat_id } }); 331 | } 332 | } 333 | 334 | async getStats() { 335 | winston.debug("getting chat stats"); 336 | const enabledFeeds = await this.feeds({ where: { disabled: false } }); 337 | const disabledFeeds = await this.feeds({ where: { disabled: true } }); 338 | const users = uniqueItems(enabledFeeds, "chat_id"); 339 | const stats = { 340 | feeds: enabledFeeds.length.toString(), 341 | users: users.toString(), 342 | disabledFeeds: disabledFeeds.length.toString() 343 | }; 344 | 345 | const chatStats = await this.statisticService.getStats(); 346 | 347 | const sum = chatStats 348 | .sort((a, b) => b._sum.count - a._sum.count) 349 | .filter((item) => item._sum.count > 1000) 350 | .map((_) => `${_.chat_id}: ${_._sum.count}\n`) 351 | .join(""); 352 | 353 | await this.telegramService.sendAdminMessage( 354 | `Enabled feeds: ${stats.feeds} 355 | Active Users: ${stats.users} 356 | Disabled Feeds: ${stats.disabledFeeds} 357 | 358 | -- Queue -- 359 | Awaiting: ${await this.messagesQueue.count()}, 360 | Active: ${await this.messagesQueue.getActiveCount()}, 361 | Completed: ${await this.messagesQueue.getCompletedCount()} 362 | 363 | -- Chat stats over 1000 -- 364 | ${sum} 365 | ` 366 | ); 367 | } 368 | 369 | async addMessageJob(chatId: number, feedItem: Parser.Item) { 370 | try { 371 | await this.messagesQueue.add( 372 | "message", 373 | { 374 | chatId: chatId, 375 | feedItem: feedItem 376 | }, 377 | { 378 | removeOnComplete: true 379 | } 380 | ); 381 | } catch (error) { 382 | winston.error(error); 383 | } 384 | } 385 | 386 | async migrateToMultiChat() { 387 | winston.debug("migrate to multichat started"); 388 | const feeds = await this.feeds({}); 389 | for (const feed of feeds) { 390 | if (feed.chat_id === 0 && chatid) { 391 | winston.debug("feed for migration found"); 392 | try { 393 | await this.updateFeed({ 394 | where: { id: feed.id }, 395 | data: { chat_id: chatid } 396 | }); 397 | winston.debug("feed saved"); 398 | } catch (error) { 399 | winston.debug("error saving migration"); 400 | winston.debug(JSON.stringify(error)); 401 | } 402 | } 403 | } 404 | } 405 | } 406 | -------------------------------------------------------------------------------- /src/rss/rss.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { RssService } from "./rss.service"; 2 | import { Test, TestingModule } from "@nestjs/testing"; 3 | import { PrismaService } from "../prisma.service"; 4 | import Parser from "rss-parser"; 5 | import axios from "axios"; 6 | import { TelegramService } from "../telegram/telegram.service"; 7 | import { CustomLoggerService } from "../logger/logger.service"; 8 | import { BullModule, getQueueToken } from "@nestjs/bull"; 9 | import constants from "../util/constants"; 10 | import { StatisticService } from "src/statistic/statistic.service"; 11 | 12 | jest.mock("axios"); 13 | 14 | jest.mock("winston", () => ({ 15 | getLogger: () => ({ 16 | debug: jest.fn() 17 | }) 18 | })); 19 | 20 | jest.mock("rss-parser", () => { 21 | return jest.fn().mockImplementation(() => { 22 | return { 23 | parseString: jest.fn().mockReturnValue({ 24 | items: [ 25 | { 26 | title: "Thank God for subtitles", 27 | link: "https://www.reddit.com/r/funny/6/", 28 | pubDate: "2022-03-19T16:52:39.000Z", 29 | author: "/u/Pathosx", 30 | content: 31 | '
Thank God for subtitles submitted by /u/Pathosx
[link] [comments]
', 32 | contentSnippet: 33 | "submitted by /u/Pathosx \n [link] [comments]", 34 | id: "t3_thzedr", 35 | isoDate: "2022-03-19T16:52:39.000Z" 36 | }, 37 | { 38 | title: "People on r/WouldYouRather finding loopholes", 39 | link: "https://www.reddit.com/r/funny/5/", 40 | pubDate: "2022-03-19T16:47:06.000Z", 41 | author: "/u/Bledalot", 42 | content: 43 | '
People on r/WouldYouRather finding loopholes submitted by /u/Bledalot
[link] [comments]
', 44 | contentSnippet: 45 | "submitted by /u/Bledalot \n [link] [comments]", 46 | id: "t3_thza2s", 47 | isoDate: "2022-03-19T16:47:06.000Z" 48 | }, 49 | { 50 | title: 51 | "Staring right into the face of death while stuffing your face", 52 | link: "https://www.reddit.com/r/funny/4/", 53 | pubDate: "2022-03-19T16:45:34.000Z", 54 | author: "/u/SligPants", 55 | content: 56 | '
Staring right into the face of death while stuffing your face submitted by /u/SligPants
[link] [comments]
', 57 | contentSnippet: 58 | "submitted by /u/SligPants \n [link] [comments]", 59 | id: "t3_thz8uq", 60 | isoDate: "2022-03-19T16:45:34.000Z" 61 | }, 62 | { 63 | title: "...and you thought smoking at the pump was risky stuff 😂", 64 | link: "https://www.reddit.com/r/funny/3/", 65 | pubDate: "2022-03-19T16:44:13.000Z", 66 | author: "/u/iBrake4NoReason", 67 | content: 68 | '
...and you thought smoking at the pump was risky stuff 😂 submitted by /u/iBrake4NoReason
[link] [comments]
', 69 | contentSnippet: 70 | "submitted by /u/iBrake4NoReason \n [link] [comments]", 71 | id: "t3_thz7ry", 72 | isoDate: "2022-03-19T16:44:13.000Z" 73 | }, 74 | { 75 | title: "Cops in Alaska are in on the joke.", 76 | link: "https://www.reddit.com/r/funny/2/", 77 | pubDate: "2022-03-19T16:43:07.000Z", 78 | author: "/u/MulletCamaro", 79 | content: 80 | '
Cops in Alaska are in on the joke. submitted by /u/MulletCamaro
[link] [comments]
', 81 | contentSnippet: 82 | "submitted by /u/MulletCamaro \n [link] [comments]", 83 | id: "t3_thz6y8", 84 | isoDate: "2022-03-19T16:43:07.000Z" 85 | }, 86 | { 87 | title: "Look out, Norway!", 88 | link: "https://www.reddit.com/r/funny/1/", 89 | pubDate: "2022-03-19T16:38:34.000Z", 90 | author: "/u/ajbenson", 91 | content: 92 | '
Look out, Norway! submitted by /u/ajbenson
[link] [comments]
', 93 | contentSnippet: 94 | "submitted by /u/ajbenson \n [link] [comments]", 95 | id: "t3_thz3dh", 96 | isoDate: "2022-03-19T16:38:34.000Z" 97 | } 98 | ] 99 | }) 100 | }; 101 | }); 102 | }); 103 | 104 | describe("RssService", () => { 105 | let service: RssService; 106 | let prisma: PrismaService; 107 | let telegramService: TelegramService; 108 | let loggerService: CustomLoggerService; 109 | let messageQueue: BullModule; 110 | let repeatableFeed: BullModule; 111 | let statisticService: StatisticService; 112 | 113 | const importQueue: any = { 114 | add: jest.fn(), 115 | process: jest.fn(), 116 | on: jest.fn() 117 | }; 118 | 119 | beforeEach(async () => { 120 | const module: TestingModule = await Test.createTestingModule({ 121 | imports: [ 122 | BullModule.registerQueue({ 123 | name: constants.queue.messages 124 | }), 125 | BullModule.registerQueue({ 126 | name: constants.queue.repeatableFeed 127 | }) 128 | ], 129 | providers: [ 130 | RssService, 131 | PrismaService, 132 | { 133 | provide: TelegramService, 134 | useValue: { 135 | sendRss: jest.fn() 136 | } 137 | }, 138 | { 139 | provide: CustomLoggerService, 140 | useValue: { 141 | setContext: jest.fn(), 142 | warn: jest.fn(), 143 | verbose: jest.fn(), 144 | debug: jest.fn() 145 | } 146 | }, 147 | { 148 | provide: StatisticService, 149 | useValue: { 150 | create: jest.fn() 151 | } 152 | } 153 | ] 154 | }) 155 | .overrideProvider(getQueueToken(constants.queue.messages)) 156 | .useValue(importQueue) 157 | .compile(); 158 | 159 | service = module.get(RssService); 160 | prisma = module.get(PrismaService); 161 | telegramService = module.get(TelegramService); 162 | loggerService = module.get(CustomLoggerService); 163 | statisticService = module.get(StatisticService); 164 | 165 | service.migrateToMultiChat = jest.fn(); 166 | jest.clearAllMocks(); 167 | }); 168 | 169 | describe("processFeedJob", () => { 170 | it("should update and send 3 posts", async () => { 171 | const rss = { 172 | chat_id: -123, 173 | id: 1, 174 | link: "idk", 175 | name: "test", 176 | disabled: false, 177 | last: "https://www.reddit.com/r/funny/3/", 178 | failures: "[]" 179 | }; 180 | 181 | // need to do this as I cant hoist any variables on the top of the file 182 | const mockFeed = await new Parser().parseString(""); 183 | prisma.rss.findMany = jest.fn().mockReturnValue([rss]); 184 | 185 | // @ts-ignore 186 | axios.get.mockResolvedValue(rss); 187 | 188 | jest.spyOn(axios, "get"); 189 | jest.spyOn(service, "processFeedJob"); 190 | jest.spyOn(service, "updateFeed"); 191 | 192 | await service.processFeedJob(rss); 193 | 194 | expect(importQueue.add).toBeCalledTimes(3); 195 | expect(statisticService.create).toBeCalledTimes(1); 196 | expect(statisticService.create).toBeCalledWith({ 197 | count: 3, 198 | chat_id: rss.chat_id 199 | }); 200 | expect(axios.get).toBeCalledTimes(1); 201 | expect(axios.get).toBeCalledWith(rss.link); 202 | expect(importQueue.add).not.toBeCalledWith(rss.last); 203 | 204 | expect(service.updateFeed).toBeCalledWith({ 205 | where: { id: rss.id }, 206 | data: { last: mockFeed.items[0].link } 207 | }); 208 | }); 209 | 210 | it("should update and send 5 posts", async () => { 211 | const rss = { 212 | id: 1, 213 | link: "idk", 214 | name: "test", 215 | last: "https://www.reddit.com/r/funny/1/" 216 | }; 217 | // need to do this as I cant hoist any variables on the top of the file 218 | const mockFeed = await new Parser().parseString(""); 219 | prisma.rss.findMany = jest.fn().mockReturnValue([rss]); 220 | 221 | // @ts-ignore 222 | axios.get.mockResolvedValue(rss); 223 | 224 | jest.spyOn(axios, "get"); 225 | jest.spyOn(service, "processFeedJob"); 226 | jest.spyOn(service, "updateFeed"); 227 | 228 | // @ts-ignore 229 | await service.processFeedJob(rss); 230 | expect(importQueue.add).toBeCalledTimes(5); 231 | expect(axios.get).toBeCalledWith(rss.link); 232 | 233 | expect(service.updateFeed).toBeCalledWith({ 234 | where: { id: rss.id }, 235 | data: { last: mockFeed.items[0].link } 236 | }); 237 | }); 238 | 239 | it("no new posts, should not call", async () => { 240 | const rss = { 241 | link: "idk", 242 | name: "test", 243 | last: "https://www.reddit.com/r/funny/6/" 244 | }; 245 | 246 | prisma.rss.findMany = jest.fn().mockReturnValue([rss]); 247 | prisma.rss.update = jest.fn(); 248 | 249 | // @ts-ignore 250 | axios.get.mockResolvedValue(rss); 251 | 252 | jest.spyOn(axios, "get"); 253 | jest.spyOn(service, "processFeedJob"); 254 | jest.spyOn(service, "updateFeed"); 255 | 256 | // @ts-ignore 257 | await service.processFeedJob(rss); 258 | 259 | expect(axios.get).toBeCalledWith(rss.link); 260 | expect(service.updateFeed).toBeCalledTimes(0); 261 | }); 262 | 263 | it("new posts, but database post cant be found within them", async () => { 264 | const rss = { 265 | id: 1, 266 | link: "idk", 267 | name: "test", 268 | last: "https://www.reddit.com/r/funny/10/" 269 | }; 270 | 271 | // need to do this as I cant hoist any variables on the top of the file 272 | const mockFeed = await new Parser().parseString(""); 273 | prisma.rss.findMany = jest.fn().mockReturnValue([rss]); 274 | 275 | // @ts-ignore 276 | axios.get.mockResolvedValue(rss); 277 | 278 | jest.spyOn(axios, "get"); 279 | jest.spyOn(service, "processFeedJob"); 280 | jest.spyOn(service, "updateFeed"); 281 | 282 | // @ts-ignore 283 | await service.processFeedJob(rss); 284 | 285 | expect(importQueue.add).toBeCalledTimes(6); 286 | expect(axios.get).toBeCalledWith(rss.link); 287 | 288 | expect(service.updateFeed).toBeCalledWith({ 289 | where: { id: rss.id }, 290 | data: { last: mockFeed.items[0].link } 291 | }); 292 | }); 293 | }); 294 | }); 295 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | 635 | Copyright (C) 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | Copyright (C) 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . --------------------------------------------------------------------------------