Skip to content

Commit

Permalink
First commit v1.0.0
Browse files Browse the repository at this point in the history
  • Loading branch information
emmaexe committed Feb 5, 2023
1 parent aa22e8f commit 1ce0d2f
Show file tree
Hide file tree
Showing 8 changed files with 487 additions and 1 deletion.
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,7 @@ dist

# TernJS port file
.tern-port

config.json
token.json
notifications.json
93 changes: 92 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,92 @@
# anilist-notifications
<h1 align="center">anilist-notifications</h1>
<p>
<img src="https://img.shields.io/badge/node-18.x-blue.svg" />
<a href="https://github.com/emmaexe/anilist-notifications/wiki" target="_blank">
<img alt="Documentation" src="https://img.shields.io/badge/documentation-yes-brightgreen.svg" />
</a>
<a href="https://github.com/emmaexe/anilist-notifications/graphs/commit-activity" target="_blank">
<img alt="Maintenance" src="https://img.shields.io/badge/Maintained%3F-yes-green.svg" />
</a>
<a href="https://github.com/emmaexe/anilist-notifications/blob/main/LICENSE" target="_blank">
<img alt="License: GPL-3.0-ONLY" src="https://img.shields.io/github/license/emmaexe/anilist-notifications" />
</a>
</p>

> A notification server for Anilist that uses ntfy.
### 🏠 [Repository](https://github.com/emmaexe/anilist-notifications)

## **Dependencies**

- NodeJS 18.x
- node-fetch

## **Installation**

Download or clone the [latest release](https://github.com/emmaexe/anilist-notifications/releases/latest).

Enter the directory and run:

```sh
npm install
```

## **Setup/configuration**

[Go to the anilist website and navigate to Settings -> Apps -> Developer.](https://anilist.co/settings/developer) Press on "Create New Client. Pick whatever you want as the Name and enter ``https://anilist.co/api/v2/oauth/pin`` as the Redirect URL. After the app is created, click on it and copy the number under the ID field. Ignore all other fields (Secret, Name, Redirect URL). The config-template folder in the root directory of the project contains the template for the configuration file. Create a folder called ``config`` (or just rename ``config-template`` to ``config``), copy the config template into it and rename it to `config.json` (from ``config.json.template``). Fill in the configuration file (this is where you paste the previously copied Api Client ID) and run the bot.

Quick explanation of the config.json file:

```json
{
"ntfy": {
"address": STRING,
"port": INT,
"topic": STRING,
"authType": STRING,
"username": STRING,
"password": STRING
},
"id": STRING,
"refresh": INT
}
```

- `ntfy` contains settings regarding the ntfy server you are connecting to:
- `address` is just the address of the server and nothing else (e.g. `ntfy.sh`).
- `port` is the port where the ntfy server is hosted.
- `topic` is the topic to which this server will send notifications to. Quoting the ntfy docs: "Topics are created on the fly by subscribing or publishing to them. Because there is no sign-up, the topic is essentially a password, so pick something that's not easily guessable."
- `authType` is the auth type for the ntfy server. This is only useful if using a selfhosted ntfy server where you use credentials to log in. If you are using such a server you can also ignore the quote on topic names from the previous config entry. Possible auth types are `header` and `param`. Header sends the auth username+password as a header and param as a URL parameter. If the key is left empty, something random is typed in or the key is deleted, no authentication will be used
- `username` ntfy username used in auth
- `password` ntfy password used in auth
- `id` is the Api Client ID you copied before. Make sure to enter it in the config as a string not an integer.
- `refresh` is the delay between checking for notifications with the Anilist API (in miliseconds). The default is 60000 (that is 60 seconds, just divide by 1000) and the minimum is 15000 (15 seconds). If you try to go lower the delay will be increased to 15000 automatically to not spam the Anilist API.

When starting the bot up for the first time after finishing the config file, you will be prompted to follow a URL and authorize the application you made earlier to access your account. You will then recieve a token you will need to paste into the console. This token grants the server 1 year of access to your account's API. You will recieve a notification via the same ntfy topic you use for Anilist notifications when the token gets close to expiry. The same topic will be used for notifications of any Anilist errors that may occur. Should something go wrong or your token is revoked and the app does not request you re-create the token, simply delete the `token.json` file in the root of the project to make the server forget the previous token and prompt you to set it up again.

## **Usage**

Run this command in the root directory of the cloned repository:

```sh
node .
```

## **Author**

👤 **Emmaexe**

- Github: [@emmaexe](https://github.com/emmaexe)
- Discord: [@emmaexe#0859](https://discord.gg/v4YrAgBRvz)

## 🤝 Contributing

Contributions, issues and feature requests are welcome!<br />Feel free to check [issues page](https://github.com/emmaexe/anilist-notifications/issues).

## Show your support

Give a ⭐️ if this project helped you!

## 📝 License

This project is [GPL-3.0-only](https://github.com/emmaexe/anilist-notifications/blob/main/LICENSE) licensed.
12 changes: 12 additions & 0 deletions config-template/config.json.template
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"ntfy": {
"address": STRING,
"port": INT,
"topic": STRING,
"authType": STRING,
"username": STRING,
"password": STRING
},
"id": STRING,
"refresh": INT
}
117 changes: 117 additions & 0 deletions config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
const readline = require('readline')
const configfile = require('./config/config.json')
const fs = require('fs')
const fetch = require('node-fetch');
const { notificationBuilder } = require('./ntfy.js')
const query = `query {
page: Page {
notifications: notifications {
... on AiringNotification {
id
type
animeId
episode
contexts
createdAt
media: media {
id
title { userPreferred }
type
coverImage { color, medium, large, extraLarge }
siteUrl
}
}
}
}
}`

module.exports = {
"configClass": class config {
constructor() {
this.token = {}
for (let item in configfile) { this[item] = configfile[item]; }
if (!this.refresh) {
this.refresh = 60000;
} else if (this.refresh < 15000) {
this.refresh = 15000;
}
this.token_access()
}
async token_access() {
fs.readFile('./token.json', {encoding:'utf8'}, (err, data) => {
if (err) { console.error(err); return this.token_constructor(); } else {
let tokenfile = JSON.parse(data);
if (tokenfile === undefined) { return this.token_constructor(); } else {
if (!(tokenfile.token && tokenfile.expires)) { return this.token_constructor(); } else {
this.token = {
'token': tokenfile.token,
'expires': tokenfile.expires
}
return;
}
}
}
})
}
async token_constructor() {
console.clear()
const cin = readline.createInterface({
input: process.stdin,
output: process.stdout
});
cin.question(`Please follow this URL to grant the server an auth token.\nhttps://anilist.co/api/v2/oauth/authorize?client_id=${this.id}&response_type=token\nYou will need to copy paste it into the console.\nYou only need to do this once a year (the lifespan of the token)\n`, async token => {
let newtokenfile = {'token': token, 'expires': Date.now() + 31536000000}
fs.writeFile('./token.json', JSON.stringify(newtokenfile), (err) => {
if (err) console.error(err);
cin.close();
process.exit();
})
});
}
async token_verify(manager) {
let notification = new notificationBuilder()
let url = 'https://graphql.anilist.co';
let options = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': `Bearer ${this.token.token}`
},
body: JSON.stringify({
'query': query
})
};
let res_raw = await fetch(url, options); let res = await res_raw.json();
if (res['errors'] != undefined) {
if (res.errors[0].message === "Invalid token" && res.errors[0].status === 400) {
notification.setTitle("AniList token expired.")
notification.setBody("Your AniList token has expired or is invalid. AniList tokens have a lifespan of 1 year. Please re-create the token to continue receiving your AniList push notifications.")
notification.setIcon('https://anilist.co/img/icons/android-chrome-512x512.png')
notification.setClickEvent("https://anilist.co/settings/apps")
notification.setPriority('urgent')
manager.send(notification)
} else {
notification.setTitle("Generic AniList API error.")
notification.setBody(`Error ${res.errors[0].status}: ${res.errors[0].message}`)
notification.setIcon('https://anilist.co/img/icons/android-chrome-512x512.png')
notification.setClickEvent("https://anilist.co/settings/apps")
notification.setPriority('high')
manager.send(notification)
}
} else {
let now = Date.now();
if ( (this.token.expires - now) <= 604800000 ) {
let expiryDate = (new Date(this.token.expires)).toISOString()
notification.setTitle("Your anilist token will expire within a week.")
notification.setBody(`Your AniList token will expire on: ${expiryDate}. AniList tokens have a lifespan of 1 year. Please re-create the token to continue receiving your AniList push notifications.`)
notification.setIcon('https://anilist.co/img/icons/android-chrome-512x512.png')
notification.setClickEvent("https://anilist.co/settings/apps")
notification.setPriority('default')
manager.send(notification)
}
}
}
},
"query": query
}
109 changes: 109 additions & 0 deletions ntfy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
const http = require('http');
function httpsPost({body, ...options}) {
return new Promise((resolve,reject) => {
const req = http.request({
method: 'POST',
...options,
}, res => {
const chunks = [];
res.on('data', data => chunks.push(data))
res.on('end', () => {
let resBody = Buffer.concat(chunks);
switch(res.headers['content-type']) {
case 'application/json':
resBody = JSON.parse(resBody);
break;
}
resolve(resBody)
})
})
req.on('error',reject);
if(body) {
req.write(body);
}
req.end();
})
}

module.exports = {
"notificationManager": class notificationManager{
constructor(address, port, topic, authType, username, password) {
this.address = address
this.port = isNaN(port) ? 80 : port
this.topic = topic
this.username = username
this.password = password
this.authType = (authType === undefined) ? 'none' : ((authType === 'header') ? 'header' : ((authType === 'param') ? 'param' : undefined))
this.authHeader = Buffer.from(`${this.username}:${this.password}`).toString('base64')
this.authParam = Buffer.from("Basic " + this.authHeader).toString('base64')
}
async send(notification) {
let path;
if (this.authType === 'none' || this.authType === undefined) {
path = `/${this.topic}`
} else if (this.authType === 'header') {
path = `/${this.topic}`
notification.headers.Authorization = `Basic ${this.authHeader}`
} else if (this.authType === 'param') {
path = `/${this.topic}?auth=${this.authParam}`
}
const res = await httpsPost({
hostname: this.address,
port: this.port,
path: path,
headers: notification.headers,
body: notification.body
})
}
},
"notificationBuilder": class notificationBuilder{
constructor() {
this.headers = {}
this.body = ""
}
setBody(body) {
this.body = body
}
setTitle(title) {
this.headers.Title = title
}
setPriority(priority) {
if (priority == 5 || priority == 'max' || priority == 'urgent') {
this.headers.Priority = 'urgent'
} else if (priority == 4 || priority == 'high') {
this.headers.Priority = 'high'
} else if (priority == 3 || priority == 'default') {
this.headers.Priority = 'default'
} else if (priority == 2 || priority == 'low') {
this.headers.Priority = 'low'
} else if (priority == 1 || priority == 'min') {
this.headers.Priority = 'min'
} else {
this.headers.Priority = 'default'
}
}
addTags(tags) {
if (Array.isArray(tags)) {
if (tags.length > 0) {
if (!this.headers.Tags) this.headers.Tags = ""
for (let tag in tags) {
if (this.header.Tags.length > 0) this.header.Tags+=','
this.headers.Tags+=tag.toString()
}
}
}
}
scheduleDelivery(time) {
this.headers.At = time
}
setClickEvent(url) {
this.headers.Click = url
}
setAttachment(url) {
this.headers.Attach = url
}
setIcon(url) {
this.headers.Icon = url
}
}
}
49 changes: 49 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading

0 comments on commit 1ce0d2f

Please sign in to comment.