This commit is contained in:
2021-12-20 15:38:11 +01:00
commit afd830c3bc
23 changed files with 1257 additions and 0 deletions

21
src/config.ts Normal file
View File

@@ -0,0 +1,21 @@
import dotenv from 'dotenv'
import { Logger } from './logger.js'
dotenv.config()
const token = process.env['BOT_TOKEN']
if (!token) {
Logger.error('No token found')
process.exit(1)
}
const password = process.env['HCS_PASSWORD']
if (!password) {
Logger.error('No password found')
process.exit(1)
}
export const Config = {
token,
password,
}

24
src/cron.ts Normal file
View File

@@ -0,0 +1,24 @@
import dayjs from 'dayjs'
import cron from 'node-cron'
import { DB } from './db.js'
import { bot } from './index.js'
import { getLatests } from './routes/status.js'
async function fn() {
const now = dayjs()
for (const user of DB.data?.users ?? []) {
const { fed, clean } = getLatests()
if (!fed || dayjs(fed.timestamp).isBefore(now.subtract(12, 'hours'))) {
await bot.telegram.sendMessage(user.id, '🥜 You have not fed me in the last 12 hours!')
}
if (!clean || dayjs(clean.timestamp).isBefore(now.subtract(7, 'days'))) {
await bot.telegram.sendMessage(user.id, '🛁 You have not cleaned me in the last 7 days!')
}
}
}
export function init() {
cron.schedule('0 22 * * *', fn)
// cron.schedule('*/10 * * * * *', fn)
}

38
src/db.ts Normal file
View File

@@ -0,0 +1,38 @@
import { mkdir } from 'fs/promises'
import { Low, JSONFile } from 'lowdb'
import { join, resolve } from 'path'
const dataDir = resolve('./data')
export type Feeding = {
timestamp: number
by: string
}
export type Cleaning = {
timestamp: number
by: string
}
export type User = {
username: string
id: number
}
export type Data = {
feeding: Feeding[]
cleaning: Cleaning[]
users: User[]
}
export const DB = new Low<Data>(new JSONFile(join(dataDir, 'db.json')))
export async function init() {
await mkdir(dataDir, { recursive: true })
await DB.read()
DB.data ||= {
cleaning: [],
feeding: [],
users: [],
}
}

1
src/feeding.ts Normal file
View File

@@ -0,0 +1 @@
import { Telegraf, Markup } from 'telegraf'

32
src/index.ts Normal file
View File

@@ -0,0 +1,32 @@
import { Context, Telegraf } from 'telegraf'
import { Config } from './config.js'
import { init as initCron } from './cron.js'
import { init as initDB, User } from './db.js'
import { Logger } from './logger.js'
import { init as initMiddleware } from './middleware.js'
import { init as initRoutes } from './routes/index.js'
import { Version } from './routes/version.js'
export interface HCSContext extends Context {
user: User
authenticated: boolean
}
export const bot = new Telegraf<HCSContext>(Config.token)
export type Bot = typeof bot
// Enable graceful stop
process.once('SIGINT', () => bot.stop('SIGINT'))
process.once('SIGTERM', () => bot.stop('SIGTERM'))
async function init() {
await initDB()
initMiddleware(bot)
initRoutes(bot)
initCron()
await bot.launch()
Logger.info('Bot started')
Logger.info(`Version: ${Version}`)
}
init()

4
src/logger.ts Normal file
View File

@@ -0,0 +1,4 @@
import pino from 'pino'
import pp from 'pino-pretty'
export const Logger = pino(pp({ ignore: 'pid,hostname' }))

6
src/messages.ts Normal file
View File

@@ -0,0 +1,6 @@
export const messages = {
welcome: 'Welcome to the HCS\nThe Hagen Control Station!',
requestAccess: '🗝 Insert the password to access the HCS',
invalidPassword: '🤔 Invalid password',
gainedAccess: '🥳 You gained access to the HCS',
}

56
src/middleware.ts Normal file
View File

@@ -0,0 +1,56 @@
import ms from 'ms'
import { Bot } from '.'
import { Config } from './config.js'
import { DB } from './db.js'
import { Logger } from './logger.js'
import { messages } from './messages.js'
export function init(bot: Bot) {
// Logger
bot.use(async (ctx, next) => {
const now = Date.now()
await next()
const elapsed = Date.now() - now
Logger.info(`Processed: ${ctx.updateType} (${ms(elapsed)})`)
})
// Auth
bot.use(async (ctx, next) => {
ctx.authenticated = false
const username = ctx.chat && ctx.chat.type === 'private' && ctx.chat.username
if (!username) {
ctx.reply('No username')
return
}
const user = DB.data?.users.find((u) => u.username === username)
if (user) {
ctx.authenticated = true
ctx.user = user
} else {
const message = (ctx.updateType === 'message' && 'text' in ctx.message! && ctx.message.text) || ''
switch (message.toLowerCase().trim()) {
case '/start':
case '/help':
break
case Config.password:
const user = {
username,
id: ctx.chat.id,
}
DB.data?.users.push(user)
DB.write()
ctx.authenticated = true
ctx.user = user
ctx.deleteMessage()
ctx.reply(messages.gainedAccess)
break
default:
if (ctx.updateType === 'message') ctx.reply(messages.invalidPassword)
return
}
}
await next()
})
}

22
src/routes/auth.ts Normal file
View File

@@ -0,0 +1,22 @@
import type { Bot } from '..'
import { DB } from '../db.js'
import { Logger } from '../logger.js'
import { messages } from '../messages.js'
export function init(bot: Bot) {
bot.on('my_chat_member', (ctx) => {
if (ctx.myChatMember.new_chat_member.status === 'kicked' || ctx.myChatMember.new_chat_member.status === 'left') {
Logger.info(`User ${ctx.user.username} left chat`)
if (DB.data?.users) {
DB.data.users = DB.data.users.filter((u) => u.username !== ctx.user.username)
DB.write()
}
}
})
bot.start((ctx) => {
if (!ctx.from.username) throw new Error('No username')
ctx.reply(messages.welcome)
ctx.reply(messages.requestAccess)
})
}

12
src/routes/index.ts Normal file
View File

@@ -0,0 +1,12 @@
import { Bot } from '..'
import { init as initAuth } from './auth.js'
import { init as initLog } from './log.js'
import { init as initStatus } from './status.js'
import { init as initVersion } from './version.js'
export function init(bot: Bot) {
initAuth(bot)
initLog(bot)
initStatus(bot)
initVersion(bot)
}

47
src/routes/log.ts Normal file
View File

@@ -0,0 +1,47 @@
import { Markup } from 'telegraf'
import type { Bot } from '..'
import { DB } from '../db.js'
import { disappearingMessage } from '../utils.js'
enum LogCommands {
Fed = 'log:fed',
Clean = 'log:clean',
Cancel = 'log:cancel',
}
export async function init(bot: Bot) {
bot.command('log', (ctx) => {
ctx.deleteMessage()
const buttons = Markup.inlineKeyboard([
[Markup.button.callback('🥜 Fed', LogCommands.Fed)],
[Markup.button.callback('🛁 Cleaned', LogCommands.Clean)],
[Markup.button.callback('❌ Cancel', LogCommands.Cancel)],
])
ctx.replyWithMarkdownV2('What do you want to log?', { reply_markup: buttons.reply_markup })
})
bot.action(LogCommands.Clean, (ctx) => {
ctx.deleteMessage()
DB.data?.cleaning.push({
timestamp: Date.now(),
by: ctx.user.username,
})
DB.write()
disappearingMessage(ctx, 'Saved')
})
bot.action(LogCommands.Fed, (ctx) => {
ctx.deleteMessage()
DB.data?.feeding.push({
timestamp: Date.now(),
by: ctx.user.username,
})
DB.write()
disappearingMessage(ctx, 'Saved')
})
bot.action(LogCommands.Cancel, (ctx) => {
ctx.deleteMessage()
disappearingMessage(ctx, 'Cancelled')
})
}

22
src/routes/status.ts Normal file
View File

@@ -0,0 +1,22 @@
import { DB } from '../db.js'
import { Bot } from '../index.js'
import { format, getLatest } from '../utils.js'
export function getLatests() {
const fed = getLatest(DB.data?.feeding || [])
const clean = getLatest(DB.data?.cleaning || [])
return { fed, clean }
}
export function init(bot: Bot) {
bot.command('status', (ctx) => {
ctx.deleteMessage()
const { fed, clean } = getLatests()
let msg = ''
msg += '**🥜 Fed**\n'
msg += fed ? `${format(fed.timestamp)} by ${fed.by}` : '🥲 Never'
msg += '\n\n**🛁 Cleaned**\n'
msg += clean ? `${format(clean.timestamp)} by ${clean.by}` : '🥲 Never'
ctx.replyWithMarkdownV2(msg)
})
}

10
src/routes/version.ts Normal file
View File

@@ -0,0 +1,10 @@
import { Bot } from '..'
export const Version = process.env['npm_package_version']
export function init(bot: Bot) {
bot.command('/version', async (ctx) => {
// ctx.reply('Version: ' + version)
ctx.reply(`Version: ${Version}`)
})
}

19
src/utils.ts Normal file
View File

@@ -0,0 +1,19 @@
import dayjs from 'dayjs'
import type { Cleaning, Feeding } from './db.js'
import { HCSContext } from './index.js'
export function format(timestamp: number): string {
const d = dayjs(timestamp)
return d.format('DD MMM') + ' at ' + d.format('HH:mm')
}
export function getLatest<T extends Cleaning | Feeding>(data: T[]): T | null {
return data.sort((a, b) => b.timestamp - a.timestamp)[0] || null
}
export async function disappearingMessage(ctx: HCSContext, msg: string, timeout = 2000) {
const message = await ctx.reply(msg)
setTimeout(() => {
ctx.deleteMessage(message.message_id)
}, timeout)
}