jfa-go/telegram.go

280 lines
8.3 KiB
Go

package main
import (
"fmt"
"math/rand"
"strings"
"time"
tg "github.com/go-telegram-bot-api/telegram-bot-api"
"github.com/timshannon/badgerhold/v4"
)
const (
VERIF_TOKEN_EXPIRY_SEC = 10 * 60
)
type TelegramVerifiedToken struct {
ChatID int64
Username string
JellyfinID string // optional, for ensuring a user-requested change is only accessed by them.
}
// VerifToken stores details about a pending user verification token.
type VerifToken struct {
Expiry time.Time
JellyfinID string // optional, for ensuring a user-requested change is only accessed by them.
}
type TelegramDaemon struct {
Stopped bool
ShutdownChannel chan string
bot *tg.BotAPI
username string
tokens map[string]VerifToken // Map of pins to tokens.
verifiedTokens map[string]TelegramVerifiedToken // Map of token pins to the responsible ChatID+Username.
languages map[int64]string // Store of languages for chatIDs. Added to on first interaction, and loaded from app.storage.telegram on start.
link string
app *appContext
}
func newTelegramDaemon(app *appContext) (*TelegramDaemon, error) {
token := app.config.Section("telegram").Key("token").String()
if token == "" {
return nil, fmt.Errorf("token was blank")
}
bot, err := tg.NewBotAPI(token)
if err != nil {
return nil, err
}
td := &TelegramDaemon{
ShutdownChannel: make(chan string),
bot: bot,
username: bot.Self.UserName,
tokens: map[string]VerifToken{},
verifiedTokens: map[string]TelegramVerifiedToken{},
languages: map[int64]string{},
link: "https://t.me/" + bot.Self.UserName,
app: app,
}
for _, user := range app.storage.GetTelegram() {
if user.Lang != "" {
td.languages[user.ChatID] = user.Lang
}
}
return td, nil
}
func genAuthToken() string {
rand.Seed(time.Now().UnixNano())
pin := make([]rune, 8)
for i := range pin {
if (i+1)%3 == 0 {
pin[i] = '-'
} else {
pin[i] = runes[rand.Intn(len(runes))]
}
}
return string(pin)
}
var runes = []rune("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
// NewAuthToken generates an 8-character pin in the form "A1-2B-CD".
func (t *TelegramDaemon) NewAuthToken() string {
pin := genAuthToken()
t.tokens[pin] = VerifToken{Expiry: time.Now().Add(VERIF_TOKEN_EXPIRY_SEC * time.Second), JellyfinID: ""}
return pin
}
// NewAssignedAuthToken generates an 8-character pin in the form "A1-2B-CD",
// and assigns it for access only with the given Jellyfin ID.
func (t *TelegramDaemon) NewAssignedAuthToken(id string) string {
pin := genAuthToken()
t.tokens[pin] = VerifToken{Expiry: time.Now().Add(VERIF_TOKEN_EXPIRY_SEC * time.Second), JellyfinID: id}
return pin
}
func (t *TelegramDaemon) run() {
t.app.info.Println("Starting Telegram bot daemon")
u := tg.NewUpdate(0)
u.Timeout = 60
updates, err := t.bot.GetUpdatesChan(u)
if err != nil {
t.app.err.Printf("Failed to start Telegram daemon: %v", err)
telegramEnabled = false
return
}
for {
var upd tg.Update
select {
case upd = <-updates:
if upd.Message == nil {
continue
}
sects := strings.Split(upd.Message.Text, " ")
if len(sects) == 0 {
continue
}
lang := t.app.storage.lang.chosenTelegramLang
storedLang, ok := t.languages[upd.Message.Chat.ID]
if !ok {
found := false
for code := range t.app.storage.lang.Telegram {
if code[:2] == upd.Message.From.LanguageCode {
lang = code
found = true
break
}
}
if found {
t.languages[upd.Message.Chat.ID] = lang
}
} else {
lang = storedLang
}
switch msg := sects[0]; msg {
case "/start":
t.commandStart(&upd, sects, lang)
continue
case "/lang":
t.commandLang(&upd, sects, lang)
continue
default:
t.commandPIN(&upd, sects, lang)
}
case <-t.ShutdownChannel:
t.bot.StopReceivingUpdates()
t.ShutdownChannel <- "Down"
return
}
}
}
func (t *TelegramDaemon) Reply(upd *tg.Update, content string) error {
msg := tg.NewMessage((*upd).Message.Chat.ID, content)
_, err := t.bot.Send(msg)
return err
}
func (t *TelegramDaemon) QuoteReply(upd *tg.Update, content string) error {
msg := tg.NewMessage((*upd).Message.Chat.ID, content)
msg.ReplyToMessageID = (*upd).Message.MessageID
_, err := t.bot.Send(msg)
return err
}
var escapedChars = []string{"_", "\\_", "*", "\\*", "[", "\\[", "]", "\\]", "(", "\\(", ")", "\\)", "~", "\\~", "`", "\\`", ">", "\\>", "#", "\\#", "+", "\\+", "-", "\\-", "=", "\\=", "|", "\\|", "{", "\\{", "}", "\\}", ".", "\\.", "!", "\\!"}
var escaper = strings.NewReplacer(escapedChars...)
// Send will send a telegram message to a list of chat IDs. message.text is used if no markdown is given.
func (t *TelegramDaemon) Send(message *Message, ID ...int64) error {
for _, id := range ID {
var msg tg.MessageConfig
if message.Markdown == "" {
msg = tg.NewMessage(id, message.Text)
} else {
text := escaper.Replace(message.Markdown)
msg = tg.NewMessage(id, text)
msg.ParseMode = "MarkdownV2"
}
_, err := t.bot.Send(msg)
if err != nil {
return err
}
}
return nil
}
func (t *TelegramDaemon) Shutdown() {
t.Stopped = true
t.ShutdownChannel <- "Down"
<-t.ShutdownChannel
close(t.ShutdownChannel)
}
func (t *TelegramDaemon) commandStart(upd *tg.Update, sects []string, lang string) {
content := t.app.storage.lang.Telegram[lang].Strings.get("startMessage") + "\n"
content += t.app.storage.lang.Telegram[lang].Strings.template("languageMessage", tmpl{"command": "/lang"})
err := t.Reply(upd, content)
if err != nil {
t.app.err.Printf("Telegram: Failed to send message to \"%s\": %v", upd.Message.From.UserName, err)
}
}
func (t *TelegramDaemon) commandLang(upd *tg.Update, sects []string, lang string) {
if len(sects) == 1 {
list := "/lang `<lang>`\n"
for code := range t.app.storage.lang.Telegram {
list += fmt.Sprintf("`%s`: %s\n", code, t.app.storage.lang.Telegram[code].Meta.Name)
}
err := t.Reply(upd, list)
if err != nil {
t.app.err.Printf("Telegram: Failed to send message to \"%s\": %v", upd.Message.From.UserName, err)
}
return
}
if _, ok := t.app.storage.lang.Telegram[sects[1]]; ok {
t.languages[upd.Message.Chat.ID] = sects[1]
for _, user := range t.app.storage.GetTelegram() {
if user.ChatID == upd.Message.Chat.ID {
user.Lang = sects[1]
t.app.storage.SetTelegramKey(user.JellyfinID, user)
break
}
}
}
}
func (t *TelegramDaemon) commandPIN(upd *tg.Update, sects []string, lang string) {
token, ok := t.tokens[upd.Message.Text]
if !ok || time.Now().After(token.Expiry) {
err := t.QuoteReply(upd, t.app.storage.lang.Telegram[lang].Strings.get("invalidPIN"))
if err != nil {
t.app.err.Printf("Telegram: Failed to send message to \"%s\": %v", upd.Message.From.UserName, err)
}
delete(t.tokens, upd.Message.Text)
return
}
err := t.QuoteReply(upd, t.app.storage.lang.Telegram[lang].Strings.get("pinSuccess"))
if err != nil {
t.app.err.Printf("Telegram: Failed to send message to \"%s\": %v", upd.Message.From.UserName, err)
}
t.verifiedTokens[upd.Message.Text] = TelegramVerifiedToken{
ChatID: upd.Message.Chat.ID,
Username: upd.Message.Chat.UserName,
JellyfinID: token.JellyfinID,
}
delete(t.tokens, upd.Message.Text)
}
// TokenVerified returns whether or not a token with the given PIN has been verified, and the token itself.
func (t *TelegramDaemon) TokenVerified(pin string) (token TelegramVerifiedToken, ok bool) {
token, ok = t.verifiedTokens[pin]
// delete(t.verifiedTokens, pin)
return
}
// AssignedTokenVerified returns whether or not a token with the given PIN has been verified, and the token itself.
// Returns false if the given Jellyfin ID does not match the one in the token.
func (t *TelegramDaemon) AssignedTokenVerified(pin string, jfID string) (token TelegramVerifiedToken, ok bool) {
token, ok = t.verifiedTokens[pin]
if ok && token.JellyfinID != jfID {
ok = false
}
// delete(t.verifiedTokens, pin)
return
}
// UserExists returns whether or not a user with the given username exists.
func (t *TelegramDaemon) UserExists(username string) bool {
c, err := t.app.storage.db.Count(&TelegramUser{}, badgerhold.Where("Username").Eq(username))
return err != nil || c > 0
}
// DeleteVerifiedToken removes the token with the given PIN.
func (t *TelegramDaemon) DeleteVerifiedToken(pin string) {
delete(t.verifiedTokens, pin)
}