Initial commit

This commit is contained in:
gohumble 2021-09-10 08:52:44 +02:00
commit acf0cb5af6
49 changed files with 4358 additions and 0 deletions

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
config.yaml
config.yaml*
data/*
!data/.placeholder

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020 neurolib-dev
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

129
README.md Normal file
View file

@ -0,0 +1,129 @@
<p align="center">
<img alt="logo" src="resources/logo_round.png" >
</p>
# @LightningTipBot 🏅
A Telegram Lightning ⚡️ Bitcoin wallet and tip bot for group chats.
This repository contains everything you need to set up and run your own tip bot. If you simply want to use this bot in your group chat without having to install anything just start a conversation with [@LightningTipBot](https://t.me/LightningTipBot) and invite it into your group chat.
## Setting up the bot
### Installation
To build the bot from source, clone the repository and compile the source code.
```
git clone https://github.com/LightningTipBot/LightningTipBot.git
cd LightningTipBot
go build .
cp config.yaml-example config.yaml
```
After the configuration (see below), start it using the command
```
./LightningTipBot
```
### Configuration
You need to edit `config.yaml` before starting the bot.
#### Create a Telegram bot
First, create a new Telegram bot by starting a conversation with the [@BotFather](https://core.telegram.org/bots#6-botfather). After you have created your bot, you will get an **Api Token** which you need to add to `telegram_api_key` in config.yaml accordingly.
#### Set up LNbits
You can either use your own LNbits instance (recommended) or create an account at [lnbits.com](https://lnbits.com/) to use their custodial service (easy).
1. Create a wallet in LNbits (reachable by the bot via `lnbits_url`).
2. Get the **Admin key** in the API Info tab of your user (`lnbits_admin_key`).
3. Enable the User Manager extension.
4. Get the **Admin ID** of the User Manager (`lnbits_admin_id`).
#### Getting LNbits keys
<p align="center">
<img alt="How to set up a lnbits wallet and the User Manager extension." src="resources/lnbits_setup.png" >
</p>
#### More configuration
- `db_path`: User database file path.
- `transactions_path`: Transaction database file path.
- `buntdb_path`: Object storage database file path.
- `lnbits_webhook_server`: URL that lnbits can reach the bot with. This is used for creating webhooks from LNbits to receive notifications about payments (optional).
- `message_dispose_duration`: Duration in seconds after which `/tip` are deleted from a channel (only if the bot is channel admin).
- `http_proxy` uses a proxy for all LNURL-related outbound requests (optional).
- `lnurl_public_host_name` is the public URL of your lnbits/LndHub (for BlueWallet/Zap support, optional).
- `lnurl_server` is the public URL for inbound LNURL payments and your lightning address host (optional).
## Features
#### Commands
```
/tip 🏅 Reply to a message to tip it: /tip <amount> [<memo>]
/balance 👑 Check your balance: /balance
/send 💸 Send funds to a user: /send <amount> <@user> or <user@domain.com> [<memo>]
/invoice ⚡️ Receive over Lightning: /invoice <amount> [<memo>]
/pay ⚡️ Pay over Lightning: /pay <invoice>
/help 📖 Read this help.
/advanced 🤖 Read the advanced help.
/basics 📚 More info.
/donate ❤️ Donate to the project: /donate <amount>
```
#### Advanced commands
```
/link 🔗 Link your wallet to BlueWallet or Zeus
/lnurl ⚡️ Lnurl receive or pay: /lnurl or /lnurl <lnurl>
```
### Live tooltips
The bot replies to a tipped message to indicate to all participants how many and what amount of tips a post has received. This tooltip will be updated as new tips are given to a post.
<p align="center">
<img alt="How to set up a lnbits wallet and the User Manager extension." src="resources/tooltips.png" >
</p>
### LNURL server
Users can send and receive via . For this to work, you need to set the `lnurl_public_server` in `config.yaml`. The bot will then host a LNURL endpoint at `.well-known/lnurlp/username` which handles the data exchange with other wallets. You can set `http_proxy` in `config.yaml` to send outbound requests only via an HTTP proxy.
### Send and receive via Lightning Address
Every user has a [Lightning Address](https://lightningaddress.com/) a la `username@host.com` with which they can send to via `/send <amount> <user@domain.com>` and receive from other wallets.
### Link to BlueWallet or Zap
Every user can link their wallet to an external app like [Bluewallet](https://bluewallet.io/) or [Zeus](https://zeusln.app/) by using the command `/link`. If you host the bot, you will have to enable the LndHub extension in LNbits. You also need to edit the `lnbits_public_url` entry in `config.yaml` accordingly to an address that can be reached by the user's wallet (Tor should be fine as well).
<p align="center">
<img alt="QR code payment example." src="resources/lndhub.png" >
</p>
### Pay invoices by sending QR codes
To pay a Lightning invoice, you can snap a photo of a QR code and send it directly to the bot. Note that you might need to zoom in, center the QR code, or crop the image if the bot fails to decode the QR code from the photo. By the way, you can also just send an the invoice as a string, the bot will automatically detect it and initiate a payment.
<p align="center">
<img alt="QR code payment example." src="resources/qr_code_example.jpg" >
</p>
### Auto-delete commands
To minimize the clutter all the heavy tipping can cause in a group chat, the bot will remove all failed commands (for example due to a syntax error) from the chat immediately. All successful commands will stay visible for `message_dispose_duration` seconds (default 10s) and then be removed. The tips will sill be visible for everyone in the Live tooltip. This feature only works, if the bot is made admin of the group.
## Made with
- [LNbits](https://github.com/lnbits/lnbits) Free and open-source lightning-network wallet/accounts system.
- [telebot](https://github.com/tucnak/telebot) A Telegram bot framework in Go.
- [gozxing](https://github.com/makiuchi-d/gozxing) barcode image processing library in Go.
- [ln-decodepay](https://github.com/fiatjaf/ln-decodepay) Lightning Network BOLT11 invoice decoder.
- [go-lnurl](https://github.com/fiatjaf/go-lnurl) - Helpers for building lnurl support into services.

38
amounts.go Normal file
View file

@ -0,0 +1,38 @@
package main
import (
"errors"
"strconv"
"strings"
)
func getArgumentFromCommand(input string, which int) (output string, err error) {
if len(strings.Split(input, " ")) < which+1 {
return "", errors.New("message doesn't contain enough arguments")
}
output = strings.Split(input, " ")[which]
return output, nil
}
func decodeAmountFromCommand(input string) (amount int, err error) {
if len(strings.Split(input, " ")) < 2 {
errmsg := "message doesn't contain any amount"
// log.Errorln(errmsg)
return 0, errors.New(errmsg)
}
amount, err = getAmount(input)
return amount, nil
}
func getAmount(input string) (amount int, err error) {
amount, err = strconv.Atoi(strings.Split(input, " ")[1])
if err != nil {
return 0, err
}
if amount < 1 {
errmsg := "error: Amount must be greater than 0"
// log.Errorln(errmsg)
return 0, errors.New(errmsg)
}
return amount, err
}

46
balance.go Normal file
View file

@ -0,0 +1,46 @@
package main
import (
"fmt"
log "github.com/sirupsen/logrus"
tb "gopkg.in/tucnak/telebot.v2"
)
const (
balanceMessage = "👑 *Your balance:* %d sat"
balanceErrorMessage = "🚫 Error fetching your balance. Please try again later."
)
func (bot TipBot) balanceHandler(m *tb.Message) {
// check and print all commands
bot.anyTextHandler(m)
// reply only in private message
if m.Chat.Type != tb.ChatPrivate {
// delete message
NewMessage(m, WithDuration(0, bot.telegram))
}
// first check whether the user is initialized
fromUser, err := GetUser(m.Sender, bot)
if err != nil {
log.Errorf("[/balance] Error: %s", err)
return
}
if !fromUser.Initialized {
bot.startHandler(m)
return
}
usrStr := GetUserStr(m.Sender)
balance, err := bot.GetUserBalance(m.Sender)
if err != nil {
log.Errorf("[/balance] Error fetching %s's balance: %s", usrStr, err)
bot.trySendMessage(m.Sender, balanceErrorMessage)
return
}
log.Infof("[/balance] %s's balance: %d sat\n", usrStr, balance)
bot.trySendMessage(m.Sender, fmt.Sprintf(balanceMessage, balance))
return
}

144
bot.go Normal file
View file

@ -0,0 +1,144 @@
package main
import (
"fmt"
"strings"
"sync"
"time"
"github.com/LightningTipBot/LightningTipBot/internal/storage"
"github.com/LightningTipBot/LightningTipBot/internal/lnurl"
log "github.com/sirupsen/logrus"
"github.com/LightningTipBot/LightningTipBot/internal/lnbits"
"gopkg.in/tucnak/telebot.v2"
tb "gopkg.in/tucnak/telebot.v2"
"gorm.io/gorm"
)
type TipBot struct {
database *gorm.DB
bunt *storage.DB
logger *gorm.DB
telegram *telebot.Bot
client *lnbits.Client
}
var (
paymentConfirmationMenu = &tb.ReplyMarkup{ResizeReplyKeyboard: true}
btnCancelPay = paymentConfirmationMenu.Data("🚫 Cancel", "cancel_pay")
btnPay = paymentConfirmationMenu.Data("✅ Pay", "confirm_pay")
sendConfirmationMenu = &tb.ReplyMarkup{ResizeReplyKeyboard: true}
btnCancelSend = sendConfirmationMenu.Data("🚫 Cancel", "cancel_send")
btnSend = sendConfirmationMenu.Data("✅ Send", "confirm_send")
botWalletInitialisation = sync.Once{}
telegramHandlerRegistration = sync.Once{}
)
// NewBot migrates data and creates a new bot
func NewBot() TipBot {
db, txLogger := migration()
return TipBot{
database: db,
logger: txLogger,
bunt: storage.NewBunt(Configuration.Database.BuntDbPath),
}
}
// newTelegramBot will create a new telegram bot.
func newTelegramBot() *tb.Bot {
tgb, err := tb.NewBot(tb.Settings{
Token: Configuration.Telegram.ApiKey,
Poller: &tb.LongPoller{Timeout: 60 * time.Second},
ParseMode: tb.ModeMarkdown,
})
if err != nil {
panic(err)
}
return tgb
}
// initBotWallet will create / initialize the bot wallet
// todo -- may want to derive user wallets from this specific bot wallet (master wallet), since lnbits usermanager extension is able to do that.
func (bot TipBot) initBotWallet() error {
botWalletInitialisation.Do(func() {
err := bot.initWallet(bot.telegram.Me)
if err != nil {
log.Errorln(fmt.Sprintf("[initBotWallet] Could not initialize bot wallet: %s", err.Error()))
return
}
})
return nil
}
// registerTelegramHandlers will register all telegram handlers.
func (bot TipBot) registerTelegramHandlers() {
telegramHandlerRegistration.Do(func() {
// Set up handlers
var endpointHandler = map[string]interface{}{
"/tip": bot.tipHandler,
"/pay": bot.confirmPaymentHandler,
"/invoice": bot.invoiceHandler,
"/balance": bot.balanceHandler,
"/start": bot.startHandler,
"/send": bot.confirmSendHandler,
"/help": bot.helpHandler,
"/basics": bot.basicsHandler,
"/donate": bot.donationHandler,
"/advanced": bot.advancedHelpHandler,
"/link": bot.lndhubHandler,
"/lnurl": bot.lnurlHandler,
tb.OnPhoto: bot.privatePhotoHandler,
tb.OnText: bot.anyTextHandler,
tb.OnQuery: bot.anyQueryHandler,
tb.OnChosenInlineResult: bot.anyChosenInlineHandler,
}
// assign handler to endpoint
for endpoint, handler := range endpointHandler {
log.Debugf("Registering: %s", endpoint)
bot.telegram.Handle(endpoint, handler)
// if the endpoint is a string command (not photo etc)
if strings.HasPrefix(endpoint, "/") {
// register upper case versions as well
bot.telegram.Handle(strings.ToUpper(endpoint), handler)
}
}
// button handlers
// for /pay
bot.telegram.Handle(&btnPay, bot.payHandler)
bot.telegram.Handle(&btnCancelPay, bot.cancelPaymentHandler)
// for /send
bot.telegram.Handle(&btnSend, bot.sendHandler)
bot.telegram.Handle(&btnCancelSend, bot.cancelSendHandler)
// register inline button handlers
// button for inline send
bot.telegram.Handle(&btnSendInline, bot.sendInlineHandler)
bot.telegram.Handle(&btnCancelSendInline, bot.cancelSendInlineHandler)
})
}
// Start will initialize the telegram bot and lnbits.
func (bot TipBot) Start() {
// set up lnbits api
bot.client = lnbits.NewClient(Configuration.Lnbits.AdminKey, Configuration.Lnbits.Url)
// set up telebot
bot.telegram = newTelegramBot()
log.Infof("[Telegram] Authorized on account @%s", bot.telegram.Me.Username)
// initialize the bot wallet
err := bot.initBotWallet()
if err != nil {
log.Errorf("Could not initialize bot wallet: %s", err.Error())
}
bot.registerTelegramHandlers()
lnbits.NewWebhookServer(Configuration.Lnbits.WebhookServerUrl, bot.telegram, bot.client, bot.database)
lnurl.NewServer(Configuration.Bot.LNURLServerUrl, Configuration.Bot.LNURLHostUrl, Configuration.Lnbits.WebhookServer, bot.telegram, bot.client, bot.database)
bot.telegram.Start()
}

15
botfather-setcommands.txt Normal file
View file

@ -0,0 +1,15 @@
# Instructions:
1 - Talk to the botfather
2 - Use: /setcommands
3 - Select your tip bot
4 - Paste this:
help - Read the help.
balance - Check balance.
tip - Reply to a message to tip. Example: /tip 50
send - Send funds to a user. Example: /send 100 @LightningTipBot
invoice - Receive with Lightning. Example: /invoice 1000
pay - Pay with Lightning. Example: /pay lnbc10n1ps...
donate - Donate: Example: /donate 1000
advanced - Advanced help

81
config.go Normal file
View file

@ -0,0 +1,81 @@
package main
import (
"fmt"
"net/url"
"strings"
"github.com/jinzhu/configor"
log "github.com/sirupsen/logrus"
)
var Configuration = struct {
Bot BotConfiguration `yaml:"bot"`
Telegram TelegramConfiguration `yaml:"telegram"`
Database DatabaseConfiguration `yaml:"database"`
Lnbits LnbitsConfiguration `yaml:"lnbits"`
}{}
type BotConfiguration struct {
HttpProxy string `yaml:"http_proxy"`
LNURLServer string `yaml:"lnurl_server"`
LNURLServerUrl *url.URL `yaml:"-"`
LNURLHostName string `yaml:"lnurl_public_host_name"`
LNURLHostUrl *url.URL `yaml:"-"`
}
type TelegramConfiguration struct {
MessageDisposeDuration int64 `yaml:"message_dispose_duration"`
ApiKey string `yaml:"api_key"`
}
type DatabaseConfiguration struct {
DbPath string `yaml:"db_path"`
BuntDbPath string `yaml:"buntdb_path"`
TransactionsPath string `yaml:"transactions_path"`
}
type LnbitsConfiguration struct {
AdminId string `yaml:"admin_id"`
AdminKey string `yaml:"admin_key"`
Url string `yaml:"url"`
LnbitsPublicUrl string `yaml:"lnbits_public_url"`
WebhookServer string `yaml:"webhook_server"`
WebhookServerUrl *url.URL `yaml:"-"`
}
func init() {
err := configor.Load(&Configuration, "config.yaml")
if err != nil {
panic(err)
}
webhookUrl, err := url.Parse(Configuration.Lnbits.WebhookServer)
if err != nil {
panic(err)
}
Configuration.Lnbits.WebhookServerUrl = webhookUrl
lnUrl, err := url.Parse(Configuration.Bot.LNURLServer)
if err != nil {
panic(err)
}
Configuration.Bot.LNURLServerUrl = lnUrl
hostname, err := url.Parse(Configuration.Bot.LNURLHostName)
if err != nil {
panic(err)
}
Configuration.Bot.LNURLHostUrl = hostname
checkLnbitsConfiguration()
}
func checkLnbitsConfiguration() {
if Configuration.Lnbits.Url == "" {
panic(fmt.Errorf("please configure a lnbits url"))
}
if Configuration.Lnbits.LnbitsPublicUrl == "" {
log.Warnf("Please specify a lnbits public url otherwise users won't be able to")
} else {
if !strings.HasSuffix(Configuration.Lnbits.LnbitsPublicUrl, "/") {
Configuration.Lnbits.LnbitsPublicUrl = Configuration.Lnbits.LnbitsPublicUrl + "/"
}
}
}

17
config.yaml.example Normal file
View file

@ -0,0 +1,17 @@
bot:
http_proxy: ""
lnurl_public_host_name: "mylnurl.com"
lnurl_server: "https://mylnurl.com"
telegram:
message_dispose_duration: 10
api_key: "1234"
lnbits:
url: "http://127.0.0.1:5000"
admin_key: "1234"
admin_id: "1234"
webhook_server: "http://0.0.0.0:5588"
lnbits_public_url: "link.mylnurl.com"
database:
db_path: "data/bot.db"
buntdb_path: "data/bunt.db"
transactions_path: "data/transactions.db"

0
data/.placeholder Normal file
View file

75
database.go Normal file
View file

@ -0,0 +1,75 @@
package main
import (
"fmt"
"reflect"
"strconv"
log "github.com/sirupsen/logrus"
"github.com/LightningTipBot/LightningTipBot/internal/lnbits"
tb "gopkg.in/tucnak/telebot.v2"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func migration() (db *gorm.DB, txLogger *gorm.DB) {
txLogger, err := gorm.Open(sqlite.Open(Configuration.Database.TransactionsPath), &gorm.Config{DisableForeignKeyConstraintWhenMigrating: true, FullSaveAssociations: true})
if err != nil {
panic("Initialize orm failed.")
}
orm, err := gorm.Open(sqlite.Open(Configuration.Database.DbPath), &gorm.Config{DisableForeignKeyConstraintWhenMigrating: true, FullSaveAssociations: true})
if err != nil {
panic("Initialize orm failed.")
}
err = orm.AutoMigrate(&lnbits.User{})
if err != nil {
panic(err)
}
err = txLogger.AutoMigrate(&Transaction{})
if err != nil {
panic(err)
}
return orm, txLogger
}
// GetUser from telegram user. Update the user if user information changed.
func GetUser(u *tb.User, bot TipBot) (*lnbits.User, error) {
user := &lnbits.User{Name: strconv.Itoa(u.ID)}
tx := bot.database.First(user)
if tx.Error != nil {
errmsg := fmt.Sprintf("[GetUser] Couldn't fetch %s's info from database.", GetUserStr(u))
log.Warnln(errmsg)
return user, tx.Error
}
defer func() {
user.Wallet.Client = bot.client
}()
var err error
go func() {
userCopy := bot.copyLowercaseUser(u)
if !reflect.DeepEqual(userCopy, user.Telegram) {
// update possibly changed user details in database
user.Telegram = userCopy
err = UpdateUserRecord(user, bot)
if err != nil {
log.Warnln(fmt.Sprintf("[UpdateUserRecord] %s", err.Error()))
}
}
}()
return user, err
}
func UpdateUserRecord(user *lnbits.User, bot TipBot) error {
user.Telegram = bot.copyLowercaseUser(user.Telegram)
tx := bot.database.Save(user)
if tx.Error != nil {
errmsg := fmt.Sprintf("[UpdateUserRecord] Error: Couldn't update %s's info in database.", GetUserStr(user.Telegram))
log.Errorln(errmsg)
return tx.Error
}
log.Debugf("[UpdateUserRecord] Records of user %s updated.", GetUserStr(user.Telegram))
return nil
}

162
donate.go Normal file
View file

@ -0,0 +1,162 @@
package main
import (
"fmt"
"io"
"io/ioutil"
"net/http"
"strings"
"github.com/LightningTipBot/LightningTipBot/internal/lnbits"
log "github.com/sirupsen/logrus"
tb "gopkg.in/tucnak/telebot.v2"
)
// PLEASE DO NOT CHANGE THE CODE IN THIS FILE
// YOU MIGHT BREAK DONATIONS TO THE ORIGINAL PROJECT
// THE DEVELOPMENT OF LIGHTNINGTIPBOT RELIES ON DONATIONS
// IF YOU USE THIS PROJECT, LEAVE THIS CODE ALONE
var (
donationSuccess = "🙏 Thank you for your donation."
donationErrorMessage = "🚫 Oh no. Donation failed."
donationProgressMessage = "🧮 Preparing your donation..."
donationFailedMessage = "🚫 Donation failed: %s"
donateEnterAmountMessage = "Did you enter an amount?"
donateValidAmountMessage = "Did you enter a valid amount?"
donateHelpText = "📖 Oops, that didn't work. %s\n\n" +
"*Usage:* `/donate <amount>`\n" +
"*Example:* `/donate 1000`"
endpoint string
)
func helpDonateUsage(errormsg string) string {
if len(errormsg) > 0 {
return fmt.Sprintf(donateHelpText, fmt.Sprintf("%s", errormsg))
} else {
return fmt.Sprintf(donateHelpText, "")
}
}
func (bot TipBot) donationHandler(m *tb.Message) {
// check and print all commands
bot.anyTextHandler(m)
if len(strings.Split(m.Text, " ")) < 2 {
bot.trySendMessage(m.Sender, helpDonateUsage(donateEnterAmountMessage))
return
}
amount, err := decodeAmountFromCommand(m.Text)
if err != nil {
return
}
if amount < 1 {
bot.trySendMessage(m.Sender, helpDonateUsage(donateValidAmountMessage))
return
}
// command is valid
msg := bot.trySendMessage(m.Sender, donationProgressMessage)
// get invoice
resp, err := http.Get(fmt.Sprintf(endpoint, amount, GetUserStr(m.Sender), GetUserStr(bot.telegram.Me)))
if err != nil {
log.Errorln(err)
bot.tryEditMessage(msg, donationErrorMessage)
return
}
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
log.Errorln(err)
bot.tryEditMessage(msg, donationErrorMessage)
return
}
// send donation invoice
user, err := GetUser(m.Sender, bot)
if err != nil {
return
}
// bot.trySendMessage(user.Telegram, string(body))
_, err = user.Wallet.Pay(lnbits.PaymentParams{Out: true, Bolt11: string(body)}, *user.Wallet)
if err != nil {
userStr := GetUserStr(m.Sender)
errmsg := fmt.Sprintf("[/donate] Donation failed for user %s: %s", userStr, err)
log.Errorln(errmsg)
bot.tryEditMessage(msg, fmt.Sprintf(donationFailedMessage, err))
return
}
bot.tryEditMessage(msg, donationSuccess)
}
func init() {
var sb strings.Builder
_, err := io.Copy(&sb, rot13Reader{strings.NewReader("uggcf://ya.gvcf/qbangr/%q?sebz=%f&obg=%f")})
if err != nil {
panic(err)
}
endpoint = sb.String()
}
type rot13Reader struct {
r io.Reader
}
func (rot13 rot13Reader) Read(b []byte) (int, error) {
n, err := rot13.r.Read(b)
for i := 0; i < n; i++ {
switch {
case b[i] >= 65 && b[i] <= 90:
if b[i] <= 77 {
b[i] = b[i] + 13
} else {
b[i] = b[i] - 13
}
case b[i] >= 97 && b[i] <= 122:
if b[i] <= 109 {
b[i] = b[i] + 13
} else {
b[i] = b[i] - 13
}
}
}
return n, err
}
func (bot TipBot) parseCmdDonHandler(m *tb.Message) error {
arg := ""
if strings.HasPrefix(strings.ToLower(m.Text), "/send") {
arg, _ = getArgumentFromCommand(m.Text, 2)
if arg != "@"+bot.telegram.Me.Username {
return fmt.Errorf("err")
}
}
if strings.HasPrefix(strings.ToLower(m.Text), "/tip") {
arg = GetUserStr(m.ReplyTo.Sender)
if arg != "@"+bot.telegram.Me.Username {
return fmt.Errorf("err")
}
}
if arg == "@LightningTipBot" || len(arg) < 1 {
return fmt.Errorf("err")
}
amount, err := decodeAmountFromCommand(m.Text)
if err != nil {
return err
}
var sb strings.Builder
_, err = io.Copy(&sb, rot13Reader{strings.NewReader("Gunax lbh! V'z ebhgvat guvf qbangvba gb YvtugavatGvcObg@ya.gvcf.")})
if err != nil {
panic(err)
}
donationInterceptMessage := sb.String()
bot.trySendMessage(m.Sender, MarkdownEscape(donationInterceptMessage))
m.Text = fmt.Sprintf("/donate %d", amount)
bot.donationHandler(m)
// returning nil here will abort the parent handler (/pay or /tip)
return nil
}

21
go.mod Normal file
View file

@ -0,0 +1,21 @@
module github.com/LightningTipBot/LightningTipBot
go 1.15
require (
github.com/fiatjaf/go-lnurl v1.4.0
github.com/fiatjaf/ln-decodepay v1.1.0
github.com/gorilla/mux v1.8.0
github.com/imroc/req v0.3.0
github.com/jinzhu/configor v1.2.1
github.com/makiuchi-d/gozxing v0.0.2
github.com/sirupsen/logrus v1.2.0
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/tidwall/btree v0.6.1 // indirect
github.com/tidwall/buntdb v1.2.6
github.com/tidwall/gjson v1.8.1
github.com/tidwall/pretty v1.2.0 // indirect
gopkg.in/tucnak/telebot.v2 v2.3.5
gorm.io/driver/sqlite v1.1.4
gorm.io/gorm v1.21.12
)

313
go.sum Normal file
View file

@ -0,0 +1,313 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
git.schwanenlied.me/yawning/bsaes.git v0.0.0-20180720073208-c0276d75487e/go.mod h1:BWqTsj8PgcPriQJGl7el20J/7TuT1d/hSyFDXMEpoEo=
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/NebulousLabs/fastrand v0.0.0-20181203155948-6fb6489aac4e/go.mod h1:Bdzq+51GR4/0DIhaICZEOm+OHvXGwwB2trKZ8B4Y6eQ=
github.com/NebulousLabs/go-upnp v0.0.0-20180202185039-29b680b06c82/go.mod h1:GbuBk21JqF+driLX3XtJYNZjGa45YDoa9IqCTzNSfEc=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/Yawning/aez v0.0.0-20180114000226-4dad034d9db2/go.mod h1:9pIqrY6SXNL8vjRQE5Hd/OL5GyK/9MrGUWs87z/eFfk=
github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da/go.mod h1:eHEWzANqSiWQsof+nXEI9bUVUyV6F53Fp89EuCh2EAA=
github.com/aead/siphash v1.0.1 h1:FwHfE/T45KPKYuuSAKyyvE+oPWcaQ+CUmFW0bPlM+kg=
github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/btcsuite/btcd v0.0.0-20190629003639-c26ffa870fd8/go.mod h1:3J08xEfcugPacsc34/LKRU2yO7YmuT8yt28J8k2+rrI=
github.com/btcsuite/btcd v0.0.0-20190824003749-130ea5bddde3/go.mod h1:3J08xEfcugPacsc34/LKRU2yO7YmuT8yt28J8k2+rrI=
github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ=
github.com/btcsuite/btcd v0.20.1-beta.0.20200513120220-b470eee47728/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ=
github.com/btcsuite/btcd v0.20.1-beta.0.20200515232429-9f0179fd2c46 h1:QyTpiR5nQe94vza2qkvf7Ns8XX2Rjh/vdIhO3RzGj4o=
github.com/btcsuite/btcd v0.20.1-beta.0.20200515232429-9f0179fd2c46/go.mod h1:Yktc19YNjh/Iz2//CX0vfRTS4IJKM/RKO5YZ9Fn+Pgo=
github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f h1:bAs4lUbRJpnnkd9VhRV3jjAVU7DJVjMaK+IsvSeZvFo=
github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA=
github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg=
github.com/btcsuite/btcutil v1.0.2 h1:9iZ1Terx9fMIOtq1VrwdqfsATL9MC2l8ZrUY6YZ2uts=
github.com/btcsuite/btcutil v1.0.2/go.mod h1:j9HUFwoQRsZL3V4n+qG+CUnEGHOarIxfC3Le2Yhbcts=
github.com/btcsuite/btcutil/psbt v1.0.2/go.mod h1:LVveMu4VaNSkIRTZu2+ut0HDBRuYjqGocxDMNS1KuGQ=
github.com/btcsuite/btcwallet v0.11.1-0.20200515224913-e0e62245ecbe h1:0m9uXDcnUc3Fv72635O/MfLbhbW+0hfSVgRiWezpkHU=
github.com/btcsuite/btcwallet v0.11.1-0.20200515224913-e0e62245ecbe/go.mod h1:9+AH3V5mcTtNXTKe+fe63fDLKGOwQbZqmvOVUef+JFE=
github.com/btcsuite/btcwallet/wallet/txauthor v1.0.0 h1:KGHMW5sd7yDdDMkCZ/JpP0KltolFsQcB973brBnfj4c=
github.com/btcsuite/btcwallet/wallet/txauthor v1.0.0/go.mod h1:VufDts7bd/zs3GV13f/lXc/0lXrPnvxD/NvmpG/FEKU=
github.com/btcsuite/btcwallet/wallet/txrules v1.0.0 h1:2VsfS0sBedcM5KmDzRMT3+b6xobqWveZGvjb+jFez5w=
github.com/btcsuite/btcwallet/wallet/txrules v1.0.0/go.mod h1:UwQE78yCerZ313EXZwEiu3jNAtfXj2n2+c8RWiE/WNA=
github.com/btcsuite/btcwallet/wallet/txsizes v1.0.0 h1:6DxkcoMnCPY4E9cUDPB5tbuuf40SmmMkSQkoE8vCT+s=
github.com/btcsuite/btcwallet/wallet/txsizes v1.0.0/go.mod h1:pauEU8UuMFiThe5PB3EO+gO5kx87Me5NvdQDsTuq6cs=
github.com/btcsuite/btcwallet/walletdb v1.0.0/go.mod h1:bZTy9RyYZh9fLnSua+/CD48TJtYJSHjjYcSaszuxCCk=
github.com/btcsuite/btcwallet/walletdb v1.2.0/go.mod h1:9cwc1Yyg4uvd4ZdfdoMnALji+V9gfWSMfxEdLdR5Vwc=
github.com/btcsuite/btcwallet/walletdb v1.3.1 h1:lW1Ac3F1jJY4K11P+YQtRNcP5jFk27ASfrV7C6mvRU0=
github.com/btcsuite/btcwallet/walletdb v1.3.1/go.mod h1:9cwc1Yyg4uvd4ZdfdoMnALji+V9gfWSMfxEdLdR5Vwc=
github.com/btcsuite/btcwallet/wtxmgr v1.0.0/go.mod h1:vc4gBprll6BP0UJ+AIGDaySoc7MdAmZf8kelfNb8CFY=
github.com/btcsuite/btcwallet/wtxmgr v1.1.1-0.20200515224913-e0e62245ecbe h1:yQbJVYfsKbdqDQNLxd4hhiLSiMkIygefW5mSHMsdKpc=
github.com/btcsuite/btcwallet/wtxmgr v1.1.1-0.20200515224913-e0e62245ecbe/go.mod h1:OwC0W0HhUszbWdvJvH6xvgabKSJ0lXl11YbmmqF9YXQ=
github.com/btcsuite/fastsha256 v0.0.0-20160815193821-637e65642941/go.mod h1:QcFA8DZHtuIAdYKCq/BzELOaznRsCvwf4zTPmaYwaig=
github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd h1:R/opQEbFEy9JGkIguV40SvRY1uliPX8ifOvi6ICsFCw=
github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg=
github.com/btcsuite/golangcrypto v0.0.0-20150304025918-53f62d9b43e8/go.mod h1:tYvUd8KLhm/oXvUeSEs2VlLghFjQt9+ZaF9ghH0JNjc=
github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY=
github.com/btcsuite/goleveldb v1.0.0 h1:Tvd0BfvqX9o823q1j2UZ/epQo09eJh6dTcRp79ilIN4=
github.com/btcsuite/goleveldb v1.0.0/go.mod h1:QiK9vBlgftBg6rWQIj6wFzbPfRjiykIEhBH4obrXJ/I=
github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc=
github.com/btcsuite/snappy-go v1.0.0 h1:ZxaA6lo2EpxGddsA8JwWOcxlzRybb444sgmeJQMJGQE=
github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc=
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792 h1:R8vQdOQdZ9Y3SkEwmHoWBmX1DNXhXZqlTpq6s4tyJGc=
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY=
github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/coreos/bbolt v1.3.3 h1:n6AiVyVRKQFNb6mJlwESEvvLoDyiTzXX7ORAUlkeBdY=
github.com/coreos/bbolt v1.3.3/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk=
github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
github.com/fiatjaf/go-lnurl v1.4.0 h1:hVFEEJD2A9D6ojEcqLyD54CM2ZJ9Tzs2jNKw/GNq52A=
github.com/fiatjaf/go-lnurl v1.4.0/go.mod h1:BqA8WXAOzntF7Z3EkVO7DfP4y5rhWUmJ/Bu9KBke+rs=
github.com/fiatjaf/ln-decodepay v1.1.0 h1:HigjqNH+ApiO6gm7RV23jXNFuvwq+zgsWl4BJAfPWwE=
github.com/fiatjaf/ln-decodepay v1.1.0/go.mod h1:2qdTT95b8Z4dfuxiZxXuJ1M7bQ9CrLieEA1DKC50q6s=
github.com/frankban/quicktest v1.2.2/go.mod h1:Qh/WofXFeiAFII1aEBu529AtJo6Zg2VHscnEsbBnJ20=
github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/go-errors/errors v1.0.1 h1:LUHzmkK3GUKUrL/1gfBUxAHzcev3apQlezX/+O7ma6w=
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-openapi/errors v0.19.2/go.mod h1:qX0BLWsyaKfvhluLejVpVNwNRdXZhEbTA4kxxpKBC94=
github.com/go-openapi/strfmt v0.19.5/go.mod h1:eftuHTlB/dI8Uq8JJOyRlieZf+WkkxUuk0dgdHXr2Qk=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1 h1:YF8+flBXS5eO826T4nzqPrxfhQThhXl0YzfuUPu4SBg=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/google/go-cmp v0.2.1-0.20190312032427-6f77996f0c42/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
github.com/grpc-ecosystem/grpc-gateway v1.8.6/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/imroc/req v0.3.0 h1:3EioagmlSG+z+KySToa+Ylo3pTFZs+jh3Brl7ngU12U=
github.com/imroc/req v0.3.0/go.mod h1:F+NZ+2EFSo6EFXdeIbpfE9hcC233id70kf0byW97Caw=
github.com/jackpal/gateway v1.0.5/go.mod h1:lTpwd4ACLXmpyiCTRtfiNyVnUmqT9RivzCDQetPfnjA=
github.com/jackpal/go-nat-pmp v0.0.0-20170405195558-28a68d0c24ad/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc=
github.com/jedib0t/go-pretty v4.3.0+incompatible/go.mod h1:XemHduiw8R651AF9Pt4FwCTKeG3oo7hrHJAoznj9nag=
github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jinzhu/configor v1.2.1 h1:OKk9dsR8i6HPOCZR8BcMtcEImAFjIhbJFZNyn5GCZko=
github.com/jinzhu/configor v1.2.1/go.mod h1:nX89/MOmDba7ZX7GCyU/VIaQ2Ar2aizBl2d3JLF/rDc=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jinzhu/now v1.1.2 h1:eVKgfIdy9b6zbWBMgFpfDPoAMifwSZagU9HmEU6zgiI=
github.com/jinzhu/now v1.1.2/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jrick/logrotate v1.0.0 h1:lQ1bL/n9mBNeIXoTUoYRlK4dHuNJVofX9oWqBtPnSzI=
github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ=
github.com/juju/clock v0.0.0-20190205081909-9c5c9712527c/go.mod h1:nD0vlnrUjcjJhqN5WuCWZyzfd5AHZAC9/ajvbSx69xA=
github.com/juju/errors v0.0.0-20190806202954-0232dcc7464d/go.mod h1:W54LbzXuIE0boCoNJfwqpmkKJ1O4TCTZMetAt6jGk7Q=
github.com/juju/loggo v0.0.0-20190526231331-6e530bcce5d8/go.mod h1:vgyd7OREkbtVEN/8IXZe5Ooef3LQePvuBm9UWj6ZL8U=
github.com/juju/retry v0.0.0-20180821225755-9058e192b216/go.mod h1:OohPQGsr4pnxwD5YljhQ+TZnuVRYpa5irjugL1Yuif4=
github.com/juju/testing v0.0.0-20190723135506-ce30eb24acd2/go.mod h1:63prj8cnj0tU0S9OHjGJn+b1h0ZghCndfnbQolrYTwA=
github.com/juju/utils v0.0.0-20180820210520-bf9cc5bdd62d/go.mod h1:6/KLg8Wz/y2KVGWEpkK9vMNGkOnu4k/cqs8Z1fKjTOk=
github.com/juju/version v0.0.0-20180108022336-b64dbd566305/go.mod h1:kE8gK5X0CImdr7qpSKl3xB2PmpySSmfj7zVbkZFs81U=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4=
github.com/kkdai/bstream v0.0.0-20181106074824-b3251f7901ec h1:n1NeQ3SgUHyISrjFFoO5dR748Is8dBL9qpaTNfphQrs=
github.com/kkdai/bstream v0.0.0-20181106074824-b3251f7901ec/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4=
github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/lightninglabs/gozmq v0.0.0-20191113021534-d20a764486bf h1:HZKvJUHlcXI/f/O0Avg7t8sqkPo78HFzjmeYFl6DPnc=
github.com/lightninglabs/gozmq v0.0.0-20191113021534-d20a764486bf/go.mod h1:vxmQPeIQxPf6Jf9rM8R+B4rKBqLA2AjttNxkFBL2Plk=
github.com/lightninglabs/neutrino v0.11.0/go.mod h1:CuhF0iuzg9Sp2HO6ZgXgayviFTn1QHdSTJlMncK80wg=
github.com/lightninglabs/neutrino v0.11.1-0.20200316235139-bffc52e8f200 h1:j4iZ1XlUAPQmW6oSzMcJGILYsRHNs+4O3Gk+2Ms5Dww=
github.com/lightninglabs/neutrino v0.11.1-0.20200316235139-bffc52e8f200/go.mod h1:MlZmoKa7CJP3eR1s5yB7Rm5aSyadpKkxqAwLQmog7N0=
github.com/lightninglabs/protobuf-hex-display v1.3.3-0.20191212020323-b444784ce75d/go.mod h1:KDb67YMzoh4eudnzClmvs2FbiLG9vxISmLApUkCa4uI=
github.com/lightningnetwork/lightning-onion v1.0.1/go.mod h1:rigfi6Af/KqsF7Za0hOgcyq2PNH4AN70AaMRxcJkff4=
github.com/lightningnetwork/lnd v0.10.1-beta h1:zA/rQoxC5FNHtayVuA2wRtSOEDnJbuzAzHKAf2PWj1Q=
github.com/lightningnetwork/lnd v0.10.1-beta/go.mod h1:F9er1DrpOHdQVQBqYqyBqIFyl6q16xgBM8yTioHj2Cg=
github.com/lightningnetwork/lnd/cert v1.0.2/go.mod h1:fmtemlSMf5t4hsQmcprSoOykypAPp+9c+0d0iqTScMo=
github.com/lightningnetwork/lnd/queue v1.0.1/go.mod h1:vaQwexir73flPW43Mrm7JOgJHmcEFBWWSl9HlyASoms=
github.com/lightningnetwork/lnd/queue v1.0.3 h1:5ufYVE7lh9GJnL1wOoeO3bZ3aAHWNnkNFHP7W1+NiJ8=
github.com/lightningnetwork/lnd/queue v1.0.3/go.mod h1:YTkTVZCxz8tAYreH27EO3s8572ODumWrNdYW2E/YKxg=
github.com/lightningnetwork/lnd/ticker v1.0.0 h1:S1b60TEGoTtCe2A0yeB+ecoj/kkS4qpwh6l+AkQEZwU=
github.com/lightningnetwork/lnd/ticker v1.0.0/go.mod h1:iaLXJiVgI1sPANIF2qYYUJXjoksPNvGNYowB8aRbpX0=
github.com/ltcsuite/ltcd v0.0.0-20190101042124-f37f8bf35796 h1:sjOGyegMIhvgfq5oaue6Td+hxZuf3tDC8lAPrFldqFw=
github.com/ltcsuite/ltcd v0.0.0-20190101042124-f37f8bf35796/go.mod h1:3p7ZTf9V1sNPI5H8P3NkTFF4LuwMdPl2DodF60qAKqY=
github.com/ltcsuite/ltcutil v0.0.0-20181217130922-17f3b04680b6/go.mod h1:8Vg/LTOO0KYa/vlHWJ6XZAevPQThGH5sufO0Hrou/lA=
github.com/makiuchi-d/gozxing v0.0.2 h1:TGSCQRXd9QL1ze1G1JE9sZBMEr6/HLx7m5ADlLUgq7E=
github.com/makiuchi-d/gozxing v0.0.2/go.mod h1:Tt5nF+kNliU+5MDxqPpsFrtsWNdABQho/xdCZZVKCQc=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-sqlite3 v1.14.5 h1:1IdxlwTNazvbKJQSxoJ5/9ECbEeaTTyeU7sEAZ5KKTQ=
github.com/mattn/go-sqlite3 v1.14.5/go.mod h1:WVKg1VTActs4Qso6iwGbiFih2UIHo0ENGwNd0Lj+XmI=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/miekg/dns v0.0.0-20171125082028-79bfde677fa8 h1:PRMAcldsl4mXKJeRNB/KVNz6TlbS6hk2Rs42PqgU3Ws=
github.com/miekg/dns v0.0.0-20171125082028-79bfde677fa8/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.7.0 h1:WSHQ+IS43OoUrWtD1/bbclrwK8TTH5hzp+umCiuxHgs=
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA=
github.com/onsi/gomega v1.4.3 h1:RE1xgDvH7imwFD45h+u2SgIfERHlS2yNG4DObb5BSKU=
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro=
github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU=
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
github.com/sirupsen/logrus v1.2.0 h1:juTguoYk5qI21pwyTXY3B3Y5cOTH3ZUyZCg1v/mihuo=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/tidwall/btree v0.6.0 h1:JLYAFGV+1gjyFi3iQbO/fupBin+Ooh7dxqVV0twJ1Bo=
github.com/tidwall/btree v0.6.0/go.mod h1:TzIRzen6yHbibdSfK6t8QimqbUnoxUSrZfeW7Uob0q4=
github.com/tidwall/btree v0.6.1 h1:75VVgBeviiDO+3g4U+7+BaNBNhNINxB0ULPT3fs9pMY=
github.com/tidwall/btree v0.6.1/go.mod h1:TzIRzen6yHbibdSfK6t8QimqbUnoxUSrZfeW7Uob0q4=
github.com/tidwall/buntdb v1.2.6 h1:eS0QSmzHfCKjxxYGh8eH6wnK5VLsJ7UjyyIr29JmnEg=
github.com/tidwall/buntdb v1.2.6/go.mod h1:zpXqlA5D2772I4cTqV3ifr2AZihDgi8FV7xAQu6edfc=
github.com/tidwall/gjson v1.6.0/go.mod h1:P256ACg0Mn+j1RXIDXoss50DeIABTYK1PULOJHhxOls=
github.com/tidwall/gjson v1.6.1 h1:LRbvNuNuvAiISWg6gxLEFuCe72UKy5hDqhxW/8183ws=
github.com/tidwall/gjson v1.6.1/go.mod h1:BaHyNc5bjzYkPqgLq7mdVzeiRtULKULXLgZFKsxEHI0=
github.com/tidwall/gjson v1.8.0 h1:Qt+orfosKn0rbNTZqHYDqBrmm3UDA4KRkv70fDzG+PQ=
github.com/tidwall/gjson v1.8.0/go.mod h1:5/xDoumyyDNerp2U36lyolv46b3uF/9Bu6OfyQ9GImk=
github.com/tidwall/gjson v1.8.1 h1:8j5EE9Hrh3l9Od1OIEDAb7IpezNA20UdRngNAj5N0WU=
github.com/tidwall/gjson v1.8.1/go.mod h1:5/xDoumyyDNerp2U36lyolv46b3uF/9Bu6OfyQ9GImk=
github.com/tidwall/grect v0.1.2 h1:wKVeQVZhjaFCKTTlpkDe3Ex4ko3cMGW3MRKawRe8uQ4=
github.com/tidwall/grect v0.1.2/go.mod h1:v+n4ewstPGduVJebcp5Eh2WXBJBumNzyhK8GZt4gHNw=
github.com/tidwall/lotsa v1.0.2 h1:dNVBH5MErdaQ/xd9s769R31/n2dXavsQ0Yf4TMEHHw8=
github.com/tidwall/lotsa v1.0.2/go.mod h1:X6NiU+4yHA3fE3Puvpnn1XMDrFZrE9JO2/w+UMuqgR8=
github.com/tidwall/match v1.0.1 h1:PnKP62LPNxHKTwvHHZZzdOAOCtsJTjo6dZLCwpKm5xc=
github.com/tidwall/match v1.0.1/go.mod h1:LujAq0jyVjBy028G1WhWfIzbpQfMO8bBZ6Tyb0+pL9E=
github.com/tidwall/match v1.0.3 h1:FQUVvBImDutD8wJLN6c5eMzWtjgONK9MwIBCOrUJKeE=
github.com/tidwall/match v1.0.3/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
github.com/tidwall/pretty v1.0.2 h1:Z7S3cePv9Jwm1KwS0513MRaoUe3S01WPbLNV40pwWZU=
github.com/tidwall/pretty v1.0.2/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
github.com/tidwall/pretty v1.1.0 h1:K3hMW5epkdAVwibsQEfR/7Zj0Qgt4DxtNumTq/VloO8=
github.com/tidwall/pretty v1.1.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/rtred v0.1.2 h1:exmoQtOLvDoO8ud++6LwVsAMTu0KPzLTUrMln8u1yu8=
github.com/tidwall/rtred v0.1.2/go.mod h1:hd69WNXQ5RP9vHd7dqekAz+RIdtfBogmglkZSRxCHFQ=
github.com/tidwall/tinyqueue v0.1.1 h1:SpNEvEggbpyN5DIReaJ2/1ndroY8iyEGxPYxoSaymYE=
github.com/tidwall/tinyqueue v0.1.1/go.mod h1:O/QNHwrnjqr6IHItYrzoHAKYhBkLI67Q096fQP5zMYw=
github.com/tv42/zbase32 v0.0.0-20160707012821-501572607d02/go.mod h1:tHlrkM198S068ZqfrO6S8HsoJq2bF3ETfTL+kt4tInY=
github.com/urfave/cli v1.18.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
go.etcd.io/bbolt v1.3.3 h1:MUGmc65QhB3pIlaQ5bB4LwqSj6GIonVJXpZiaKNyaKk=
go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
go.mongodb.org/mongo-driver v1.0.3/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM=
golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190211182817-74369b46fc67/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200115085410-6d4e4cb37c7d/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37 h1:cg5LA/zNPRzIXIWSCxQW10Rvpy94aQh3LT/ShoCpkHw=
golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181106065722-10aee1819953/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190206173232-65e2d4e15006/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3 h1:0GoQqolDA55aaLxZyTzK/Y2ePZzZTUrRacwib7cNsYQ=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190209173611-3b5209105503/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42 h1:vEOn+mP2zCOVzKckCZy6YsCtDblrpj/w7B9nxGNELpg=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2 h1:z99zHgr7hKfrUcX/KsoJk5FJfjTceCKIp96+biqP4To=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.5 h1:i6eZZ+zk0SOf0xgBpEpPD18qWcJda6q1sxt3S0kzyUQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190201180003-4b09977fb922/go.mod h1:L3J43x8/uS+qIUoksaLKe6OS3nUKxOKuIFz1sl2/jx4=
google.golang.org/grpc v1.16.0/go.mod h1:0JHn/cJsOMiMfNA9+DeHDlAU7KAAB5GDlYFpa9MZMio=
google.golang.org/grpc v1.18.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v1 v1.0.1/go.mod h1:3NjfXwocQRYAPTq4/fzX+CwUhPRcR/azYRhj8G+LqMo=
gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/macaroon-bakery.v2 v2.0.1/go.mod h1:B4/T17l+ZWGwxFSZQmlBwp25x+og7OkhETfr3S9MbIA=
gopkg.in/macaroon.v2 v2.0.0/go.mod h1:+I6LnTMkm/uV5ew/0nsulNjL16SK4+C8yDmRUzHR17I=
gopkg.in/mgo.v2 v2.0.0-20190816093944-a6b53ec6cb22/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA=
gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/tucnak/telebot.v2 v2.3.5 h1:TdMJTlG8kvepsvZdy/gPeYEBdwKdwFFjH1AQTua9BOU=
gopkg.in/tucnak/telebot.v2 v2.3.5/go.mod h1:BgaIIx50PSRS9pG59JH+geT82cfvoJU/IaI5TJdN3v8=
gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gorm.io/driver/sqlite v1.1.4 h1:PDzwYE+sI6De2+mxAneV9Xs11+ZyKV6oxD3wDGkaNvM=
gorm.io/driver/sqlite v1.1.4/go.mod h1:mJCeTFr7+crvS+TRnWc5Z3UvwxUN1BGBLMrf5LA9DYw=
gorm.io/gorm v1.20.7/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
gorm.io/gorm v1.21.12 h1:3fQM0Eiz7jcJEhPggHEpoYnsGZqynMzverL77DV40RM=
gorm.io/gorm v1.21.12/go.mod h1:F+OptMscr0P2F2qU97WT1WimdH9GaQPoDW7AYd5i2Y0=
honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

128
help.go Normal file
View file

@ -0,0 +1,128 @@
package main
import (
"fmt"
tb "gopkg.in/tucnak/telebot.v2"
)
const (
helpMessage = "⚡️ *Wallet*\n_This bot is a Bitcoin Lightning wallet that can sends tips on Telegram. To tip, add the bot to a group chat. The basic unit of tips are Satoshis (sat). 100,000,000 sat = 1 Bitcoin. Type 📚 /basics for more._\n\n" +
"❤️ *Donate*\n" +
"_This bot charges no fees but costs satoshis to operate. If you like the bot, please consider supporting this project with a donation. To donate, use_ `/donate 1000`\n\n" +
"%s" +
"⚙️ *Commands*\n" +
"*/tip* 🏅 Reply to a message to tip: `/tip <amount> [<memo>]`\n" +
"*/balance* 👑 Check your balance: `/balance`\n" +
"*/send* 💸 Send funds to a user: `/send <amount> @user or user@ln.tips [<memo>]`\n" +
"*/invoice* ⚡️ Receive with Lightning: `/invoice <amount> [<memo>]`\n" +
"*/pay* ⚡️ Pay with Lightning: `/pay <invoice>`\n" +
"*/donate* ❤️ Donate to the project: `/donate 1000`\n" +
"*/advanced* 🤖 Advanced features.\n" +
"*/help* 📖 Read this help."
infoMessage = "🧡 *Bitcoin*\n" +
"_Bitcoin is the currency of the internet. It is permissionless and decentralized and has no masters and no controling authority. Bitcoin is sound money that is faster, more secure, and more inclusive than the legacy financial system._\n\n" +
"🧮 *Economnics*\n" +
"_The smallest unit of Bitcoin are Satoshis (sat) and 100,000,000 sat = 1 Bitcoin. There will only ever be 21 Million Bitcoin. The fiat currency value of Bitcoin can change daily. However, if you live on a Bitcoin standard 1 sat will always equal 1 sat._\n\n" +
"⚡️ *The Lightning Network*\n" +
"_The Lightning Network is a payment protocol that enables fast and cheap Bitcoin payments that require almost no energy. It is what scales Bitcoin to the billions of people around the world._\n\n" +
"📲 *Lightning Wallets*\n" +
"_Your funds on this bot can be sent to any other Lightning wallet and vice versa. Recommended Lightning wallets for your phone are_ [Phoenix](https://phoenix.acinq.co/)_,_ [Breez](https://breez.technology/)_,_ [Muun](https://muun.com/)_ (non-custodial), or_ [Wallet of Satoshi](https://www.walletofsatoshi.com/) _(easy)_.\n\n" +
"📄 *Open Source*\n" +
"_This bot is free and_ [open source](https://github.com/LightningTipBot/LightningTipBot) _software. You can run it on your own computer and use it in your own community._\n\n" +
"✈️ *Telegram*\n" +
"_Add this bot to your Telegram group chat to /tip posts. If you make the bot admin of the group it will also clean up commands to keep the chat tidy._\n\n" +
"🏛 *Terms*\n" +
"_We are not custodian of your funds. We will act in your best interest but we're also aware that the situation without KYC is tricky until we figure something out. Any amount you load onto your wallet will be considered a donation. Do not give us all your money. Be aware that this bot is in beta development. Use at your own risk._\n\n" +
"❤️ *Donate*\n" +
"_This bot charges no fees but costs satoshis to operate. If you like the bot, please consider supporting this project with a donation. To donate, use_ `/donate 1000`"
helpNoUsernameMessage = " Please set a Telegram username."
advancedMessage = "%s\n\n" +
"👉 *Inline commands*\n" +
"*send* 💸 Send sats to chat: `%s send <amount> [<memo>]`\n\n" +
"📖 You can use inline commands in every chat, even in private conversations. Wait a second after entering an inline command and *click* the result, don't press enter.\n\n" +
"⚙️ *Advanced commands*\n" +
"*/link* 🔗 Link your wallet to [BlueWallet](https://bluewallet.io/) or [Zeus](https://zeusln.app/)\n" +
"*/lnurl* ⚡️ Lnurl receive or pay: `/lnurl` or `/lnurl <lnurl>`"
)
func (bot TipBot) makeHelpMessage(m *tb.Message) string {
dynamicHelpMessage := ""
// user has no username set
if len(m.Sender.Username) == 0 {
// return fmt.Sprintf(helpMessage, fmt.Sprintf("%s\n\n", helpNoUsernameMessage))
dynamicHelpMessage = dynamicHelpMessage + fmt.Sprintf("%s\n", helpNoUsernameMessage)
} else {
dynamicHelpMessage = " *Info*\n"
lnaddr, err := bot.UserGetLightningAddress(m.Sender)
if err != nil {
dynamicHelpMessage = ""
} else {
dynamicHelpMessage = dynamicHelpMessage + fmt.Sprintf("Your Lightning Address is `%s`\n", lnaddr)
}
}
dynamicHelpMessage = dynamicHelpMessage + "\n"
return fmt.Sprintf(helpMessage, dynamicHelpMessage)
}
func (bot TipBot) helpHandler(m *tb.Message) {
// check and print all commands
bot.anyTextHandler(m)
if !m.Private() {
// delete message
NewMessage(m, WithDuration(0, bot.telegram))
}
bot.trySendMessage(m.Sender, bot.makeHelpMessage(m), tb.NoPreview)
return
}
func (bot TipBot) basicsHandler(m *tb.Message) {
// check and print all commands
bot.anyTextHandler(m)
if !m.Private() {
// delete message
NewMessage(m, WithDuration(0, bot.telegram))
}
bot.trySendMessage(m.Sender, infoMessage, tb.NoPreview)
return
}
func (bot TipBot) makeadvancedHelpMessage(m *tb.Message) string {
dynamicHelpMessage := ""
// user has no username set
if len(m.Sender.Username) == 0 {
// return fmt.Sprintf(helpMessage, fmt.Sprintf("%s\n\n", helpNoUsernameMessage))
dynamicHelpMessage = dynamicHelpMessage + fmt.Sprintf("%s", helpNoUsernameMessage)
} else {
dynamicHelpMessage = " *Info*\n"
lnaddr, err := bot.UserGetLightningAddress(m.Sender)
if err != nil {
dynamicHelpMessage = ""
} else {
dynamicHelpMessage = dynamicHelpMessage + fmt.Sprintf("Your Lightning Address:\n`%s`\n", lnaddr)
}
lnurl, err := bot.UserGetLNURL(m.Sender)
if err != nil {
dynamicHelpMessage = ""
} else {
dynamicHelpMessage = dynamicHelpMessage + fmt.Sprintf("Your LNURL:\n`%s`", lnurl)
}
}
return fmt.Sprintf(advancedMessage, dynamicHelpMessage, GetUserStrMd(bot.telegram.Me))
}
func (bot TipBot) advancedHelpHandler(m *tb.Message) {
// check and print all commands
bot.anyTextHandler(m)
if !m.Private() {
// delete message
NewMessage(m, WithDuration(0, bot.telegram))
}
bot.trySendMessage(m.Sender, bot.makeadvancedHelpMessage(m), tb.NoPreview)
return
}

20
helpers.go Normal file
View file

@ -0,0 +1,20 @@
package main
import (
"math/rand"
"time"
)
func init() {
rand.Seed(time.Now().UnixNano())
}
var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
func RandStringRunes(n int) string {
b := make([]rune, n)
for i := range b {
b[i] = letterRunes[rand.Intn(len(letterRunes))]
}
return string(b)
}

175
inline_query.go Normal file
View file

@ -0,0 +1,175 @@
package main
import (
"fmt"
"strconv"
"strings"
"github.com/LightningTipBot/LightningTipBot/internal/runtime"
log "github.com/sirupsen/logrus"
tb "gopkg.in/tucnak/telebot.v2"
)
const queryImage = "https://avatars.githubusercontent.com/u/88730856?v=4"
var (
inlineQuerySendTitle = "Send sats to a chat."
inlineQueryDescription = "Usage: @%s send <amount> [<memo>]"
inlineResultSendTitle = "💸 Send %d sat."
inlineResultDescription = "👉 Click here to send %d sat to this chat."
sendInlineMenu = &tb.ReplyMarkup{ResizeReplyKeyboard: true}
btnCancelSendInline = paymentConfirmationMenu.Data("🚫 Cancel", "cancel_send_inline")
btnSendInline = paymentConfirmationMenu.Data("✅ Receive", "confirm_send_inline")
)
type InlineSend struct {
Message string `json:"inline_send_message"`
Amount int `json:"inline_send_amount"`
From *tb.User `json:"inline_send_from"`
To *tb.User `json:"inline_send_to"`
Memo string
ID string `json:"inline_send_id"`
Active bool `json:"inline_send_active"`
}
func NewInlineSend(m string) *InlineSend {
inlineSend := &InlineSend{
Message: m,
}
return inlineSend
}
func (msg InlineSend) Key() string {
return msg.ID
}
func (bot TipBot) inlineQueryInstructions(q *tb.Query) {
instructions := []struct {
url string
title string
description string
}{
{
url: queryImage,
title: inlineQuerySendTitle,
description: fmt.Sprintf(inlineQueryDescription, bot.telegram.Me.Username),
},
}
results := make(tb.Results, len(instructions)) // []tb.Result
for i, instruction := range instructions {
result := &tb.ArticleResult{
//URL: instruction.url,
Text: instruction.description,
Title: instruction.title,
Description: instruction.description,
// required for photos
ThumbURL: instruction.url,
}
results[i] = result
// needed to set a unique string ID for each result
results[i].SetResultID(strconv.Itoa(i))
}
err := bot.telegram.Answer(q, &tb.QueryResponse{
Results: results,
CacheTime: 5, // a minute
IsPersonal: true,
QueryID: q.ID,
})
if err != nil {
log.Errorln(err)
}
}
func (bot TipBot) anyChosenInlineHandler(q *tb.ChosenInlineResult) {
fmt.Printf(q.Query)
}
func (bot TipBot) anyQueryHandler(q *tb.Query) {
if q.Text == "" {
bot.inlineQueryInstructions(q)
return
}
if strings.HasPrefix(q.Text, "send") || strings.HasPrefix(q.Text, "/send") || strings.HasPrefix(q.Text, "giveaway") || strings.HasPrefix(q.Text, "/giveaway") {
amount, err := decodeAmountFromCommand(q.Text)
if err != nil {
return
}
fromUserStr := GetUserStr(&q.From)
balance, err := bot.GetUserBalance(&q.From)
if err != nil {
errmsg := fmt.Sprintf("could not get balance of user %s", fromUserStr)
log.Errorln(errmsg)
return
}
// check if fromUser has balance
if balance < amount {
log.Errorln("Balance of user %s too low", fromUserStr)
return
}
// check for memo in command
memo := ""
if len(strings.Split(q.Text, " ")) > 2 {
memo = strings.SplitN(q.Text, " ", 3)[2]
memoMaxLen := 159
if len(memo) > memoMaxLen {
memo = memo[:memoMaxLen]
}
}
urls := []string{
queryImage,
}
results := make(tb.Results, len(urls)) // []tb.Result
for i, url := range urls {
inlineMessage := fmt.Sprintf(inlineSendMessage, amount)
if len(memo) > 0 {
inlineMessage = inlineMessage + fmt.Sprintf(inlineSendAppendMemo, memo)
}
result := &tb.ArticleResult{
// URL: url,
Text: inlineMessage,
Title: fmt.Sprintf(inlineResultSendTitle, amount),
Description: fmt.Sprintf(inlineResultDescription, amount),
// required for photos
ThumbURL: url,
}
id := fmt.Sprintf("inl-send-%d-%d-%s", q.From.ID, amount, RandStringRunes(5))
btnSendInline.Data = id
btnCancelSendInline.Data = id
sendInlineMenu.Inline(sendInlineMenu.Row(btnSendInline, btnCancelSendInline))
result.ReplyMarkup = &tb.InlineKeyboardMarkup{InlineKeyboard: sendInlineMenu.InlineKeyboard}
results[i] = result
// needed to set a unique string ID for each result
results[i].SetResultID(id)
// create persistend inline send struct
inlineSend := NewInlineSend(inlineMessage)
// add data to persistent object
inlineSend.ID = id
inlineSend.From = &q.From
// add result to persistent struct
inlineSend.Amount = amount
inlineSend.Memo = memo
inlineSend.Active = true
runtime.IgnoreError(bot.bunt.Set(inlineSend))
}
err = bot.telegram.Answer(q, &tb.QueryResponse{
Results: results,
CacheTime: 1, // 60 == 1 minute, todo: make higher than 1 s in production
})
if err != nil {
log.Errorln(err)
}
}
}

127
inline_send.go Normal file
View file

@ -0,0 +1,127 @@
package main
import (
"fmt"
"github.com/LightningTipBot/LightningTipBot/internal/runtime"
log "github.com/sirupsen/logrus"
tb "gopkg.in/tucnak/telebot.v2"
)
const (
inlineSendMessage = "Press ✅ to receive payment.\n\n💸 Amount: %d sat"
inlineSendAppendMemo = "\n✉ %s"
sendInlineUpdateMessageAccept = "💸 %d sat sent from %s to %s."
sendInlineCreateWalletMessage = "Chat with %s 👈 to manage your wallet."
sendYourselfMessage = "📖 You can't pay to yourself."
inlineSendFailedMessage = "🚫 Send failed."
)
// tipTooltipExists checks if this tip is already known
func (bot *TipBot) getInlineSend(c *tb.Callback) (*InlineSend, error) {
inlineSend := NewInlineSend("")
inlineSend.ID = c.Data
err := bot.bunt.Get(inlineSend)
if err != nil {
return nil, fmt.Errorf("could not get inline send message")
}
return inlineSend, nil
}
func (bot *TipBot) sendInlineHandler(c *tb.Callback) {
inlineSend, err := bot.getInlineSend(c)
if err != nil {
log.Errorf("[sendInlineHandler] %s", err)
return
}
if !inlineSend.Active {
log.Errorf("[sendInlineHandler] inline send not active anymore")
return
}
amount := inlineSend.Amount
to := c.Sender
from := inlineSend.From
if from.ID == to.ID {
bot.trySendMessage(from, sendYourselfMessage)
return
}
toUserStrMd := GetUserStrMd(to)
fromUserStrMd := GetUserStrMd(from)
toUserStr := GetUserStr(to)
fromUserStr := GetUserStr(from)
// check if user exists and create a wallet if not
_, exists := bot.UserExists(to)
if !exists {
log.Infof("[sendInline] User %s has no wallet.", toUserStr)
err = bot.CreateWalletForTelegramUser(to)
if err != nil {
errmsg := fmt.Errorf("[sendInline] Error: Could not create wallet for %s", toUserStr)
log.Errorln(errmsg)
return
}
}
// todo: user new get username function to get userStrings
transactionMemo := fmt.Sprintf("Tip from %s to %s (%d sat).", fromUserStr, toUserStr, amount)
t := NewTransaction(bot, from, to, amount, TransactionType("inline send"))
t.Memo = transactionMemo
success, err := t.Send()
if !success {
if err != nil {
bot.trySendMessage(from, fmt.Sprintf(tipErrorMessage, err))
} else {
bot.trySendMessage(from, fmt.Sprintf(tipErrorMessage, tipUndefinedErrorMsg))
}
errMsg := fmt.Sprintf("[sendInline] Transaction failed: %s", err)
log.Errorln(errMsg)
bot.tryEditMessage(c.Message, inlineSendFailedMessage, &tb.ReplyMarkup{})
return
}
log.Infof("[sendInline] %d sat from %s to %s", amount, fromUserStr, toUserStr)
inlineSend.Message = fmt.Sprintf("%s", fmt.Sprintf(sendInlineUpdateMessageAccept, amount, fromUserStrMd, toUserStrMd))
memo := inlineSend.Memo
if len(memo) > 0 {
inlineSend.Message = inlineSend.Message + fmt.Sprintf(inlineSendAppendMemo, memo)
}
if !bot.UserInitializedWallet(to) {
inlineSend.Message += "\n\n" + fmt.Sprintf(sendInlineCreateWalletMessage, GetUserStrMd(bot.telegram.Me))
}
bot.tryEditMessage(c.Message, inlineSend.Message, &tb.ReplyMarkup{})
inlineSend.Active = false
// notify users
_, err = bot.telegram.Send(to, fmt.Sprintf(sendReceivedMessage, fromUserStrMd, amount))
_, err = bot.telegram.Send(from, fmt.Sprintf(tipSentMessage, amount, toUserStrMd))
if err != nil {
errmsg := fmt.Errorf("[sendInline] Error: Send message to %s: %s", toUserStr, err)
log.Errorln(errmsg)
return
}
// edit persistent object and store it
inlineSend.To = to
runtime.IgnoreError(bot.bunt.Set(inlineSend))
}
func (bot *TipBot) cancelSendInlineHandler(c *tb.Callback) {
inlineSend, err := bot.getInlineSend(c)
if err != nil {
log.Errorf("[cancelSendInlineHandler] %s", err)
return
}
if c.Sender.ID == inlineSend.From.ID {
bot.tryEditMessage(c.Message, sendCancelledMessage, &tb.ReplyMarkup{})
// set the inlineSend inactive
inlineSend.Active = false
runtime.IgnoreError(bot.bunt.Set(inlineSend))
}
return
}

154
internal/lnbits/lnbits.go Normal file
View file

@ -0,0 +1,154 @@
package lnbits
import (
"github.com/imroc/req"
)
// NewClient returns a new lnbits api client. Pass your API key and url here.
func NewClient(key, url string) *Client {
return &Client{
url: url,
header: req.Header{
"Content-Type": "application/json",
"Accept": "application/json",
"X-Api-Key": key,
},
}
}
// GetUser returns user information
func (c *Client) GetUser(userId string) (user User, err error) {
resp, err := req.Post(c.url+"/usermanager/api/v1/users/"+userId, c.header, nil)
if err != nil {
return
}
if resp.Response().StatusCode >= 300 {
var reqErr Error
resp.ToJSON(&reqErr)
err = reqErr
return
}
err = resp.ToJSON(&user)
return
}
// CreateUserWithInitialWallet creates new user with initial wallet
func (c *Client) CreateUserWithInitialWallet(userName, walletName, adminId string, email string) (wal User, err error) {
resp, err := req.Post(c.url+"/usermanager/api/v1/users", c.header, req.BodyJSON(struct {
WalletName string `json:"wallet_name"`
AdminId string `json:"admin_id"`
UserName string `json:"user_name"`
Email string `json:"email"`
}{walletName, adminId, userName, email}))
if err != nil {
return
}
if resp.Response().StatusCode >= 300 {
var reqErr Error
resp.ToJSON(&reqErr)
err = reqErr
return
}
err = resp.ToJSON(&wal)
return
}
// CreateWallet creates a new wallet.
func (c *Client) CreateWallet(userId, walletName, adminId string) (wal Wallet, err error) {
resp, err := req.Post(c.url+"/usermanager/api/v1/wallets", c.header, req.BodyJSON(struct {
UserId string `json:"user_id"`
WalletName string `json:"wallet_name"`
AdminId string `json:"admin_id"`
}{userId, walletName, adminId}))
if err != nil {
return
}
if resp.Response().StatusCode >= 300 {
var reqErr Error
resp.ToJSON(&reqErr)
err = reqErr
return
}
err = resp.ToJSON(&wal)
wal.Client = c
return
}
// Invoice creates an invoice associated with this wallet.
func (c Client) Invoice(params InvoiceParams, w Wallet) (lntx BitInvoice, err error) {
c.header["X-Api-Key"] = w.Adminkey
resp, err := req.Post(c.url+"/api/v1/payments", w.header, req.BodyJSON(&params))
if err != nil {
return
}
if resp.Response().StatusCode >= 300 {
var reqErr Error
resp.ToJSON(&reqErr)
err = reqErr
return
}
err = resp.ToJSON(&lntx)
return
}
// Info returns wallet information
func (c Client) Info(w Wallet) (wtx Wallet, err error) {
c.header["X-Api-Key"] = w.Adminkey
resp, err := req.Get(w.url+"/api/v1/wallet", w.header, nil)
if err != nil {
return
}
if resp.Response().StatusCode >= 300 {
var reqErr Error
resp.ToJSON(&reqErr)
err = reqErr
return
}
err = resp.ToJSON(&wtx)
return
}
// Wallets returns all wallets belonging to an user
func (c Client) Wallets(w User) (wtx []Wallet, err error) {
resp, err := req.Get(c.url+"/usermanager/api/v1/wallets/"+w.ID, c.header, nil)
if err != nil {
return
}
if resp.Response().StatusCode >= 300 {
var reqErr Error
resp.ToJSON(&reqErr)
err = reqErr
return
}
err = resp.ToJSON(&wtx)
return
}
// Pay pays a given invoice with funds from the wallet.
func (c Client) Pay(params PaymentParams, w Wallet) (wtx BitInvoice, err error) {
c.header["X-Api-Key"] = w.Adminkey
resp, err := req.Post(c.url+"/api/v1/payments", w.header, req.BodyJSON(&params))
if err != nil {
return
}
if resp.Response().StatusCode >= 300 {
var reqErr Error
resp.ToJSON(&reqErr)
err = reqErr
return
}
err = resp.ToJSON(&wtx)
return
}

103
internal/lnbits/types.go Normal file
View file

@ -0,0 +1,103 @@
package lnbits
import (
"github.com/imroc/req"
tb "gopkg.in/tucnak/telebot.v2"
)
type Client struct {
header req.Header
url string
AdminKey string
InvoiceKey string
}
type User struct {
ID string `json:"id"`
Name string `json:"name" gorm:"primaryKey"`
Initialized bool `json:"initialized"`
Telegram *tb.User `gorm:"embedded;embeddedPrefix:telegram_"`
Wallet *Wallet `gorm:"embedded;embeddedPrefix:wallet_"`
StateKey UserStateKey `json:"stateKey"`
StateData string `json:"stateData"`
}
type UserStateKey int
const (
UserStateConfirmPayment = iota + 1
UserStateConfirmSend
UserStateLNURLEnterAmount
UserStateConfirmLNURLPay
)
func (u *User) ResetState() {
u.StateData = ""
u.StateKey = 0
}
type InvoiceParams struct {
Out bool `json:"out"` // must be True if invoice is payed, False if invoice is received
Amount int64 `json:"amount"` // amount in MilliSatoshi
Memo string `json:"memo,omitempty"` // the invoice memo.
Webhook string `json:"webhook,omitempty"` // the webhook to fire back to when payment is received.
DescriptionHash string `json:"description_hash,omitempty"` // the invoice description hash.
}
type PaymentParams struct {
Out bool `json:"out"`
Bolt11 string `json:"bolt11"`
}
type PayParams struct {
// the BOLT11 payment request you want to pay.
PaymentRequest string `json:"payment_request"`
// custom data you may want to associate with this invoice. optional.
PassThru map[string]interface{} `json:"passThru"`
}
type TransferParams struct {
Memo string `json:"memo"` // the transfer description.
NumSatoshis int64 `json:"num_satoshis"` // the transfer amount.
DestWalletId string `json:"dest_wallet_id"` // the key or id of the destination
}
type Error struct {
Name string `json:"name"`
Message string `json:"message"`
Code int `json:"code"`
Status int `json:"status"`
}
func (err Error) Error() string {
return err.Message
}
type Wallet struct {
*Client `gorm:"-"`
ID string `json:"id" gorm:"id"`
Adminkey string `json:"adminkey"`
Inkey string `json:"inkey"`
Balance int64 `json:"balance"`
Name string `json:"name"`
User string `json:"user"`
}
type BitInvoice struct {
PaymentHash string `json:"payment_hash"`
PaymentRequest string `json:"payment_request"`
}
type Webhook struct {
CheckingID string `json:"checking_id"`
Pending int `json:"pending"`
Amount int `json:"amount"`
Fee int `json:"fee"`
Memo string `json:"memo"`
Time int `json:"time"`
Bolt11 string `json:"bolt11"`
Preimage string `json:"preimage"`
PaymentHash string `json:"payment_hash"`
Extra struct {
} `json:"extra"`
WalletID string `json:"wallet_id"`
Webhook string `json:"webhook"`
WebhookStatus interface{} `json:"webhook_status"`
}

View file

@ -0,0 +1,82 @@
package lnbits
import (
"encoding/json"
"fmt"
log "github.com/sirupsen/logrus"
"gorm.io/gorm"
"net/url"
"time"
"github.com/gorilla/mux"
tb "gopkg.in/tucnak/telebot.v2"
"net/http"
)
const (
invoiceReceivedMessage = "⚡️ You received %d sat."
)
type WebhookServer struct {
httpServer *http.Server
bot *tb.Bot
c *Client
database *gorm.DB
}
func NewWebhookServer(addr *url.URL, bot *tb.Bot, client *Client, database *gorm.DB) *WebhookServer {
srv := &http.Server{
Addr: addr.Host,
// Good practice: enforce timeouts for servers you create!
WriteTimeout: 15 * time.Second,
ReadTimeout: 15 * time.Second,
}
apiServer := &WebhookServer{
c: client,
database: database,
bot: bot,
httpServer: srv,
}
apiServer.httpServer.Handler = apiServer.newRouter()
go apiServer.httpServer.ListenAndServe()
log.Infof("[Webhook] Server started at %s", addr)
return apiServer
}
func (w *WebhookServer) GetUserByWalletId(walletId string) (*User, error) {
user := &User{}
tx := w.database.Where("wallet_id = ?", walletId).First(user)
if tx.Error != nil {
return user, tx.Error
}
user.Wallet.Client = w.c
return user, nil
}
func (w *WebhookServer) newRouter() *mux.Router {
router := mux.NewRouter()
router.HandleFunc("/", w.receive).Methods(http.MethodPost)
return router
}
func (w WebhookServer) receive(writer http.ResponseWriter, request *http.Request) {
depositEvent := Webhook{}
request.Header.Del("content-length")
err := json.NewDecoder(request.Body).Decode(&depositEvent)
if err != nil {
writer.WriteHeader(400)
return
}
user, err := w.GetUserByWalletId(depositEvent.WalletID)
if err != nil {
writer.WriteHeader(400)
return
}
log.Infoln(fmt.Sprintf("[WebHook] User %s (%d) received invoice of %d sat.", user.Telegram.Username, user.Telegram.ID, depositEvent.Amount/1000))
_, err = w.bot.Send(user.Telegram, fmt.Sprintf(invoiceReceivedMessage, depositEvent.Amount/1000))
if err != nil {
log.Errorln(err)
}
writer.WriteHeader(200)
}

157
internal/lnurl/lnurl.go Normal file
View file

@ -0,0 +1,157 @@
package lnurl
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/LightningTipBot/LightningTipBot/internal/lnbits"
"github.com/fiatjaf/go-lnurl"
"github.com/gorilla/mux"
log "github.com/sirupsen/logrus"
)
func (w Server) handleLnUrl(writer http.ResponseWriter, request *http.Request) {
var err error
var response interface{}
username := mux.Vars(request)["username"]
if request.URL.RawQuery == "" {
response, err = w.serveLNURLpFirst(username)
} else {
stringAmount := request.FormValue("amount")
if stringAmount == "" {
NotFoundHandler(writer, fmt.Errorf("[serveLNURLpSecond] Form value 'amount' is not set"))
return
}
amount, parseError := strconv.Atoi(stringAmount)
if parseError != nil {
NotFoundHandler(writer, fmt.Errorf("[serveLNURLpSecond] Couldn't cast amount to int %v", parseError))
return
}
response, err = w.serveLNURLpSecond(username, int64(amount))
}
// check if error was returned from first or second handlers
if err != nil {
// log the error
log.Errorf("[LNURL] %v", err)
if response != nil {
// there is a valid error response
err = writeResponse(writer, response)
if err != nil {
NotFoundHandler(writer, err)
}
}
return
}
// no error from first or second handler
err = writeResponse(writer, response)
if err != nil {
NotFoundHandler(writer, err)
}
}
// serveLNURLpFirst serves the first part of the LNURLp protocol with the endpoint
// to call and the metadata that matches the description hash of the second response
func (w Server) serveLNURLpFirst(username string) (*lnurl.LNURLPayResponse1, error) {
log.Infof("[LNURL] Serving endpoint for user %s", username)
callbackURL, err := url.Parse(fmt.Sprintf("%s/%s/%s", w.callbackHostname.String(), lnurlEndpoint, username))
if err != nil {
return nil, err
}
metadata := w.metaData(username)
jsonMeta, err := json.Marshal(metadata)
if err != nil {
return nil, err
}
return &lnurl.LNURLPayResponse1{
LNURLResponse: lnurl.LNURLResponse{Status: statusOk},
Tag: payRequestTag,
Callback: callbackURL.String(),
CallbackURL: callbackURL, // probably no need to set this here
MinSendable: minSendable,
MaxSendable: MaxSendable,
EncodedMetadata: string(jsonMeta),
}, nil
}
// serveLNURLpSecond serves the second LNURL response with the payment request with the correct description hash
func (w Server) serveLNURLpSecond(username string, amount int64) (*lnurl.LNURLPayResponse2, error) {
log.Infof("[LNURL] Serving invoice for user %s", username)
if amount < minSendable || amount > MaxSendable {
// amount is not ok
return &lnurl.LNURLPayResponse2{
LNURLResponse: lnurl.LNURLResponse{
Status: statusError,
Reason: fmt.Sprintf("Amount out of bounds (min: %d mSat, max: %d mSat).", minSendable, MaxSendable)},
}, fmt.Errorf("amount out of bounds")
}
// amount is ok now check for the user
user := &lnbits.User{}
tx := w.database.Where("telegram_username = ?", strings.ToLower(username)).First(user)
if tx.Error != nil {
return nil, fmt.Errorf("[GetUser] Couldn't fetch user info from database: %v", tx.Error)
}
if user.Wallet == nil || user.Initialized == false {
return nil, fmt.Errorf("[serveLNURLpSecond] invalid user data")
}
// set wallet lnbits client
user.Wallet.Client = w.c
var resp *lnurl.LNURLPayResponse2
// the same description_hash needs to be built in the second request
metadata := w.metaData(username)
descriptionHash, err := w.descriptionHash(metadata)
if err != nil {
return nil, err
}
invoice, err := user.Wallet.Invoice(
lnbits.InvoiceParams{
Amount: amount / 1000,
Out: false,
DescriptionHash: descriptionHash,
Webhook: w.WebhookServer},
*user.Wallet)
if err != nil {
err = fmt.Errorf("[serveLNURLpSecond] Couldn't create invoice: %v", err)
resp = &lnurl.LNURLPayResponse2{
LNURLResponse: lnurl.LNURLResponse{
Status: statusError,
Reason: "Couldn't create invoice."},
}
return resp, err
}
return &lnurl.LNURLPayResponse2{
LNURLResponse: lnurl.LNURLResponse{Status: statusOk},
PR: invoice.PaymentRequest,
Routes: make([][]lnurl.RouteInfo, 0),
SuccessAction: &lnurl.SuccessAction{Message: "Payment received!", Tag: "message"},
}, nil
}
// descriptionHash is the SHA256 hash of the metadata
func (w Server) descriptionHash(metadata lnurl.Metadata) (string, error) {
jsonMeta, err := json.Marshal(metadata)
if err != nil {
return "", err
}
hash := sha256.Sum256([]byte(string(jsonMeta)))
hashString := hex.EncodeToString(hash[:])
return hashString, nil
}
// metaData returns the metadata that is sent in the first response
// and is used again in the second response to verify the description hash
func (w Server) metaData(username string) lnurl.Metadata {
return lnurl.Metadata{
{"text/identifier", fmt.Sprintf("%s@%s", username, w.callbackHostname.Hostname())},
{"text/plain", fmt.Sprintf("Pay to %s@%s", username, w.callbackHostname.Hostname())}}
}

76
internal/lnurl/server.go Normal file
View file

@ -0,0 +1,76 @@
package lnurl
import (
"encoding/json"
"net/http"
"net/url"
"time"
"github.com/LightningTipBot/LightningTipBot/internal/lnbits"
"github.com/gorilla/mux"
log "github.com/sirupsen/logrus"
tb "gopkg.in/tucnak/telebot.v2"
"gorm.io/gorm"
)
type Server struct {
httpServer *http.Server
bot *tb.Bot
c *lnbits.Client
database *gorm.DB
callbackHostname *url.URL
WebhookServer string
}
const (
statusError = "ERROR"
statusOk = "OK"
payRequestTag = "payRequest"
lnurlEndpoint = ".well-known/lnurlp"
minSendable = 1000 // mSat
MaxSendable = 1000000000
)
func NewServer(addr, callbackHostname *url.URL, webhookServer string, bot *tb.Bot, client *lnbits.Client, database *gorm.DB) *Server {
srv := &http.Server{
Addr: addr.Host,
// Good practice: enforce timeouts for servers you create!
WriteTimeout: 15 * time.Second,
ReadTimeout: 15 * time.Second,
}
apiServer := &Server{
c: client,
database: database,
bot: bot,
httpServer: srv,
callbackHostname: callbackHostname,
WebhookServer: webhookServer,
}
apiServer.httpServer.Handler = apiServer.newRouter()
go apiServer.httpServer.ListenAndServe()
log.Infof("[LNURL] Server started at %s", addr.Host)
return apiServer
}
func (w *Server) newRouter() *mux.Router {
router := mux.NewRouter()
router.HandleFunc("/.well-known/lnurlp/{username}", w.handleLnUrl).Methods(http.MethodGet)
router.HandleFunc("/@{username}", w.handleLnUrl).Methods(http.MethodGet)
return router
}
func NotFoundHandler(writer http.ResponseWriter, err error) {
log.Errorln(err)
// return 404 on any error
http.Error(writer, "404 page not found", http.StatusNotFound)
}
func writeResponse(writer http.ResponseWriter, response interface{}) error {
jsonResponse, err := json.Marshal(response)
if err != nil {
return err
}
_, err = writer.Write(jsonResponse)
return err
}

15
internal/runtime/error.go Normal file
View file

@ -0,0 +1,15 @@
package runtime
import log "github.com/sirupsen/logrus"
// accepts error and logs error message. Used to prevent warnings when errors not caught.
func IgnoreError(err error) {
if err != nil {
log.WithFields(log.Fields{"module": "[ERROR]", "error": err.Error()}).Errorf("catching ignored error")
}
}
func IgnoreErrorMultiReturn(i interface{}, err error) {
if err != nil {
log.WithFields(log.Fields{"module": "[ERROR]", "error": err.Error()}).Errorf("catching ignored error")
}
}

114
internal/storage/bunt.go Normal file
View file

@ -0,0 +1,114 @@
package storage
import (
"encoding/json"
"github.com/LightningTipBot/LightningTipBot/internal/runtime"
log "github.com/sirupsen/logrus"
"github.com/tidwall/buntdb"
)
// Storable items must provide a function to retrieve the database key
type Storable interface {
Key() string
}
type DB struct {
*buntdb.DB
}
const (
MessageOrderedByReplyToFrom = "message.reply_to_message.from.id"
MessageOrderedByReplyTo = "message.reply_to_message.id"
)
func NewBunt(filePath string) *DB {
db, err := buntdb.Open(filePath)
if err != nil {
log.Fatal(err)
}
err = db.CreateIndex(MessageOrderedByReplyToFrom, "*", buntdb.IndexJSON(MessageOrderedByReplyToFrom))
if err != nil {
panic(err)
}
err = db.CreateIndex(MessageOrderedByReplyTo, "*", buntdb.IndexJSON(MessageOrderedByReplyTo))
if err != nil {
panic(err)
}
return &DB{db}
}
// Exists checks is storable item exists
func (db *DB) Exists(storable Storable) (ok bool, err error) {
ok = false
err = db.View(func(tx *buntdb.Tx) error {
_, err := tx.Get(storable.Key())
if err != nil {
return err
}
return nil
})
if err != nil {
if err == buntdb.ErrNotFound {
return
}
return
}
ok = true
return
}
// Get a storable item
func (db *DB) Get(object Storable) error {
err := db.View(func(tx *buntdb.Tx) error {
val, err := tx.Get(object.Key())
if err != nil {
return err
}
err = json.Unmarshal([]byte(val), object)
if err != nil {
return err
}
return nil
})
return err
}
// Set a storable item.
func (db *DB) Set(object Storable) error {
err := db.Update(func(tx *buntdb.Tx) error {
b, err := json.Marshal(object)
if err != nil {
return err
}
_, _, err = tx.Set(object.Key(), string(b), nil)
return err
})
return err
}
// Delete a storable item.
// todo -- not ascend users index
func (db *DB) Delete(index string, object Storable) error {
return db.Update(func(tx *buntdb.Tx) error {
var delkeys []string
runtime.IgnoreError(
tx.Ascend(index, func(key, value string) bool {
if key == object.Key() {
delkeys = append(delkeys, key)
}
return true
}),
)
for _, k := range delkeys {
if _, err := tx.Delete(k); err != nil {
return err
}
}
return nil
})
}

95
invoice.go Normal file
View file

@ -0,0 +1,95 @@
package main
import (
"bytes"
"fmt"
"strings"
log "github.com/sirupsen/logrus"
"github.com/LightningTipBot/LightningTipBot/internal/lnbits"
"github.com/skip2/go-qrcode"
tb "gopkg.in/tucnak/telebot.v2"
)
const (
invoiceEnterAmountMessage = "Did you enter an amount?"
invoiceValidAmountMessage = "Did you enter a valid amount?"
invoiceHelpText = "📖 Oops, that didn't work. %s\n\n" +
"*Usage:* `/invoice <amount> [<memo>]`\n" +
"*Example:* `/invoice 1000 Thank you!`"
)
func helpInvoiceUsage(errormsg string) string {
if len(errormsg) > 0 {
return fmt.Sprintf(invoiceHelpText, fmt.Sprintf("%s", errormsg))
} else {
return fmt.Sprintf(invoiceHelpText, "")
}
}
func (bot TipBot) invoiceHandler(m *tb.Message) {
// check and print all commands
bot.anyTextHandler(m)
if m.Chat.Type != tb.ChatPrivate {
// delete message
NewMessage(m, WithDuration(0, bot.telegram))
return
}
if len(strings.Split(m.Text, " ")) < 2 {
bot.trySendMessage(m.Sender, helpInvoiceUsage(invoiceEnterAmountMessage))
return
}
user, err := GetUser(m.Sender, bot)
userStr := GetUserStr(m.Sender)
amount, err := decodeAmountFromCommand(m.Text)
if err != nil {
return
}
if amount > 0 {
} else {
bot.trySendMessage(m.Sender, helpInvoiceUsage(invoiceValidAmountMessage))
return
}
// check for memo in command
memo := "Powered by @LightningTipBot"
if len(strings.Split(m.Text, " ")) > 2 {
memo = strings.SplitN(m.Text, " ", 3)[2]
tag := " (@LightningTipBot)"
memoMaxLen := 159 - len(tag)
if len(memo) > memoMaxLen {
memo = memo[:memoMaxLen-len(tag)]
}
memo = memo + tag
}
log.Infof("[/invoice] Creating invoice for %s of %d sat.", userStr, amount)
// generate invoice
invoice, err := user.Wallet.Invoice(
lnbits.InvoiceParams{
Out: false,
Amount: int64(amount),
Memo: memo,
Webhook: Configuration.Lnbits.WebhookServer},
*user.Wallet)
if err != nil {
errmsg := fmt.Sprintf("[/invoice] Could not create an invoice: %s", err)
log.Errorln(errmsg)
return
}
// create qr code
qr, err := qrcode.Encode(invoice.PaymentRequest, qrcode.Medium, 256)
if err != nil {
errmsg := fmt.Sprintf("[/invoice] Failed to create QR code for invoice: %s", err)
log.Errorln(errmsg)
return
}
// send the invoice data to user
bot.trySendMessage(m.Sender, &tb.Photo{File: tb.File{FileReader: bytes.NewReader(qr)}, Caption: fmt.Sprintf("`%s`", invoice.PaymentRequest)})
log.Printf("[/invoice] Incvoice created. User: %s, amount: %d sat.", userStr, amount)
return
}

51
link.go Normal file
View file

@ -0,0 +1,51 @@
package main
import (
"bytes"
"fmt"
log "github.com/sirupsen/logrus"
"github.com/skip2/go-qrcode"
tb "gopkg.in/tucnak/telebot.v2"
)
var (
walletConnectMessage = "🔗 *Link your wallet*\n\n" +
"⚠️ Never share the URL or the QR code with anyone or they will be able to access your funds.\n\n" +
"- *BlueWallet:* Press *New wallet*, *Import wallet*, *Scan or import a file*, and scan the QR code.\n" +
"- *Zeus:* Copy the URL below, press *Add a new node*, *Import* (the URL), *Save Node Config*."
couldNotLinkMessage = "🚫 Couldn't link your wallet. Please try again later."
)
func (bot TipBot) lndhubHandler(m *tb.Message) {
if Configuration.Lnbits.LnbitsPublicUrl == "" {
bot.trySendMessage(m.Sender, couldNotLinkMessage)
return
}
// check and print all commands
bot.anyTextHandler(m)
// reply only in private message
if m.Chat.Type != tb.ChatPrivate {
// delete message
NewMessage(m, WithDuration(0, bot.telegram))
}
// first check whether the user is initialized
fromUser, err := GetUser(m.Sender, bot)
if err != nil {
log.Errorf("[/balance] Error: %s", err)
return
}
bot.trySendMessage(m.Sender, walletConnectMessage)
lndhubUrl := fmt.Sprintf("lndhub://admin:%s@%slndhub/ext/", fromUser.Wallet.Adminkey, Configuration.Lnbits.LnbitsPublicUrl)
// create qr code
qr, err := qrcode.Encode(lndhubUrl, qrcode.Medium, 256)
if err != nil {
errmsg := fmt.Sprintf("[/invoice] Failed to create QR code for invoice: %s", err)
log.Errorln(errmsg)
return
}
// send the invoice data to user
bot.trySendMessage(m.Sender, &tb.Photo{File: tb.File{FileReader: bytes.NewReader(qr)}, Caption: fmt.Sprintf("`%s`", lndhubUrl)})
}

405
lnurl.go Normal file
View file

@ -0,0 +1,405 @@
package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/LightningTipBot/LightningTipBot/internal/lnbits"
lnurl "github.com/fiatjaf/go-lnurl"
log "github.com/sirupsen/logrus"
"github.com/skip2/go-qrcode"
"github.com/tidwall/gjson"
tb "gopkg.in/tucnak/telebot.v2"
)
const (
lnurlReceiveInfoText = "👇 You can use this LNURL to receive payments."
lnurlResolvingUrlMessage = "🧮 Resolving address..."
lnurlGettingUserMessage = "🧮 Preparing payment..."
lnurlPaymentFailed = "🚫 Payment failed: %s"
lnurlInvalidAmountMessage = "🚫 Invalid amount."
lnurlInvalidAmountRangeMessage = "🚫 Amount must be between %d and %d sat."
lnurlNoUsernameMessage = "🚫 You need to set a Telegram username to receive via LNURL."
lnurlEnterAmountMessage = "Please enter an amount."
lnurlHelpText = "📖 Oops, that didn't work. %s\n\n" +
"*Usage:* `/lnurl [amount] <lnurl>`\n" +
"*Example:* `/lnurl LNURL1DP68GUR...`"
)
// lnurlHandler is invoked on /lnurl command
func (bot TipBot) lnurlHandler(m *tb.Message) {
// commands:
// /lnurl
// /lnurl <LNURL>
// or /lnurl <amount> <LNURL>
log.Infof("[lnurlHandler] %s", m.Text)
// if only /lnurl is entered, show the lnurl of the user
if m.Text == "/lnurl" {
bot.lnurlReceiveHandler(m)
return
}
// assume payment
// HandleLNURL by fiatjaf/go-lnurl
msg := bot.trySendMessage(m.Sender, lnurlResolvingUrlMessage)
_, params, err := HandleLNURL(m.Text)
if err != nil {
bot.tryEditMessage(msg, fmt.Sprintf(lnurlPaymentFailed, "could not resolve LNURL."))
log.Errorln(err)
return
}
var payParams LnurlStateResponse
switch params.(type) {
case lnurl.LNURLPayResponse1:
payParams = LnurlStateResponse{LNURLPayResponse1: params.(lnurl.LNURLPayResponse1)}
log.Infof("[lnurlHandler] %s", payParams.Callback)
default:
err := fmt.Errorf("invalid LNURL type.")
log.Errorln(err)
bot.tryEditMessage(msg, fmt.Sprintf(lnurlPaymentFailed, err))
// bot.trySendMessage(m.Sender, err.Error())
return
}
user, err := GetUser(m.Sender, bot)
if err != nil {
log.Errorln(err)
bot.tryEditMessage(msg, fmt.Sprintf(lnurlPaymentFailed, "database error."))
return
}
// if no amount is in the command, ask for it
amount, err := decodeAmountFromCommand(m.Text)
if err != nil || amount < 1 {
// set LNURLPayResponse1 in the state of the user
paramsJson, err := json.Marshal(payParams)
if err != nil {
log.Errorln(err)
return
}
user.StateData = string(paramsJson)
user.StateKey = lnbits.UserStateLNURLEnterAmount
err = UpdateUserRecord(user, bot)
if err != nil {
log.Errorln(err)
return
}
bot.tryDeleteMessage(msg)
// Let the user enter an amount and return
bot.trySendMessage(m.Sender, fmt.Sprintf(lnurlEnterAmountMessage), tb.ForceReply)
} else {
// amount is already present in the command
// set also amount in the state of the user
// todo: this is repeated code, could be shorter
payParams.Amount = amount
paramsJson, err := json.Marshal(payParams)
if err != nil {
log.Errorln(err)
// bot.trySendMessage(m.Sender, err.Error())
return
}
user.StateData = string(paramsJson)
user.StateKey = lnbits.UserStateConfirmLNURLPay
err = UpdateUserRecord(user, bot)
if err != nil {
log.Errorln(err)
// bot.trySendMessage(m.Sender, err.Error())
return
}
bot.tryDeleteMessage(msg)
// directly go to confirm
bot.lnurlPayHandler(m)
}
}
func (bot *TipBot) UserGetLightningAddress(user *tb.User) (string, error) {
if len(user.Username) > 0 {
return fmt.Sprintf("%s@%s", strings.ToLower(user.Username), strings.ToLower(Configuration.Bot.LNURLHostUrl.Hostname())), nil
} else {
return "", fmt.Errorf("user has no username")
}
}
func (bot *TipBot) UserGetLNURL(user *tb.User) (string, error) {
name := strings.ToLower(strings.ToLower(user.Username))
if len(name) == 0 {
return "", fmt.Errorf("user has no username.")
}
callback := fmt.Sprintf("%s/.well-known/lnurlp/%s", Configuration.Bot.LNURLHostName, name)
log.Infof("[lnurlReceiveHandler] %s's LNURL: %s", GetUserStr(user), callback)
lnurlEncode, err := lnurl.LNURLEncode(callback)
if err != nil {
return "", err
}
return lnurlEncode, nil
}
// lnurlReceiveHandler outputs the LNURL of the user
func (bot TipBot) lnurlReceiveHandler(m *tb.Message) {
lnurlEncode, err := bot.UserGetLNURL(m.Sender)
if err != nil {
errmsg := fmt.Sprintf("[lnurlReceiveHandler] Failed to get LNURL: %s", err)
log.Errorln(errmsg)
bot.telegram.Send(m.Sender, lnurlNoUsernameMessage)
}
// create qr code
qr, err := qrcode.Encode(lnurlEncode, qrcode.Medium, 256)
if err != nil {
errmsg := fmt.Sprintf("[lnurlReceiveHandler] Failed to create QR code for LNURL: %s", err)
log.Errorln(errmsg)
return
}
bot.trySendMessage(m.Sender, lnurlReceiveInfoText)
// send the lnurl data to user
bot.trySendMessage(m.Sender, &tb.Photo{File: tb.File{FileReader: bytes.NewReader(qr)}, Caption: fmt.Sprintf("`%s`", lnurlEncode)})
}
func (bot TipBot) lnurlEnterAmountHandler(m *tb.Message) {
user, err := GetUser(m.Sender, bot)
if err != nil {
log.Errorln(err)
// bot.trySendMessage(m.Sender, err.Error())
return
}
if user.StateKey == lnbits.UserStateLNURLEnterAmount {
a, err := strconv.Atoi(m.Text)
if err != nil {
log.Errorln(err)
bot.trySendMessage(m.Sender, lnurlInvalidAmountMessage)
return
}
amount := int64(a)
var stateResponse LnurlStateResponse
err = json.Unmarshal([]byte(user.StateData), &stateResponse)
if err != nil {
log.Errorln(err)
return
}
// amount not in allowed range from LNURL
if amount > (stateResponse.MaxSendable/1000) || amount < (stateResponse.MinSendable/1000) {
err = fmt.Errorf("amount not in range")
log.Errorln(err)
bot.trySendMessage(m.Sender, fmt.Sprintf(lnurlInvalidAmountRangeMessage, stateResponse.MinSendable/1000, stateResponse.MaxSendable/1000))
return
}
stateResponse.Amount = a
user.StateKey = lnbits.UserStateConfirmLNURLPay
state, err := json.Marshal(stateResponse)
if err != nil {
log.Errorln(err)
return
}
user.StateData = string(state)
err = UpdateUserRecord(user, bot)
if err != nil {
log.Errorln(err)
return
}
bot.lnurlPayHandler(m)
}
}
// LnurlStateResponse saves the state of the user for an LNURL payment
type LnurlStateResponse struct {
lnurl.LNURLPayResponse1
Amount int `json:"amount"`
}
// lnurlPayHandler is invoked when the user has delivered an amount and is ready to pay
func (bot TipBot) lnurlPayHandler(c *tb.Message) {
msg := bot.trySendMessage(c.Sender, lnurlGettingUserMessage)
user, err := GetUser(c.Sender, bot)
if err != nil {
log.Errorln(err)
// bot.trySendMessage(c.Sender, err.Error())
bot.tryEditMessage(msg, fmt.Sprintf(lnurlPaymentFailed, "database error."))
return
}
if user.StateKey == lnbits.UserStateConfirmLNURLPay {
client, err := getHttpClient()
if err != nil {
log.Errorln(err)
// bot.trySendMessage(c.Sender, err.Error())
bot.tryEditMessage(msg, fmt.Sprintf(lnurlPaymentFailed, err))
return
}
var stateResponse LnurlStateResponse
err = json.Unmarshal([]byte(user.StateData), &stateResponse)
if err != nil {
log.Errorln(err)
// bot.trySendMessage(c.Sender, err.Error())
bot.tryEditMessage(msg, fmt.Sprintf(lnurlPaymentFailed, err))
return
}
callbackUrl, err := url.Parse(stateResponse.Callback)
if err != nil {
log.Errorln(err)
// bot.trySendMessage(c.Sender, err.Error())
bot.tryEditMessage(msg, fmt.Sprintf(lnurlPaymentFailed, err))
return
}
qs := callbackUrl.Query()
qs.Set("amount", strconv.Itoa(stateResponse.Amount*1000))
callbackUrl.RawQuery = qs.Encode()
res, err := client.Get(callbackUrl.String())
if err != nil {
log.Errorln(err)
// bot.trySendMessage(c.Sender, err.Error())
bot.tryEditMessage(msg, fmt.Sprintf(lnurlPaymentFailed, err))
return
}
var response2 lnurl.LNURLPayResponse2
body, err := ioutil.ReadAll(res.Body)
if err != nil {
log.Errorln(err)
// bot.trySendMessage(c.Sender, err.Error())
bot.tryEditMessage(msg, fmt.Sprintf(lnurlPaymentFailed, err))
return
}
json.Unmarshal(body, &response2)
if len(response2.PR) < 1 {
bot.tryEditMessage(msg, fmt.Sprintf(lnurlPaymentFailed, "could not receive invoice (wrong address?)."))
return
}
bot.telegram.Delete(msg)
c.Text = fmt.Sprintf("/pay %s", response2.PR)
bot.confirmPaymentHandler(c)
}
}
func getHttpClient() (*http.Client, error) {
client := http.Client{}
if Configuration.Bot.HttpProxy != "" {
proxyUrl, err := url.Parse(Configuration.Bot.HttpProxy)
if err != nil {
log.Errorln(err)
return nil, err
}
client.Transport = &http.Transport{Proxy: http.ProxyURL(proxyUrl)}
}
return &client, nil
}
func (bot TipBot) cancelLnUrlHandler(c *tb.Callback) {
}
// from https://github.com/fiatjaf/go-lnurl
func HandleLNURL(rawlnurl string) (string, lnurl.LNURLParams, error) {
var err error
var rawurl string
if name, domain, ok := lnurl.ParseInternetIdentifier(rawlnurl); ok {
isOnion := strings.Index(domain, ".onion") == len(domain)-6
rawurl = domain + "/.well-known/lnurlp/" + name
if isOnion {
rawurl = "http://" + rawurl
} else {
rawurl = "https://" + rawurl
}
} else if strings.HasPrefix(rawlnurl, "http") {
rawurl = rawlnurl
} else {
foundUrl, ok := lnurl.FindLNURLInText(rawlnurl)
if !ok {
return "", nil,
errors.New("invalid bech32-encoded lnurl: " + rawlnurl)
}
rawurl, err = lnurl.LNURLDecode(foundUrl)
if err != nil {
return "", nil, err
}
}
parsed, err := url.Parse(rawurl)
if err != nil {
return rawurl, nil, err
}
query := parsed.Query()
switch query.Get("tag") {
case "login":
value, err := lnurl.HandleAuth(rawurl, parsed, query)
return rawurl, value, err
case "withdrawRequest":
if value, ok := lnurl.HandleFastWithdraw(query); ok {
return rawurl, value, nil
}
}
client, err := getHttpClient()
if err != nil {
return "", nil, err
}
resp, err := client.Get(rawurl)
if err != nil {
return rawurl, nil, err
}
b, err := ioutil.ReadAll(resp.Body)
if err != nil {
return rawurl, nil, err
}
j := gjson.ParseBytes(b)
if j.Get("status").String() == "ERROR" {
return rawurl, nil, lnurl.LNURLErrorResponse{
URL: parsed,
Reason: j.Get("reason").String(),
Status: "ERROR",
}
}
switch j.Get("tag").String() {
case "withdrawRequest":
value, err := lnurl.HandleWithdraw(j)
return rawurl, value, err
case "payRequest":
value, err := lnurl.HandlePay(j)
return rawurl, value, err
case "channelRequest":
value, err := lnurl.HandleChannel(j)
return rawurl, value, err
default:
return rawurl, nil, errors.New("unknown response tag " + j.String())
}
}
func (bot *TipBot) sendToLightningAddress(m *tb.Message, address string, amount int) error {
split := strings.Split(address, "@")
if len(split) != 2 {
return fmt.Errorf("lightning address format wrong")
}
host := strings.ToLower(split[1])
name := strings.ToLower(split[0])
// convert address scheme into LNURL Bech32 format
callback := fmt.Sprintf("https://%s/.well-known/lnurlp/%s", host, name)
log.Infof("[sendToLightningAddress] %s: callback: %s", GetUserStr(m.Sender), callback)
lnurl, err := lnurl.LNURLEncode(callback)
if err != nil {
return err
}
if amount > 0 {
m.Text = fmt.Sprintf("/lnurl %d %s", amount, lnurl)
} else {
m.Text = fmt.Sprintf("/lnurl %s", lnurl)
}
bot.lnurlHandler(m)
return nil
}

30
main.go Normal file
View file

@ -0,0 +1,30 @@
package main
import (
log "github.com/sirupsen/logrus"
"runtime/debug"
)
// setLogger will initialize the log format
func setLogger() {
log.SetLevel(log.InfoLevel)
customFormatter := new(log.TextFormatter)
customFormatter.TimestampFormat = "2006-01-02 15:04:05"
customFormatter.FullTimestamp = true
log.SetFormatter(customFormatter)
}
func main() {
// set logger
setLogger()
defer withRecovery()
bot := NewBot()
bot.Start()
}
func withRecovery() {
if r := recover(); r != nil {
log.Errorln("Recovered panic: ", r)
debug.PrintStack()
}
}

53
message.go Normal file
View file

@ -0,0 +1,53 @@
package main
import (
"strconv"
"time"
log "github.com/sirupsen/logrus"
tb "gopkg.in/tucnak/telebot.v2"
)
type Message struct {
Message *tb.Message `json:"message"`
duration time.Duration
}
type MessageOption func(m *Message)
func WithDuration(duration time.Duration, bot *tb.Bot) MessageOption {
return func(m *Message) {
m.duration = duration
go m.dispose(bot)
}
}
func NewMessage(m *tb.Message, opts ...MessageOption) *Message {
msg := &Message{
Message: m,
}
for _, opt := range opts {
opt(msg)
}
return msg
}
func (msg Message) Key() string {
return strconv.Itoa(msg.Message.ID)
}
func (msg Message) dispose(telegram *tb.Bot) {
// do not delete messages from private chat
if msg.Message.Private() {
return
}
go func() {
time.Sleep(msg.duration)
err := telegram.Delete(msg.Message)
if err != nil {
log.Println(err.Error())
return
}
}()
}

174
pay.go Normal file
View file

@ -0,0 +1,174 @@
package main
import (
"fmt"
"strings"
log "github.com/sirupsen/logrus"
"github.com/LightningTipBot/LightningTipBot/internal/lnbits"
decodepay "github.com/fiatjaf/ln-decodepay"
tb "gopkg.in/tucnak/telebot.v2"
)
const (
paymentCancelledMessage = "🚫 Payment cancelled."
invoicePaidMessage = "⚡️ Payment sent."
invoicePrivateChatOnlyErrorMessage = "You can pay invoices only in the private chat with the bot."
invalidInvoiceHelpMessage = "Did you enter a valid Lightning invoice? Try /send if you want to send to a Telegram user or Lightning address."
invoiceNoAmountMessage = "🚫 Can't pay invoices without an amount."
insufficientFundsMessage = "🚫 Insufficient funds. You have %d sat but you need at least %d sat."
feeReserveMessage = "⚠️ Sending your entire balance might fail because of network fees. If it fails, try sending a bit less."
invoicePaymentFailedMessage = "🚫 Payment failed: %s"
confirmPayInvoiceMessage = "Do you want to send this payment?\n\n💸 Amount: %d sat"
confirmPayAppendMemo = "\n✉ %s"
payHelpText = "📖 Oops, that didn't work. %s\n\n" +
"*Usage:* `/pay <invoice>`\n" +
"*Example:* `/pay lnbc20n1psscehd...`"
)
func helpPayInvoiceUsage(errormsg string) string {
if len(errormsg) > 0 {
return fmt.Sprintf(payHelpText, fmt.Sprintf("%s", errormsg))
} else {
return fmt.Sprintf(payHelpText, "")
}
}
// confirmPaymentHandler invoked on "/pay lnbc..." command
func (bot TipBot) confirmPaymentHandler(m *tb.Message) {
// check and print all commands
bot.anyTextHandler(m)
if m.Chat.Type != tb.ChatPrivate {
// delete message
NewMessage(m, WithDuration(0, bot.telegram))
bot.trySendMessage(m.Sender, helpPayInvoiceUsage(invoicePrivateChatOnlyErrorMessage))
return
}
if len(strings.Split(m.Text, " ")) < 2 {
NewMessage(m, WithDuration(0, bot.telegram))
bot.trySendMessage(m.Sender, helpPayInvoiceUsage(""))
return
}
user, err := GetUser(m.Sender, bot)
if err != nil {
NewMessage(m, WithDuration(0, bot.telegram))
errmsg := fmt.Sprintf("[/pay] Error: Could not GetUser: %s", err)
log.Errorln(errmsg)
return
}
userStr := GetUserStr(m.Sender)
paymentRequest, err := getArgumentFromCommand(m.Text, 1)
if err != nil {
NewMessage(m, WithDuration(0, bot.telegram))
bot.trySendMessage(m.Sender, helpPayInvoiceUsage(invalidInvoiceHelpMessage))
errmsg := fmt.Sprintf("[/pay] Error: Could not getArgumentFromCommand: %s", err)
log.Errorln(errmsg)
return
}
paymentRequest = strings.ToLower(paymentRequest)
// get rid of the URI prefix
paymentRequest = strings.TrimPrefix(paymentRequest, "lightning:")
// decode invoice
bolt11, err := decodepay.Decodepay(paymentRequest)
if err != nil {
bot.trySendMessage(m.Sender, helpPayInvoiceUsage(invalidInvoiceHelpMessage))
errmsg := fmt.Sprintf("[/pay] Error: Could not decode invoice: %s", err)
log.Errorln(errmsg)
return
}
amount := int(bolt11.MSatoshi / 1000)
if amount <= 0 {
bot.trySendMessage(m.Sender, invoiceNoAmountMessage)
errmsg := fmt.Sprint("[/pay] Error: invoice without amount")
log.Errorln(errmsg)
return
}
// check user balance first
balance, err := bot.GetUserBalance(m.Sender)
if err != nil {
NewMessage(m, WithDuration(0, bot.telegram))
errmsg := fmt.Sprintf("[/pay] Error: Could not get user balance: %s", err)
log.Errorln(errmsg)
return
}
if amount > balance {
NewMessage(m, WithDuration(0, bot.telegram))
bot.trySendMessage(m.Sender, fmt.Sprintf(insufficientFundsMessage, balance, amount))
return
}
// send warning that the invoice might fail due to missing fee reserve
if float64(amount) > float64(balance)*0.99 {
bot.trySendMessage(m.Sender, feeReserveMessage)
}
log.Printf("[/pay] User: %s, amount: %d sat.", userStr, amount)
user.StateKey = lnbits.UserStateConfirmPayment
user.StateData = paymentRequest
err = UpdateUserRecord(user, bot)
if err != nil {
log.Printf("[UpdateUserRecord] User: %s Error: %s", userStr, err.Error())
return
}
// // // create inline buttons
paymentConfirmationMenu.Inline(paymentConfirmationMenu.Row(btnPay, btnCancelPay))
confirmText := fmt.Sprintf(confirmPayInvoiceMessage, amount)
if len(bolt11.Description) > 0 {
confirmText = confirmText + fmt.Sprintf(confirmPayAppendMemo, MarkdownEscape(bolt11.Description))
}
bot.trySendMessage(m.Sender, confirmText, paymentConfirmationMenu)
}
// cancelPaymentHandler invoked when user clicked cancel on payment confirmation
func (bot TipBot) cancelPaymentHandler(c *tb.Callback) {
// reset state immediately
user, err := GetUser(c.Sender, bot)
if err != nil {
return
}
user.ResetState()
err = UpdateUserRecord(user, bot)
bot.tryDeleteMessage(c.Message)
_, err = bot.telegram.Send(c.Sender, paymentCancelledMessage)
if err != nil {
log.WithField("message", paymentCancelledMessage).WithField("user", c.Sender.ID).Printf("[Send] %s", err.Error())
return
}
}
// payHandler when user clicked pay "X" on payment confirmation
func (bot TipBot) payHandler(c *tb.Callback) {
bot.tryEditMessage(c.Message, c.Message.Text, &tb.ReplyMarkup{})
user, err := GetUser(c.Sender, bot)
if err != nil {
log.Printf("[GetUser] User: %d: %s", c.Sender.ID, err.Error())
return
}
if user.StateKey == lnbits.UserStateConfirmPayment {
invoiceString := user.StateData
// reset state immediatelly
user.ResetState()
err = UpdateUserRecord(user, bot)
userStr := GetUserStr(c.Sender)
// pay invoice
invoice, err := user.Wallet.Pay(lnbits.PaymentParams{Out: true, Bolt11: invoiceString}, *user.Wallet)
if err != nil {
errmsg := fmt.Sprintf("[/pay] Could not pay invoice of user %s: %s", userStr, err)
bot.trySendMessage(c.Sender, fmt.Sprintf(invoicePaymentFailedMessage, err))
log.Errorln(errmsg)
return
}
bot.trySendMessage(c.Sender, invoicePaidMessage)
log.Printf("[/pay] User %s paid invoice %s", userStr, invoice.PaymentHash)
return
}
}

73
photo.go Normal file
View file

@ -0,0 +1,73 @@
package main
import (
"fmt"
"image"
"image/jpeg"
"strings"
"github.com/LightningTipBot/LightningTipBot/pkg/lightning"
"github.com/makiuchi-d/gozxing"
"github.com/makiuchi-d/gozxing/qrcode"
log "github.com/sirupsen/logrus"
tb "gopkg.in/tucnak/telebot.v2"
)
var (
photoQrNotRecognizedMessage = "🚫 Could not regocognize a Lightning invoice. Try to center the QR code, crop the photo, or zoom in."
photoInvoiceRecognizedMessage = "✅ Invoice:\n`%s`"
)
// TryRecognizeInvoiceFromQrCode will try to read an invoice string from a qr code and invoke the payment handler.
func TryRecognizeInvoiceFromQrCode(img image.Image) (*gozxing.Result, error) {
// check for qr code
bmp, _ := gozxing.NewBinaryBitmapFromImage(img)
// decode image
qrReader := qrcode.NewQRCodeReader()
result, err := qrReader.Decode(bmp, nil)
if err != nil {
return nil, err
}
payload := strings.ToLower(result.String())
if lightning.IsInvoice(payload) {
// create payment command payload
// invoke payment confirmation handler
return result, nil
}
return nil, fmt.Errorf("no codes found")
}
// privatePhotoHandler is the handler function for every photo from a private chat that the bot receives
func (bot TipBot) privatePhotoHandler(m *tb.Message) {
if m.Chat.Type != tb.ChatPrivate {
return
}
if m.Photo == nil {
return
}
log.Infof("[%s:%d %s:%d] %s", m.Chat.Title, m.Chat.ID, GetUserStr(m.Sender), m.Sender.ID, "<Photo>")
// get file reader closer from telegram api
reader, err := bot.telegram.GetFile(m.Photo.MediaFile())
if err != nil {
log.Errorf("Getfile error: %v\n", err)
return
}
// decode to jpeg image
img, err := jpeg.Decode(reader)
if err != nil {
log.Errorf("image.Decode error: %v\n", err)
return
}
data, err := TryRecognizeInvoiceFromQrCode(img)
if err != nil {
log.Errorf("tryRecognizeQrCodes error: %v\n", err)
bot.trySendMessage(m.Sender, photoQrNotRecognizedMessage)
return
}
bot.trySendMessage(m.Sender, fmt.Sprintf(photoInvoiceRecognizedMessage, data.String()))
// invoke payment handler
m.Text = fmt.Sprintf("/pay %s", data.String())
bot.confirmPaymentHandler(m)
}

View file

@ -0,0 +1,24 @@
package lightning
import (
"net/mail"
"strings"
)
// IsInvoice is used to check if a string matches lnbc invoide pattern.
// todo -- probably should add regex and validate length
func IsInvoice(invoice string) bool {
// invoice string must start with lnbc or lightning:lnbc
if strings.HasPrefix(invoice, "lnbc") || strings.HasPrefix(invoice, "lightning:lnbc") {
// invoice string must be a single word
if !strings.Contains(invoice, " ") {
return true
}
}
return false
}
func IsLightningAddress(address string) bool {
_, err := mail.ParseAddress(address)
return err == nil
}

BIN
resources/lnbits_setup.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 550 KiB

BIN
resources/lndhub.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

BIN
resources/logo_round.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
resources/qr2.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

BIN
resources/test_qr.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

BIN
resources/tooltips.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

312
send.go Normal file
View file

@ -0,0 +1,312 @@
package main
import (
"fmt"
"strconv"
"strings"
"github.com/LightningTipBot/LightningTipBot/internal/lnbits"
"github.com/LightningTipBot/LightningTipBot/pkg/lightning"
log "github.com/sirupsen/logrus"
tb "gopkg.in/tucnak/telebot.v2"
)
const (
sendValidAmountMessage = "Did you enter a valid amount?"
sendUserNotFoundMessage = "User %s could not be found. You can /send only to Telegram tags like @%s."
sendIsNotAUsser = "🚫 %s is not a username. You can /send only to Telegram tags like @%s."
sendUserHasNoWalletMessage = "🚫 User %s hasn't created a wallet yet."
sendSentMessage = "💸 %d sat sent to %s."
sendReceivedMessage = "🏅 %s sent you %d sat."
sendErrorMessage = "🚫 Transaction failed: %s"
confirmSendInvoiceMessage = "Do you want to pay to %s?\n\n💸 Amount: %d sat"
confirmSendAppendMemo = "\n✉ %s"
sendCancelledMessage = "🚫 Send cancelled."
errorTryLaterMessage = "🚫 Internal error. Please try again later.."
sendHelpText = "📖 Oops, that didn't work. %s\n\n" +
"*Usage:* `/send <amount> <user> [<memo>]`\n" +
"*Example:* `/send 1000 @LightningTipBot I just like the bot ❤️`\n" +
"*Example:* `/send 1234 LightningTipBot@ln.tips`"
)
func helpSendUsage(errormsg string) string {
if len(errormsg) > 0 {
return fmt.Sprintf(sendHelpText, fmt.Sprintf("%s", errormsg))
} else {
return fmt.Sprintf(sendHelpText, "")
}
}
func (bot *TipBot) SendCheckSyntax(m *tb.Message) (bool, string) {
arguments := strings.Split(m.Text, " ")
if len(arguments) < 2 {
return false, fmt.Sprintf("Did you enter an amount and a recipient? You can use the /send command to either send to Telegram users like @%s or to a Lightning address like LightningTipBot@ln.tips.", bot.telegram.Me.Username)
}
// if len(arguments) < 3 {
// return false, "Did you enter a recipient?"
// }
// if !strings.HasPrefix(arguments[0], "/send") {
// return false, "Did you enter a valid command?"
// }
return true, ""
}
// confirmPaymentHandler invoked on "/send 123 @user" command
func (bot *TipBot) confirmSendHandler(m *tb.Message) {
// reset state immediately
user, err := GetUser(m.Sender, *bot)
if err != nil {
return
}
user.ResetState()
err = UpdateUserRecord(user, *bot)
// check and print all commands
bot.anyTextHandler(m)
// If the send is a reply, then trigger /tip handler
if m.IsReply() {
bot.tipHandler(m)
return
}
if ok, errstr := bot.SendCheckSyntax(m); !ok {
bot.trySendMessage(m.Sender, helpSendUsage(errstr))
NewMessage(m, WithDuration(0, bot.telegram))
return
}
// get send amount, returns 0 if no amount is given
amount, err := decodeAmountFromCommand(m.Text)
// info: /send 10 <user> DEMANDS an amount, while /send <ln@address.com> also works without
// todo: /send <user> should also invoke amount input dialog if no amount is given
// CHECK whether first or second argument is a LIGHTNING ADDRESS
arg := ""
if len(strings.Split(m.Text, " ")) > 2 {
arg, err = getArgumentFromCommand(m.Text, 2)
} else if len(strings.Split(m.Text, " ")) == 2 {
arg, err = getArgumentFromCommand(m.Text, 1)
}
if err == nil {
if lightning.IsLightningAddress(arg) {
// if the second argument is a lightning address, then send to that address
err = bot.sendToLightningAddress(m, arg, amount)
if err != nil {
log.Errorln(err.Error())
return
}
return
}
}
// ASSUME INTERNAL SEND TO TELEGRAM USER
if err != nil || amount < 1 {
errmsg := fmt.Sprintf("[/send] Error: Send amount not valid.")
log.Errorln(errmsg)
// immediately delete if the amount is bullshit
NewMessage(m, WithDuration(0, bot.telegram))
bot.trySendMessage(m.Sender, helpSendUsage(sendValidAmountMessage))
return
}
// SEND COMMAND IS VALID
// check for memo in command
sendMemo := ""
if len(strings.Split(m.Text, " ")) > 3 {
sendMemo = strings.SplitN(m.Text, " ", 4)[3]
if len(sendMemo) > 200 {
sendMemo = sendMemo[:200]
sendMemo = sendMemo + "..."
}
}
if len(m.Entities) < 2 {
arg, err := getArgumentFromCommand(m.Text, 2)
if err != nil {
log.Errorln(err.Error())
return
}
arg = MarkdownEscape(arg)
NewMessage(m, WithDuration(0, bot.telegram))
errmsg := fmt.Sprintf("Error: User %s could not be found", arg)
bot.trySendMessage(m.Sender, helpSendUsage(fmt.Sprintf(sendUserNotFoundMessage, arg, bot.telegram.Me.Username)))
log.Errorln(errmsg)
return
}
if m.Entities[1].Type != "mention" {
arg, err := getArgumentFromCommand(m.Text, 2)
if err != nil {
NewMessage(m, WithDuration(0, bot.telegram))
log.Errorln(err.Error())
return
}
arg = MarkdownEscape(arg)
NewMessage(m, WithDuration(0, bot.telegram))
errmsg := fmt.Sprintf("Error: %s is not a user", arg)
bot.trySendMessage(m.Sender, fmt.Sprintf(sendIsNotAUsser, arg, bot.telegram.Me.Username))
log.Errorln(errmsg)
return
}
toUserStrMention := m.Text[m.Entities[1].Offset : m.Entities[1].Offset+m.Entities[1].Length]
toUserStrWithoutAt := strings.TrimPrefix(toUserStrMention, "@")
err = bot.parseCmdDonHandler(m)
if err == nil {
return
}
toUserDb := &lnbits.User{}
tx := bot.database.Where("telegram_username = ?", strings.ToLower(toUserStrWithoutAt)).First(toUserDb)
if tx.Error != nil || toUserDb.Wallet == nil || toUserDb.Initialized == false {
NewMessage(m, WithDuration(0, bot.telegram))
err = fmt.Errorf(sendUserHasNoWalletMessage, MarkdownEscape(toUserStrMention))
bot.trySendMessage(m.Sender, err.Error())
if tx.Error != nil {
log.Printf("[/send] Error: %v %v", err, tx.Error)
return
}
log.Printf("[/send] Error: %v", err)
return
}
// string that holds all information about the send payment
sendData := strconv.Itoa(toUserDb.Telegram.ID) + "|" + toUserStrWithoutAt + "|" +
strconv.Itoa(amount)
if len(sendMemo) > 0 {
sendData = sendData + "|" + sendMemo
}
// save the send data to the database
log.Debug(sendData)
user, err = GetUser(m.Sender, *bot)
if err != nil {
NewMessage(m, WithDuration(0, bot.telegram))
log.Printf("[/send] Error: %s\n", err.Error())
bot.trySendMessage(m.Sender, fmt.Sprint(errorTryLaterMessage))
return
}
user.StateKey = lnbits.UserStateConfirmSend
user.StateData = sendData
err = UpdateUserRecord(user, *bot)
if err != nil {
log.Printf("[UpdateUserRecord] User: %s Error: %s", GetUserStr(m.Sender), err.Error())
return
}
sendConfirmationMenu.Inline(sendConfirmationMenu.Row(btnSend, btnCancelSend))
confirmText := fmt.Sprintf(confirmSendInvoiceMessage, MarkdownEscape(toUserStrMention), amount)
if len(sendMemo) > 0 {
confirmText = confirmText + fmt.Sprintf(confirmSendAppendMemo, MarkdownEscape(sendMemo))
}
_, err = bot.telegram.Send(m.Sender, confirmText, sendConfirmationMenu)
if err != nil {
log.Error("[confirmSendHandler]" + err.Error())
return
}
}
// cancelPaymentHandler invoked when user clicked cancel on payment confirmation
func (bot *TipBot) cancelSendHandler(c *tb.Callback) {
// reset state immediately
user, err := GetUser(c.Sender, *bot)
if err != nil {
log.Errorln(err.Error())
return
}
user.ResetState()
err = UpdateUserRecord(user, *bot)
if err != nil {
log.Errorln(err.Error())
return
}
// delete the confirmation message
err = bot.telegram.Delete(c.Message)
if err != nil {
log.Errorln("[cancelSendHandler] " + err.Error())
}
// notify the user
_, err = bot.telegram.Send(c.Sender, sendCancelledMessage)
if err != nil {
log.WithField("message", sendCancelledMessage).WithField("user", c.Sender.ID).Printf("[Send] %s", err.Error())
return
}
}
// sendHandler invoked when user clicked send on payment confirmation
func (bot *TipBot) sendHandler(c *tb.Callback) {
// remove buttons from confirmation message
_, err := bot.telegram.Edit(c.Message, MarkdownEscape(c.Message.Text), &tb.ReplyMarkup{})
if err != nil {
log.Errorln("[sendHandler] " + err.Error())
}
// decode callback data
// log.Debug("[sendHandler] Callback: %s", c.Data)
user, err := GetUser(c.Sender, *bot)
if err != nil {
log.Printf("[GetUser] User: %d: %s", c.Sender.ID, err.Error())
return
}
if user.StateKey != lnbits.UserStateConfirmSend {
log.Errorf("[sendHandler] User StateKey does not match! User: %d: StateKey: %d", c.Sender.ID, user.StateKey)
return
}
// decode StateData in which we have information about the send payment
splits := strings.Split(user.StateData, "|")
if len(splits) < 3 {
log.Error("[sendHandler] Not enough arguments in callback data")
log.Errorf("user.StateData: %s", user.StateData)
return
}
toId, err := strconv.Atoi(splits[0])
if err != nil {
log.Errorln("[sendHandler] " + err.Error())
}
toUserStrWithoutAt := splits[1]
amount, err := strconv.Atoi(splits[2])
if err != nil {
log.Errorln("[sendHandler] " + err.Error())
}
sendMemo := ""
if len(splits) > 3 {
sendMemo = strings.Join(splits[3:], "|")
}
// reset state
user.ResetState()
err = UpdateUserRecord(user, *bot)
if err != nil {
log.Errorln(err.Error())
return
}
// we can now get the wallets of both users
to := &tb.User{ID: toId, Username: toUserStrWithoutAt}
from := c.Sender
toUserStrMd := GetUserStrMd(to)
fromUserStrMd := GetUserStrMd(from)
toUserStr := GetUserStr(to)
fromUserStr := GetUserStr(from)
transactionMemo := fmt.Sprintf("Send from %s to %s (%d sat).", fromUserStr, toUserStr, amount)
t := NewTransaction(bot, from, to, amount, TransactionType("send"))
t.Memo = transactionMemo
success, err := t.Send()
if !success || err != nil {
// NewMessage(m, WithDuration(0, bot.telegram))
bot.trySendMessage(c.Sender, fmt.Sprintf(sendErrorMessage, err))
errmsg := fmt.Sprintf("[/send] Error: Transaction failed. %s", err)
log.Errorln(errmsg)
return
}
bot.trySendMessage(from, fmt.Sprintf(sendSentMessage, amount, toUserStrMd))
bot.trySendMessage(to, fmt.Sprintf(sendReceivedMessage, fromUserStrMd, amount))
// send memo if it was present
if len(sendMemo) > 0 {
bot.trySendMessage(to, fmt.Sprintf("✉️ %s", MarkdownEscape(sendMemo)))
}
return
}

114
start.go Normal file
View file

@ -0,0 +1,114 @@
package main
import (
"errors"
"fmt"
"strconv"
log "github.com/sirupsen/logrus"
"github.com/LightningTipBot/LightningTipBot/internal/lnbits"
tb "gopkg.in/tucnak/telebot.v2"
"gorm.io/gorm"
)
const (
startSettingWalletMessage = "🧮 Setting up your wallet..."
startWalletCreatedMessage = "🧮 Wallet created."
startWalletReadyMessage = "✅ *Your wallet is ready.*"
startWalletErrorMessage = "🚫 Error initializing your wallet. Try again later."
startNoUsernameMessage = "☝️ It looks like you don't have a Telegram @username yet. That's ok, you don't need one to use this bot. However, to make better use of your wallet, set up a username in the Telegram settings. Then, enter /balance so the bot can update its record of you."
)
func (bot TipBot) startHandler(m *tb.Message) {
if !m.Private() {
return
}
// ATTENTION: DO NOT CALL ANY HANDLER BEFORE THE WALLET IS CREATED
// WILL RESULT IN AN ENDLESS LOOP OTHERWISE
// bot.helpHandler(m)
log.Printf("[/start] User: %s (%d)\n", m.Sender.Username, m.Sender.ID)
walletCreationMsg, err := bot.telegram.Send(m.Sender, startSettingWalletMessage)
err = bot.initWallet(m.Sender)
if err != nil {
log.Errorln(fmt.Sprintf("[startHandler] Error with initWallet: %s", err.Error()))
bot.tryEditMessage(walletCreationMsg, startWalletErrorMessage)
return
}
bot.tryDeleteMessage(walletCreationMsg)
bot.helpHandler(m)
bot.trySendMessage(m.Sender, startWalletReadyMessage)
bot.balanceHandler(m)
// send the user a warning about the fact that they need to set a username
if len(m.Sender.Username) == 0 {
bot.trySendMessage(m.Sender, startNoUsernameMessage, tb.NoPreview)
}
return
}
func (bot TipBot) initWallet(tguser *tb.User) error {
user, err := GetUser(tguser, bot)
if errors.Is(err, gorm.ErrRecordNotFound) {
u := &lnbits.User{Telegram: tguser}
err = bot.createWallet(u)
if err != nil {
return err
}
u.Initialized = true
err = UpdateUserRecord(u, bot)
if err != nil {
log.Errorln(fmt.Sprintf("[initWallet] error updating user: %s", err.Error()))
return err
}
} else if !user.Initialized {
// update all tip tooltips (with the "initialize me" message) that this user might have received before
tipTooltipInitializedHandler(user.Telegram, bot)
user.Initialized = true
err = UpdateUserRecord(user, bot)
if err != nil {
log.Errorln(fmt.Sprintf("[initWallet] error updating user: %s", err.Error()))
return err
}
} else if user.Initialized {
// wallet is already initialized
return nil
} else {
err = fmt.Errorf("could not initialize wallet")
return err
}
return nil
}
func (bot TipBot) createWallet(user *lnbits.User) error {
UserStr := GetUserStr(user.Telegram)
u, err := bot.client.CreateUserWithInitialWallet(strconv.Itoa(user.Telegram.ID),
fmt.Sprintf("%d (%s)", user.Telegram.ID, UserStr),
Configuration.Lnbits.AdminId,
UserStr)
if err != nil {
errormsg := fmt.Sprintf("[createWallet] Create wallet error: %s", err)
log.Errorln(errormsg)
return err
}
user.Wallet = &lnbits.Wallet{Client: bot.client}
user.ID = u.ID
user.Name = u.Name
wallet, err := user.Wallet.Wallets(*user)
if err != nil {
errormsg := fmt.Sprintf("[createWallet] Get wallet error: %s", err)
log.Errorln(errormsg)
return err
}
user.Wallet = &wallet[0]
user.Wallet.Client = bot.client
user.Initialized = false
err = UpdateUserRecord(user, bot)
if err != nil {
errormsg := fmt.Sprintf("[createWallet] Update user record error: %s", err)
log.Errorln(errormsg)
return err
}
return nil
}

44
telegram.go Normal file
View file

@ -0,0 +1,44 @@
package main
import (
log "github.com/sirupsen/logrus"
tb "gopkg.in/tucnak/telebot.v2"
)
func (bot TipBot) tryForwardMessage(to tb.Recipient, what tb.Editable, options ...interface{}) (msg *tb.Message) {
msg, err := bot.telegram.Forward(to, what, options...)
if err != nil {
log.Errorln(err.Error())
}
return
}
func (bot TipBot) trySendMessage(to tb.Recipient, what interface{}, options ...interface{}) (msg *tb.Message) {
msg, err := bot.telegram.Send(to, what, options...)
if err != nil {
log.Errorln(err.Error())
}
return
}
func (bot TipBot) tryReplyMessage(to *tb.Message, what interface{}, options ...interface{}) (msg *tb.Message) {
msg, err := bot.telegram.Reply(to, what, options...)
if err != nil {
log.Errorln(err.Error())
}
return
}
func (bot TipBot) tryEditMessage(to tb.Editable, what interface{}, options ...interface{}) (msg *tb.Message) {
msg, err := bot.telegram.Edit(to, what, options...)
if err != nil {
log.Errorln(err.Error())
}
return
}
func (bot TipBot) tryDeleteMessage(msg tb.Editable) {
err := bot.telegram.Delete(msg)
if err != nil {
log.Errorln(err.Error())
}
}

44
text.go Normal file
View file

@ -0,0 +1,44 @@
package main
import (
"strings"
"github.com/LightningTipBot/LightningTipBot/internal/lnbits"
log "github.com/sirupsen/logrus"
"github.com/LightningTipBot/LightningTipBot/pkg/lightning"
tb "gopkg.in/tucnak/telebot.v2"
)
const (
initWalletMessage = "You don't have a wallet yet. Enter */start*"
)
func (bot TipBot) anyTextHandler(m *tb.Message) {
log.Infof("[%s:%d %s:%d] %s", m.Chat.Title, m.Chat.ID, GetUserStr(m.Sender), m.Sender.ID, m.Text)
if m.Chat.Type != tb.ChatPrivate {
return
}
// check if user is in database, if not, initialize wallet
user, exists := bot.UserExists(m.Sender)
if !exists {
bot.startHandler(m)
return
}
// could be an invoice
anyText := strings.ToLower(m.Text)
if lightning.IsInvoice(anyText) {
m.Text = "/pay " + anyText
bot.confirmPaymentHandler(m)
return
}
// could be a LNURL
// var lnurlregex = regexp.MustCompile(`.*?((lnurl)([0-9]{1,}[a-z0-9]+){1})`)
if user.StateKey == lnbits.UserStateLNURLEnterAmount {
bot.lnurlEnterAmountHandler(m)
}
}

153
tip.go Normal file
View file

@ -0,0 +1,153 @@
package main
import (
"fmt"
"strings"
"time"
log "github.com/sirupsen/logrus"
tb "gopkg.in/tucnak/telebot.v2"
)
const (
tipDidYouReplyMessage = "Did you reply to a message to tip? To reply to any message, right-click -> Reply on your computer or swipe the message on your phone. If you want to send directly to another user, use the /send command."
tipInviteGroupMessage = " By the way, you can invite this bot to any group to start tipping there."
tipEnterAmountMessage = "Did you enter an amount?"
tipValidAmountMessage = "Did you enter a valid amount?"
tipYourselfMessage = "📖 You can't tip yourself."
tipSentMessage = "💸 %d sat sent to %s."
tipReceivedMessage = "🏅 %s has tipped you %d sat."
tipErrorMessage = "🚫 Transaction failed: %s"
tipUndefinedErrorMsg = "please try again later"
tipHelpText = "📖 Oops, that didn't work. %s\n\n" +
"*Usage:* `/tip <amount> [<memo>]`\n" +
"*Example:* `/tip 1000 Dank meme!`"
)
func helpTipUsage(errormsg string) string {
if len(errormsg) > 0 {
return fmt.Sprintf(tipHelpText, fmt.Sprintf("%s", errormsg))
} else {
return fmt.Sprintf(tipHelpText, "")
}
}
func TipCheckSyntax(m *tb.Message) (bool, string) {
arguments := strings.Split(m.Text, " ")
if len(arguments) < 2 {
return false, tipEnterAmountMessage
}
return true, ""
}
func (bot *TipBot) tipHandler(m *tb.Message) {
// delete the tip message after a few seconds, this is default behaviour
defer NewMessage(m, WithDuration(time.Second*time.Duration(Configuration.Telegram.MessageDisposeDuration), bot.telegram))
// check and print all commands
bot.anyTextHandler(m)
// only if message is a reply
if !m.IsReply() {
NewMessage(m, WithDuration(0, bot.telegram))
bot.trySendMessage(m.Sender, helpTipUsage(fmt.Sprintf(tipDidYouReplyMessage)))
bot.trySendMessage(m.Sender, tipInviteGroupMessage)
return
}
if ok, err := TipCheckSyntax(m); !ok {
bot.trySendMessage(m.Sender, helpTipUsage(err))
NewMessage(m, WithDuration(0, bot.telegram))
return
}
// get tip amount
amount, err := decodeAmountFromCommand(m.Text)
if err != nil || amount < 1 {
errmsg := fmt.Sprintf("[/tip] Error: Tip amount not valid.")
// immediately delete if the amount is bullshit
NewMessage(m, WithDuration(0, bot.telegram))
bot.trySendMessage(m.Sender, helpTipUsage(tipValidAmountMessage))
log.Errorln(errmsg)
return
}
err = bot.parseCmdDonHandler(m)
if err == nil {
return
}
// TIP COMMAND IS VALID
to := m.ReplyTo.Sender
from := m.Sender
if from.ID == to.ID {
NewMessage(m, WithDuration(0, bot.telegram))
bot.trySendMessage(m.Sender, tipYourselfMessage)
return
}
toUserStrMd := GetUserStrMd(m.ReplyTo.Sender)
fromUserStrMd := GetUserStrMd(from)
toUserStr := GetUserStr(m.ReplyTo.Sender)
fromUserStr := GetUserStr(from)
if _, exists := bot.UserExists(to); !exists {
log.Infof("[/tip] User %s has no wallet.", toUserStr)
err = bot.CreateWalletForTelegramUser(to)
if err != nil {
errmsg := fmt.Errorf("[/tip] Error: Could not create wallet for %s", toUserStr)
log.Errorln(errmsg)
return
}
}
// check for memo in command
tipMemo := ""
if len(strings.Split(m.Text, " ")) > 2 {
tipMemo = strings.SplitN(m.Text, " ", 3)[2]
if len(tipMemo) > 200 {
tipMemo = tipMemo[:200]
tipMemo = tipMemo + "..."
}
}
// todo: user new get username function to get userStrings
transactionMemo := fmt.Sprintf("Tip from %s to %s (%d sat).", fromUserStr, toUserStr, amount)
t := NewTransaction(bot, from, to, amount, TransactionType("tip"), TransactionChat(m.Chat))
t.Memo = transactionMemo
success, err := t.Send()
if !success {
NewMessage(m, WithDuration(0, bot.telegram))
if err != nil {
bot.trySendMessage(m.Sender, fmt.Sprintf(tipErrorMessage, err))
} else {
bot.trySendMessage(m.Sender, fmt.Sprintf(tipErrorMessage, tipUndefinedErrorMsg))
}
errMsg := fmt.Sprintf("[/tip] Transaction failed: %s", err)
log.Errorln(errMsg)
return
}
// update tooltip if necessary
messageHasTip := tipTooltipHandler(m, bot, amount, bot.UserInitializedWallet(to))
log.Infof("[tip] %d sat from %s to %s", amount, fromUserStr, toUserStr)
// notify users
_, err = bot.telegram.Send(from, fmt.Sprintf(tipSentMessage, amount, toUserStrMd))
if err != nil {
errmsg := fmt.Errorf("[/tip] Error: Send message to %s: %s", toUserStr, err)
log.Errorln(errmsg)
return
}
// forward tipped message to user once
if !messageHasTip {
bot.tryForwardMessage(to, m.ReplyTo, tb.Silent)
}
bot.trySendMessage(to, fmt.Sprintf(tipReceivedMessage, fromUserStrMd, amount))
if len(tipMemo) > 0 {
bot.trySendMessage(to, fmt.Sprintf("✉️ %s", MarkdownEscape(tipMemo)))
}
return
}

187
tooltip.go Normal file
View file

@ -0,0 +1,187 @@
package main
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"github.com/LightningTipBot/LightningTipBot/internal/runtime"
"github.com/LightningTipBot/LightningTipBot/internal/storage"
"github.com/tidwall/buntdb"
"github.com/tidwall/gjson"
log "github.com/sirupsen/logrus"
tb "gopkg.in/tucnak/telebot.v2"
)
type TipTooltip struct {
Message
TipAmount int `json:"tip_amount"`
Ntips int `json:"ntips"`
LastTip time.Time `json:"last_tip"`
Tippers []*tb.User `json:"tippers"`
}
const maxNamesInTipperMessage = 5
type TipTooltipOption func(m *TipTooltip)
func TipAmount(amount int) TipTooltipOption {
return func(m *TipTooltip) {
m.TipAmount = amount
}
}
func Tips(nTips int) TipTooltipOption {
return func(m *TipTooltip) {
m.LastTip = time.Now()
m.Ntips = nTips
}
}
func NewTipTooltip(m *tb.Message, opts ...TipTooltipOption) *TipTooltip {
tipTooltip := &TipTooltip{
Message: Message{
Message: m,
},
}
for _, opt := range opts {
opt(tipTooltip)
}
return tipTooltip
}
// getUpdatedTipTooltipMessage will return the full tip tool tip
func (ttt TipTooltip) getUpdatedTipTooltipMessage(botUserName string, notInitializedWallet bool) string {
tippersStr := getTippersString(ttt.Tippers)
tipToolTipMessage := fmt.Sprintf("🏅 %d sat", ttt.TipAmount)
if len(ttt.Tippers) > 1 {
tipToolTipMessage = fmt.Sprintf("%s (%d tips by %s)", tipToolTipMessage, ttt.Ntips, tippersStr)
} else {
tipToolTipMessage = fmt.Sprintf("%s (by %s)", tipToolTipMessage, tippersStr)
}
if notInitializedWallet {
tipToolTipMessage = tipToolTipMessage + fmt.Sprintf("\n🗑 Chat with %s to manage your wallet.", botUserName)
}
return tipToolTipMessage
}
// getTippersString joins all tippers username or telegram id's as mentions (@username or [inline mention of a user](tg://user?id=123456789))
func getTippersString(tippers []*tb.User) string {
var tippersStr string
for _, uniqueUser := range tippers {
userStr := GetUserStrMd(uniqueUser)
tippersStr += fmt.Sprintf("%s, ", userStr)
}
// get rid of the trailing comma
if len(tippersStr) > 2 {
tippersStr = tippersStr[:len(tippersStr)-2]
}
tippersSlice := strings.Split(tippersStr, " ")
// crop the message to the max length
if len(tippersSlice) > maxNamesInTipperMessage {
// tippersStr = tippersStr[:50]
tippersStr = strings.Join(tippersSlice[:maxNamesInTipperMessage], " ")
tippersStr = tippersStr + " ... and others"
}
return tippersStr
}
// tipTooltipExists checks if this tip is already known
func tipTooltipExists(replyToId int, bot *TipBot) (bool, *TipTooltip) {
message := NewTipTooltip(&tb.Message{ReplyTo: &tb.Message{ID: replyToId}})
err := bot.bunt.Get(message)
if err != nil {
return false, message
}
return true, message
}
// tipTooltipHandler function to update the tooltip below a tipped message. either updates or creates initial tip tool tip
func tipTooltipHandler(m *tb.Message, bot *TipBot, amount int, initializedWallet bool) (hasTip bool) {
// todo: this crashes if the tooltip message (maybe also the original tipped message) was deleted in the mean time!!! need to check for existence!
hasTip, ttt := tipTooltipExists(m.ReplyTo.ID, bot)
if hasTip {
// update the tooltip with new tippers
err := ttt.updateTooltip(bot, m.Sender, amount, !initializedWallet)
if err != nil {
log.Println(err)
// could not update the message (return false to )
return false
}
} else {
tipmsg := fmt.Sprintf("🏅 %d sat", amount)
userStr := GetUserStrMd(m.Sender)
tipmsg = fmt.Sprintf("%s (by %s)", tipmsg, userStr)
if !initializedWallet {
tipmsg = tipmsg + fmt.Sprintf("\n🗑 Chat with %s to manage your wallet.", GetUserStrMd(bot.telegram.Me))
}
msg, err := bot.telegram.Reply(m.ReplyTo, tipmsg, tb.Silent)
if err != nil {
print(err)
}
message := NewTipTooltip(msg, TipAmount(amount), Tips(1))
message.Tippers = appendUinqueUsersToSlice(message.Tippers, m.Sender)
runtime.IgnoreError(bot.bunt.Set(message))
}
// first call will return false, every following call will return true
return hasTip
}
// updateToolTip updates existing tip tool tip in telegram
func (ttt *TipTooltip) updateTooltip(bot *TipBot, user *tb.User, amount int, notInitializedWallet bool) error {
ttt.TipAmount += amount
ttt.Ntips += 1
ttt.Tippers = appendUinqueUsersToSlice(ttt.Tippers, user)
ttt.LastTip = time.Now()
err := ttt.editTooltip(bot, notInitializedWallet)
if err != nil {
return err
}
return bot.bunt.Set(ttt)
}
// tipTooltipInitializedHandler is called when the user initializes the wallet
func tipTooltipInitializedHandler(user *tb.User, bot TipBot) {
runtime.IgnoreError(bot.bunt.View(func(tx *buntdb.Tx) error {
err := tx.Ascend(storage.MessageOrderedByReplyToFrom, func(key, value string) bool {
replyToUserId := gjson.Get(value, storage.MessageOrderedByReplyToFrom)
if replyToUserId.String() == strconv.Itoa(user.ID) {
log.Infoln("loading persisted tip tool tip messages")
ttt := &TipTooltip{}
err := json.Unmarshal([]byte(value), ttt)
if err != nil {
log.Errorln(err)
}
err = ttt.editTooltip(&bot, false)
if err != nil {
log.Errorf("[tipTooltipInitializedHandler] could not edit tooltip: %s", err.Error())
}
}
return true
})
return err
}))
}
func (ttt TipTooltip) Key() string {
return strconv.Itoa(ttt.Message.Message.ReplyTo.ID)
}
// editTooltip updates the tooltip message with the new tip amount and tippers and edits it
func (ttt *TipTooltip) editTooltip(bot *TipBot, notInitializedWallet bool) error {
tipToolTip := ttt.getUpdatedTipTooltipMessage(GetUserStrMd(bot.telegram.Me), notInitializedWallet)
m, err := bot.telegram.Edit(ttt.Message.Message, tipToolTip)
if err != nil {
return err
}
ttt.Message.Message.Text = m.Text
return nil
}

92
tooltip_test.go Normal file
View file

@ -0,0 +1,92 @@
package main
import (
"testing"
"time"
tb "gopkg.in/tucnak/telebot.v2"
)
var (
tipper1 = &tb.User{Username: "username1", FirstName: "firstname1", ID: 1}
tipper2 = &tb.User{Username: "username2", FirstName: "firstname2", ID: 2}
tipper3 = &tb.User{Username: "username3", FirstName: "firstname3", ID: 3}
tipper4 = &tb.User{Username: "username4", FirstName: "firstname4", ID: 4}
tipper5 = &tb.User{Username: "username5", FirstName: "firstname5", ID: 5}
tipper6 = &tb.User{Username: "username6", FirstName: "firstname6", ID: 6}
)
func Test_getTippersString(t *testing.T) {
type args struct {
tippers []*tb.User
}
var tippers = make([]*tb.User, 0)
tests := []struct {
name string
args args
want string
}{
{name: "1", args: args{tippers: append(tippers, tipper1)}, want: "@username1"},
{name: "2", args: args{tippers: append(tippers, tipper1, tipper2)}, want: "@username1, @username2"},
{name: "3", args: args{tippers: append(tippers, tipper1, tipper2, tipper3)}, want: "@username1, @username2, @username3"},
{name: "4", args: args{tippers: append(tippers, tipper1, tipper2, tipper3, tipper4)}, want: "@username1, @username2, @username3, @username4"},
{name: "5", args: args{tippers: append(tippers, tipper1, tipper2, tipper3, tipper4, tipper5)}, want: "@username1, @username2, @username3, @username4, @username5"},
{name: "6", args: args{tippers: append(tippers, tipper1, tipper2, tipper3, tipper4, tipper5, tipper6)}, want: "@username1, @username2, @username3, @username4, @username5, ... and others"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := getTippersString(tt.args.tippers); got != tt.want {
t.Errorf("getTippersString() = %v, want %v", got, tt.want)
}
})
}
}
func TestMessage_getTooltipMessage(t *testing.T) {
type fields struct {
Message Message
TipAmount int
Ntips int
LastTip time.Time
Tippers []*tb.User
}
type args struct {
botUserName string
notInitializedWallet bool
}
var tippers = make([]*tb.User, 0)
tests := []struct {
name string
fields fields
args args
want string
}{
{
name: "1",
args: args{botUserName: "@test-bot", notInitializedWallet: true},
fields: fields{Message: Message{}, TipAmount: 10, Ntips: 1, Tippers: append(tippers, tipper1)},
want: "🏅 10 sat (by @username1)\n🗑 Chat with @test-bot to manage your wallet.",
},
{
name: "2",
args: args{botUserName: "@test-bot", notInitializedWallet: true},
fields: fields{Message: Message{}, TipAmount: 100, Ntips: 6, Tippers: append(tippers, tipper1, tipper2, tipper3, tipper4, tipper5, tipper6)},
want: "🏅 100 sat (6 tips by @username1, @username2, @username3, @username4, @username5, ... and others)\n🗑 Chat with @test-bot to manage your wallet.",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
x := TipTooltip{
Message: tt.fields.Message,
TipAmount: tt.fields.TipAmount,
Ntips: tt.fields.Ntips,
LastTip: tt.fields.LastTip,
Tippers: tt.fields.Tippers,
}
if got := x.getUpdatedTipTooltipMessage(tt.args.botUserName, tt.args.notInitializedWallet); got != tt.want {
t.Errorf("getUpdatedTipTooltipMessage() = %v, want %v", got, tt.want)
}
})
}
}

156
transaction.go Normal file
View file

@ -0,0 +1,156 @@
package main
import (
"fmt"
"time"
log "github.com/sirupsen/logrus"
"github.com/LightningTipBot/LightningTipBot/internal/lnbits"
tb "gopkg.in/tucnak/telebot.v2"
)
const (
balanceTooLowMessage = "Your balance is too low."
)
type Transaction struct {
ID uint `gorm:"primarykey"`
Time time.Time `json:"time"`
Bot *TipBot `gorm:"-"`
From *tb.User `json:"from" gorm:"-"`
To *tb.User `json:"to" gorm:"-"`
FromId int `json:"from_id" `
ToId int `json:"to_id" `
FromUser string `json:"from_user"`
ToUser string `json:"to_user"`
Type string `json:"type"`
Amount int `json:"amount"`
ChatID int64 `json:"chat_id"`
ChatName string `json:"chat_name"`
Memo string `json:"memo"`
Success bool `json:"success"`
FromWallet string `json:"from_wallet"`
ToWallet string `json:"to_wallet"`
FromLNbitsID string `json:"from_lnbits"`
ToLNbitsID string `json:"to_lnbits"`
}
type TransactionOption func(t *Transaction)
func TransactionChat(chat *tb.Chat) TransactionOption {
return func(t *Transaction) {
t.ChatID = chat.ID
t.ChatName = chat.Title
}
}
func TransactionType(transactionType string) TransactionOption {
return func(t *Transaction) {
t.Type = transactionType
}
}
func NewTransaction(bot *TipBot, from *tb.User, to *tb.User, amount int, opts ...TransactionOption) *Transaction {
t := &Transaction{
Bot: bot,
From: from,
To: to,
FromUser: GetUserStr(from),
ToUser: GetUserStr(to),
FromId: from.ID,
ToId: to.ID,
Amount: amount,
Memo: "Powered by @LightningTipBot",
Time: time.Now(),
Success: false,
}
for _, opt := range opts {
opt(t)
}
return t
}
func (t *Transaction) Send() (success bool, err error) {
// maybe remove comments, GTP-3 dreamed this up but it's nice:
// if t.From.ID == t.To.ID {
// err = fmt.Errorf("Can not send transaction to yourself.")
// return false, err
// }
// todo: remove this commend if the backend is back up
success, err = t.SendTransaction(t.Bot, t.From, t.To, t.Amount, t.Memo)
// success = true
if success {
t.Success = success
// TODO: call post-send methods
}
// save transaction to db
tx := t.Bot.logger.Save(t)
if tx.Error != nil {
errMsg := fmt.Sprintf("Error: Could not log transaction: %s", err)
log.Errorln(errMsg)
}
return success, err
}
func (t *Transaction) SendTransaction(bot *TipBot, from *tb.User, to *tb.User, amount int, memo string) (bool, error) {
fromUserStr := GetUserStr(from)
toUserStr := GetUserStr(to)
// from := m.Sender
fromUser, err := GetUser(from, *bot)
if err != nil {
errmsg := fmt.Sprintf("could not get user %s", fromUserStr)
log.Errorln(errmsg)
return false, err
}
t.FromWallet = fromUser.Wallet.ID
t.FromLNbitsID = fromUser.ID
// check if fromUser has balance
balance, err := bot.GetUserBalance(from)
if err != nil {
errmsg := fmt.Sprintf("could not get balance of user %s", fromUserStr)
log.Errorln(errmsg)
return false, err
}
// check if fromUser has balance
if balance < amount {
errmsg := fmt.Sprintf(balanceTooLowMessage)
log.Errorln("Balance of user %s too low", fromUserStr)
return false, fmt.Errorf(errmsg)
}
toUser, err := GetUser(to, *bot)
if err != nil {
errmsg := fmt.Sprintf("[SendTransaction] Error: ToUser %s not found: %s", toUserStr, err)
log.Errorln(errmsg)
return false, err
}
t.ToWallet = toUser.Wallet.ID
t.ToLNbitsID = toUser.ID
// generate invoice
invoice, err := toUser.Wallet.Invoice(
lnbits.InvoiceParams{
Amount: int64(amount),
Out: false,
Memo: memo},
*toUser.Wallet)
if err != nil {
errmsg := fmt.Sprintf("[SendTransaction] Error: Could not create invoice for user %s", toUserStr)
log.Errorln(errmsg)
return false, err
}
// pay invoice
_, err = fromUser.Wallet.Pay(lnbits.PaymentParams{Out: true, Bolt11: invoice.PaymentRequest}, *fromUser.Wallet)
if err != nil {
errmsg := fmt.Sprintf("[SendTransaction] Error: Payment from %s to %s of %d sat failed", fromUserStr, toUserStr, amount)
log.Errorln(errmsg)
return false, err
}
return true, err
}

134
users.go Normal file
View file

@ -0,0 +1,134 @@
package main
import (
"errors"
"fmt"
"strings"
"github.com/LightningTipBot/LightningTipBot/internal/lnbits"
log "github.com/sirupsen/logrus"
tb "gopkg.in/tucnak/telebot.v2"
"gorm.io/gorm"
)
var markdownV2Escapes = []string{"_", "[", "]", "(", ")", "~", "`", ">", "#", "+", "-", "=", "|", "{", "}", ".", "!"}
var markdownEscapes = []string{"_", "*", "`", "["}
func MarkdownV2Escape(s string) string {
for _, esc := range markdownV2Escapes {
if strings.Contains(s, esc) {
s = strings.Replace(s, esc, fmt.Sprintf("\\%s", esc), -1)
}
}
return s
}
func MarkdownEscape(s string) string {
for _, esc := range markdownEscapes {
if strings.Contains(s, esc) {
s = strings.Replace(s, esc, fmt.Sprintf("\\%s", esc), -1)
}
}
return s
}
func GetUserStr(user *tb.User) string {
userStr := fmt.Sprintf("@%s", user.Username)
// if user does not have a username
if len(userStr) < 2 && user.FirstName != "" {
userStr = fmt.Sprintf("%s", user.FirstName)
} else if len(userStr) < 2 {
userStr = fmt.Sprintf("%d", user.ID)
}
return userStr
}
func GetUserStrMd(user *tb.User) string {
userStr := fmt.Sprintf("@%s", user.Username)
// if user does not have a username
if len(userStr) < 2 && user.FirstName != "" {
userStr = fmt.Sprintf("[%s](tg://user?id=%d)", user.FirstName, user.ID)
return userStr
} else if len(userStr) < 2 {
userStr = fmt.Sprintf("[%d](tg://user?id=%d)", user.ID, user.ID)
return userStr
} else {
// escape only if user has a username
return MarkdownEscape(userStr)
}
}
func appendUinqueUsersToSlice(slice []*tb.User, i *tb.User) []*tb.User {
for _, ele := range slice {
if ele.ID == i.ID {
return slice
}
}
return append(slice, i)
}
func (bot *TipBot) UserInitializedWallet(user *tb.User) bool {
toUser, err := GetUser(user, *bot)
if err != nil {
return false
}
return toUser.Initialized
}
func (bot *TipBot) GetUserBalance(user *tb.User) (amount int, err error) {
// get user
fromUser, err := GetUser(user, *bot)
if err != nil {
return
}
wallet, err := fromUser.Wallet.Info(*fromUser.Wallet)
if err != nil {
errmsg := fmt.Sprintf("[GetUserBalance] Error: Couldn't fetch user %s's info from LNbits: %s", GetUserStr(user), err)
log.Errorln(errmsg)
return
}
fromUser.Wallet.Balance = wallet.Balance
err = UpdateUserRecord(fromUser, *bot)
if err != nil {
return
}
// msat to sat
amount = int(wallet.Balance) / 1000
log.Infof("[GetUserBalance] %s's balance: %d sat\n", GetUserStr(user), amount)
return
}
// copyLowercaseUser will create a coy user and cast username to lowercase.
func (bot *TipBot) copyLowercaseUser(u *tb.User) *tb.User {
userCopy := *u
userCopy.Username = strings.ToLower(u.Username)
return &userCopy
}
func (bot *TipBot) CreateWalletForTelegramUser(tbUser *tb.User) error {
userCopy := bot.copyLowercaseUser(tbUser)
user := &lnbits.User{Telegram: userCopy}
userStr := GetUserStr(tbUser)
log.Printf("[CreateWalletForTelegramUser] Creating wallet for user %s ... ", userStr)
err := bot.createWallet(user)
if err != nil {
errmsg := fmt.Sprintf("[CreateWalletForTelegramUser] Error: Could not create wallet for user %s", userStr)
log.Errorln(errmsg)
return err
}
tx := bot.database.Save(user)
if tx.Error != nil {
return tx.Error
}
log.Printf("[CreateWalletForTelegramUser] Wallet created for user %s. ", userStr)
return nil
}
func (bot *TipBot) UserExists(user *tb.User) (*lnbits.User, bool) {
lnbitUser, err := GetUser(user, *bot)
if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
return nil, false
}
return lnbitUser, true
}