diff --git a/.github/workflows/build-deploy-dev.yml b/.github/workflows/build-deploy-dev.yml new file mode 100644 index 00000000..828820d1 --- /dev/null +++ b/.github/workflows/build-deploy-dev.yml @@ -0,0 +1,173 @@ + +name: BitcoinDeepaBot Deployment - Dev + +on: + push: + branches: + - dev + +defaults: + run: + working-directory: . + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + env: + GO_VERSION: "1.21" + PROJECT_NAME: "⚡ BitcoinDeepaBot - Dev" + DEPLOY_PATH: "/root/projects/ceyloncash/bitcoin-deepa/dev/" + PROJECT_DIR: "bitcoin-deepa-bot" + SERVICE_NAME: "bitcoin_deepa_bot_dev.service" + BINARY_NAME: "BitcoinDeepaBot" + + steps: + - name: Notify Build Start + run: | + curl -s -X POST https://api.telegram.org/bot${{ secrets.DEV_ACTIONS_BOT_TOKEN }}/sendMessage \ + -d chat_id="${{ secrets.DEV_ACTIONS_GROUP_ID }}" \ + -d text=" + ${{ env.PROJECT_NAME }} Build Started 🚀 + 📁 Repository: ${{ github.repository }} + 👤 Initiator: ${{ github.actor }} + 🕒 Timestamp: ${{ github.event.head_commit.timestamp}} + 📝 Commit Message: ${{ github.event.head_commit.message }}" + + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Notify Build Complete + run: | + curl -s -X POST https://api.telegram.org/bot${{ secrets.DEV_ACTIONS_BOT_TOKEN }}/sendMessage \ + -d chat_id="${{ secrets.DEV_ACTIONS_GROUP_ID }}" \ + -d text=" + ${{ env.PROJECT_NAME }} Code Checked Out � + 📁 Repository: ${{ github.repository }} + 👤 Initiator: ${{ github.actor }} + 🕒 Timestamp: ${{ github.event.head_commit.timestamp }} + 📝 Commit Message: ${{ github.event.head_commit.message }}" + + # ✅ Check if .git directory exists remotely + - name: Check if Git Exists on Server + id: check_git + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USERNAME }} + key: ${{ secrets.SERVER_KEY }} + port: ${{ secrets.SERVER_PORT }} + script: | + if [ -d "${{ env.DEPLOY_PATH }}${{ env.PROJECT_DIR }}/.git" ]; then + echo "true" + else + echo "false" + fi + + # ✅ Capture the above output + - name: Set git_exists output + id: set_git_output + run: echo "::set-output name=git_exists::$(echo '${{ steps.check_git.outputs.stdout }}' | tr -d '\r')" + + - name: Copy Files to Server + if: steps.set_git_output.outputs.git_exists == 'false' + uses: appleboy/scp-action@master + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USERNAME }} + key: ${{ secrets.SERVER_KEY }} + port: ${{ secrets.SERVER_PORT }} + source: "." + target: "${{ env.DEPLOY_PATH }}${{ env.PROJECT_DIR }}" + strip_components: 1 + rm: true + + - name: Deploy Application + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USERNAME }} + key: ${{ secrets.SERVER_KEY }} + port: ${{ secrets.SERVER_PORT }} + script: | + cd ${{ env.DEPLOY_PATH }}${{ env.PROJECT_DIR }} + + # Handle git repository - pull if .git exists, otherwise fresh deployment + if [ -d ".git" ]; then + echo "Git repository exists, pulling latest changes..." + git fetch origin + git reset --hard origin/dev + git clean -fd + else + echo "Fresh deployment - no git repository found" + fi + + # Set Go environment + export PATH=$PATH:/usr/local/go/bin + export GOPATH=$HOME/go + export GOOS=linux + export GOARCH=amd64 + + # Build the application on the server + echo "Building application on server..." + go mod download + go mod verify + CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -a -installsuffix cgo -ldflags="-w -s" -buildvcs=false -o ${{ env.BINARY_NAME }} . + + # Make binary executable + chmod +x ${{ env.BINARY_NAME }} + + # Update service definition + sudo bash -c 'cat > "/etc/systemd/system/${{ env.SERVICE_NAME }}" <<- EOM + [Unit] + Description=${{ env.PROJECT_NAME }} + After=network.target + + [Service] + Type=simple + Restart=always + RestartSec=5s + WorkingDirectory=${{ env.DEPLOY_PATH }}${{ env.PROJECT_DIR }} + ExecStart=${{ env.DEPLOY_PATH }}${{ env.PROJECT_DIR }}/${{ env.BINARY_NAME }} + Environment=HOME=/root + User=root + + [Install] + WantedBy=multi-user.target + EOM' + + # Create data directory if it doesn't exist + mkdir -p data/dalle + + # Reload systemd and restart service + sudo systemctl daemon-reload + sudo systemctl enable ${{ env.SERVICE_NAME }} + sudo systemctl restart ${{ env.SERVICE_NAME }} + + # Wait a moment and check service status + sleep 3 + sudo systemctl status ${{ env.SERVICE_NAME }} --no-pager + + - name: Notify Deploy Complete + run: | + curl -s -X POST https://api.telegram.org/bot${{ secrets.DEV_ACTIONS_BOT_TOKEN }}/sendMessage \ + -d chat_id="${{ secrets.DEV_ACTIONS_GROUP_ID }}" \ + -d text=" + ${{ env.PROJECT_NAME }} Deploy Complete ✅ + 📁 Repository: ${{ github.repository }} + 👤 Initiator: ${{ github.actor }} + 🕒 Timestamp: ${{ github.event.head_commit.timestamp }} + 📝 Commit Message: ${{ github.event.head_commit.message }}" + + - name: Notify Deploy Failure + if: failure() + run: | + curl -s -X POST https://api.telegram.org/bot${{ secrets.DEV_ACTIONS_BOT_TOKEN }}/sendMessage \ + -d chat_id="${{ secrets.DEV_ACTIONS_GROUP_ID }}" \ + -d text=" + ❌ ${{ env.PROJECT_NAME }} Deploy Failed! + 📁 Repository: ${{ github.repository }} + 👤 Initiator: ${{ github.actor }} + 🕒 Timestamp: ${{ github.event.head_commit.timestamp }} + 📝 Commit Message: ${{ github.event.head_commit.message }} + + Please check the GitHub Actions logs for more details." diff --git a/.github/workflows/build-deploy.yml b/.github/workflows/build-deploy.yml new file mode 100644 index 00000000..a6e7240f --- /dev/null +++ b/.github/workflows/build-deploy.yml @@ -0,0 +1,173 @@ +name: BitcoinDeepaBot Deployment - Prod + +on: + push: + branches: + - main + +defaults: + run: + working-directory: . + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + env: + GO_VERSION: "1.21" + PROJECT_NAME: "⚡ BitcoinDeepaBot - Prod" + DEPLOY_PATH: "/root/projects/ceyloncash/bitcoin-deepa/prod/" + PROJECT_DIR: "bitcoin-deepa-bot" + SERVICE_NAME: "bitcoin_deepa_bot_prod.service" + BINARY_NAME: "BitcoinDeepaBot" + + steps: + - name: Notify Build Start + run: | + curl -s -X POST https://api.telegram.org/bot${{ secrets.DEV_ACTIONS_BOT_TOKEN }}/sendMessage \ + -d chat_id="${{ secrets.DEV_ACTIONS_GROUP_ID }}" \ + -d text=" + ${{ env.PROJECT_NAME }} Build Started 🚀 + 📁 Repository: ${{ github.repository }} + 👤 Initiator: ${{ github.actor }} + 🕒 Timestamp: ${{ github.event.head_commit.timestamp}} + 📝 Commit Message: ${{ github.event.head_commit.message }}" + + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Notify Build Complete + run: | + curl -s -X POST https://api.telegram.org/bot${{ secrets.DEV_ACTIONS_BOT_TOKEN }}/sendMessage \ + -d chat_id="${{ secrets.DEV_ACTIONS_GROUP_ID }}" \ + -d text=" + ${{ env.PROJECT_NAME }} Code Checked Out � + 📁 Repository: ${{ github.repository }} + 👤 Initiator: ${{ github.actor }} + 🕒 Timestamp: ${{ github.event.head_commit.timestamp }} + 📝 Commit Message: ${{ github.event.head_commit.message }}" + + # ✅ Check if .git directory exists remotely + - name: Check if Git Exists on Server + id: check_git + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USERNAME }} + key: ${{ secrets.SERVER_KEY }} + port: ${{ secrets.SERVER_PORT }} + script: | + if [ -d "${{ env.DEPLOY_PATH }}${{ env.PROJECT_DIR }}/.git" ]; then + echo "true" + else + echo "false" + fi + + # ✅ Capture the above output + - name: Set git_exists output + id: set_git_output + run: echo "::set-output name=git_exists::$(echo '${{ steps.check_git.outputs.stdout }}' | tr -d '\r')" + + + - name: Copy Files to Server + if: steps.set_git_output.outputs.git_exists == 'false' + uses: appleboy/scp-action@master + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USERNAME }} + key: ${{ secrets.SERVER_KEY }} + port: ${{ secrets.SERVER_PORT }} + source: "." + target: "${{ env.DEPLOY_PATH }}${{ env.PROJECT_DIR }}" + strip_components: 1 + rm: true + + - name: Deploy Application + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USERNAME }} + key: ${{ secrets.SERVER_KEY }} + port: ${{ secrets.SERVER_PORT }} + script: | + cd ${{ env.DEPLOY_PATH }}${{ env.PROJECT_DIR }} + + # Handle git repository - pull if .git exists, otherwise fresh deployment + if [ -d ".git" ]; then + echo "Git repository exists, pulling latest changes..." + git fetch origin + git reset --hard origin/main + git clean -fd + else + echo "Fresh deployment - no git repository found" + fi + + # Set Go environment + export PATH=$PATH:/usr/local/go/bin + export GOPATH=$HOME/go + export GOOS=linux + export GOARCH=amd64 + + # Build the application on the server + echo "Building application on server..." + go mod download + go mod verify + CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -a -installsuffix cgo -ldflags="-w -s" -o ${{ env.BINARY_NAME }} . + + # Make binary executable + chmod +x ${{ env.BINARY_NAME }} + + # Update service definition + sudo bash -c 'cat > "/etc/systemd/system/${{ env.SERVICE_NAME }}" <<- EOM + [Unit] + Description=${{ env.PROJECT_NAME }} + After=network.target + + [Service] + Type=simple + Restart=always + RestartSec=5s + WorkingDirectory=${{ env.DEPLOY_PATH }}${{ env.PROJECT_DIR }} + ExecStart=${{ env.DEPLOY_PATH }}${{ env.PROJECT_DIR }}/${{ env.BINARY_NAME }} + Environment=HOME=/root + User=root + + [Install] + WantedBy=multi-user.target + EOM' + + # Create data directory if it doesn't exist + mkdir -p data/dalle + + # Reload systemd and restart service + sudo systemctl daemon-reload + sudo systemctl enable ${{ env.SERVICE_NAME }} + sudo systemctl restart ${{ env.SERVICE_NAME }} + + # Wait a moment and check service status + sleep 3 + sudo systemctl status ${{ env.SERVICE_NAME }} --no-pager + + - name: Notify Deploy Complete + run: | + curl -s -X POST https://api.telegram.org/bot${{ secrets.DEV_ACTIONS_BOT_TOKEN }}/sendMessage \ + -d chat_id="${{ secrets.DEV_ACTIONS_GROUP_ID }}" \ + -d text=" + ${{ env.PROJECT_NAME }} Deploy Complete ✅ + 📁 Repository: ${{ github.repository }} + 👤 Initiator: ${{ github.actor }} + 🕒 Timestamp: ${{ github.event.head_commit.timestamp }} + 📝 Commit Message: ${{ github.event.head_commit.message }}" + + - name: Notify Deploy Failure + if: failure() + run: | + curl -s -X POST https://api.telegram.org/bot${{ secrets.DEV_ACTIONS_BOT_TOKEN }}/sendMessage \ + -d chat_id="${{ secrets.DEV_ACTIONS_GROUP_ID }}" \ + -d text=" + ❌ ${{ env.PROJECT_NAME }} Deploy Failed! + 📁 Repository: ${{ github.repository }} + 👤 Initiator: ${{ github.actor }} + 🕒 Timestamp: ${{ github.event.head_commit.timestamp }} + 📝 Commit Message: ${{ github.event.head_commit.message }} + + Please check the GitHub Actions logs for more details." diff --git a/.github/workflows/release-dev.yml b/.github/workflows/release-dev.yml new file mode 100644 index 00000000..63f6a5fc --- /dev/null +++ b/.github/workflows/release-dev.yml @@ -0,0 +1,88 @@ +name: Dev Release + +on: + push: + branches: + - dev + +permissions: + contents: write + +jobs: + release-dev: + runs-on: ubuntu-latest + env: + BOT_TOKEN: ${{ secrets.MAIN_BOT_TOKEN }} + CHAT_ID: ${{ vars.DEV_RELEASE_CHAT_IDS }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: '1.21' + + - name: Get short SHA + id: vars + run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT + + - name: Calculate next version + id: calc_version + uses: mathieudutour/github-tag-action@v6.1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + dry_run: true + tag_prefix: "v" + + - name: Tag dev build + id: tag_version + uses: mathieudutour/github-tag-action@v6.1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + custom_tag: v${{ steps.calc_version.outputs.major }}.${{ steps.calc_version.outputs.minor }}.${{ steps.calc_version.outputs.patch }}-${{ steps.vars.outputs.sha_short }} + tag_prefix: "" + + - name: Create local tag + run: git tag ${{ steps.tag_version.outputs.new_tag }} + + - name: Run GoReleaser (dev) + uses: goreleaser/goreleaser-action@v5 + with: + distribution: goreleaser + version: latest + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Mark as prerelease + run: gh release edit ${{ steps.tag_version.outputs.new_tag }} --prerelease + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Notify Telegram (Dev) + if: success() + run: | + IFS=',' read -ra TARGETS <<< "${{ env.CHAT_ID }}" + for target in "${TARGETS[@]}"; do + target=$(echo "$target" | xargs) + if [[ -z "$target" ]]; then + continue + fi + if [[ "$target" == *":"* ]]; then + chat_id=${target%%:*} + thread_id=${target#*:} + curl -s -X POST https://api.telegram.org/bot${{ env.BOT_TOKEN }}/sendMessage \ + -d chat_id="$chat_id" \ + -d message_thread_id="$thread_id" \ + -d text="🧪 *New Dev Build Published!*%0A%0A*Tag:* ${{ steps.tag_version.outputs.new_tag }}%0A*Commit:* ${{ github.sha }}%0A*Author:* ${{ github.actor }}%0A%0A[View Release](https://github.com/${{ github.repository }}/releases/tag/${{ steps.tag_version.outputs.new_tag }})" \ + -d parse_mode="Markdown" + else + curl -s -X POST https://api.telegram.org/bot${{ env.BOT_TOKEN }}/sendMessage \ + -d chat_id="$target" \ + -d text="🧪 *New Dev Build Published!*%0A%0A*Tag:* ${{ steps.tag_version.outputs.new_tag }}%0A*Commit:* ${{ github.sha }}%0A*Author:* ${{ github.actor }}%0A%0A[View Release](https://github.com/${{ github.repository }}/releases/tag/${{ steps.tag_version.outputs.new_tag }})" \ + -d parse_mode="Markdown" + fi + done diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..284575e8 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,81 @@ +name: Release + +on: + pull_request: + types: [closed] + branches: + - main + +permissions: + contents: write + +jobs: + release: + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + env: + BOT_TOKEN: ${{ secrets.MAIN_BOT_TOKEN }} + CHAT_ID: ${{ vars.RELEASE_CHAT_IDS }} + PR_TITLE: ${{ github.event.pull_request.title }} + PR_BODY: ${{ github.event.pull_request.body }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: '1.21' + + - name: Bump version and push tag + id: tag_version + uses: mathieudutour/github-tag-action@v6.1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + default_bump: patch + + - name: Create local tag + run: git tag ${{ steps.tag_version.outputs.new_tag }} + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v5 + with: + distribution: goreleaser + version: latest + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Notify Telegram Success + if: success() + run: | + # Escape PR body for JSON + ESCAPED_BODY=$(echo "$PR_BODY" | jq -R -s '.') + # Remove enclosing quotes from jq output + ESCAPED_BODY="${ESCAPED_BODY:1:-1}" + # Truncate if too long (Telegram limit is 4096 chars, so 3000 is safe) + if [ ${#ESCAPED_BODY} -gt 3000 ]; then + ESCAPED_BODY="${ESCAPED_BODY:0:3000}..." + fi + + IFS=',' read -ra TARGETS <<< "${{ env.CHAT_ID }}" + for target in "${TARGETS[@]}"; do + target=$(echo "$target" | xargs) + if [[ "$target" == *":"* ]]; then + chat_id=${target%%:*} + thread_id=${target#*:} + curl -s -X POST https://api.telegram.org/bot${{ env.BOT_TOKEN }}/sendMessage \ + -d chat_id="$chat_id" \ + -d message_thread_id="$thread_id" \ + -d text="🚀 *New Release Published!*%0A%0A*Version:* ${{ steps.tag_version.outputs.new_tag }}%0A*Title:* ${PR_TITLE}%0A*Author:* ${{ github.actor }}%0A%0A*Description:*%0A${ESCAPED_BODY}%0A%0A🔗 *Release Link:*%0Ahttps://github.com/${{ github.repository }}/releases/tag/${{ steps.tag_version.outputs.new_tag }}" \ + -d parse_mode="Markdown" + else + curl -s -X POST https://api.telegram.org/bot${{ env.BOT_TOKEN }}/sendMessage \ + -d chat_id="$target" \ + -d text="🚀 *New Release Published!*%0A%0A*Version:* ${{ steps.tag_version.outputs.new_tag }}%0A*Title:* ${PR_TITLE}%0A*Author:* ${{ github.actor }}%0A%0A*Description:*%0A${ESCAPED_BODY}%0A%0A🔗 *Release Link:*%0Ahttps://github.com/${{ github.repository }}/releases/tag/${{ steps.tag_version.outputs.new_tag }}" \ + -d parse_mode="Markdown" + fi + done + diff --git a/.gitignore b/.gitignore index 39203b7c..246754b2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,15 @@ config.yaml config.yaml* data/* -!data/.placeholder \ No newline at end of file +!data/.placeholder +LightningTipBot +LightningTipBot.exe +BitcoinDeepaBot +test_pay_api.sh + +.DS_Store +.claude/settings.local.json +analytics_export.py +analytics_requirements.txt +ANALYTICS_API.md +ANALYTICS_QUICKSTART.md diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 00000000..07222a8b --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,24 @@ +project_name: BitcoinDeepaBot +builds: + - env: + - CGO_ENABLED=1 + goos: + - linux + goarch: + - amd64 + flags: + - -a + - -installsuffix + - cgo + ldflags: + - -s -w +checksum: + name_template: 'checksums.txt' +snapshot: + name_template: "{{ .Tag }}-next" +changelog: + sort: asc + filters: + exclude: + - '^docs:' + - '^test:' diff --git a/README.md b/README.md index 7b7faf2f..dc659307 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,10 @@ -

- logo -

+![image](https://github.com/user-attachments/assets/11535aed-3285-4546-9389-b78924092199) -# @LightningTipBot 🏅 +# [@BitcoinDeepaBot](https://t.me/BitcoinDeepaBot) 🏅 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. +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 [@](https://t.me/BitcoinDeepaBot) and invite it into your group chat. ## Setting up the bot @@ -15,16 +13,16 @@ This repository contains everything you need to set up and run your own tip bot. To build the bot from source, clone the repository and compile the source code. ``` -git clone https://github.com/LightningTipBot/LightningTipBot.git -cd LightningTipBot +git clone https://github.com/BitcoinDeepaBot/BitcoinDeepaBot.git +cd BitcoinDeepaBot go build . -cp config.yaml-example config.yaml +cp config.yaml.example config.yaml ``` After the configuration (see below), start it using the command ``` -./LightningTipBot +./BitcoinDeepaBot ``` ### Configuration @@ -50,16 +48,49 @@ You can either use your own LNbits instance (recommended) or create an account a How to set up a lnbits wallet and the User Manager extension.

-#### More configuration +## API Send Endpoint 🚀 + +The bot now includes a new HTTP API endpoint `/api/send` for programmatic Bitcoin Lightning payments. This feature allows authorized applications to send payments on behalf of whitelisted accounts. + +### Features + +- **Secure**: Only whitelisted sender accounts can use the API +- **Network Restricted**: Limited to internal network access (10.0.0.0/24) +- **Flexible Recipients**: Send to any Telegram username or wallet ID +- **Transaction Logging**: All API payments are logged for audit purposes + +### Quick Start + +```bash +curl -X POST http://10.0.0.5:8080/api/send \ + -H "Content-Type: application/json" \ + -d '{ + "from": "BiccoindeepaDSA", + "to": "recipient_user", + "amount": 1000, + "memo": "API payment" + }' +``` + +### 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). +By default, only these accounts are whitelisted to send payments: +- `@BiccoindeepaDSA` +- `@CeycubeBank` + +To modify the whitelist, edit `WhitelistedFromAccounts` in `internal/api/send_config.go`. + +### Documentation + +For complete API documentation and examples, see [API_SEND_DOCUMENTATION.md](API_SEND_DOCUMENTATION.md). + +### Testing + +Use the included test script to verify the API functionality: + +```bash +python test_api_send.py --test-suite +``` ## Features @@ -75,6 +106,8 @@ You can either use your own LNbits instance (recommended) or create an account a /advanced 🤖 Read the advanced help. /basics 📚 More info. /donate ❤️ Donate to the project: /donate +/lkrsats 💱 Convert LKR to sats: /lkrsats +/convert 💱 Convert sats to fiat: /convert ``` #### Advanced commands @@ -86,14 +119,14 @@ You can either use your own LNbits instance (recommended) or create an account a ### Inline commands ``` -send 💸 Send sats to chat: @LightningTipBot send [] +send 💸 Send sats to chat: @BitcoinDeepaBot send [] ``` 📖 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. ### Inline send -You can use inline commands to send payments to anyone who can read your messages, even inside private chats and group chat in which the bot isn't part of. For that, you need to trigger an [inline command](https://core.telegram.org/bots/inline). Here is how it works: Enter the name of the bot (`@LightningTipBot`) and the command you want to trigger (`send 13 Hi friend!`). When the result pops up above the text field, click it to send it to the chat. Do not press enter. +You can use inline commands to send payments to anyone who can read your messages, even inside private chats and group chat in which the bot isn't part of. For that, you need to trigger an [inline command](https://core.telegram.org/bots/inline). Here is how it works: Enter the name of the bot (`@BitcoinDeepaBot`) and the command you want to trigger (`send 13 Hi friend!`). When the result pops up above the text field, click it to send it to the chat. Do not press enter.

Send sats inside any chat, including private conversations and groups. @@ -116,7 +149,7 @@ Users can send and receive via . For this to work, you need to set the `lnurl_pu Every user has a [Lightning Address](https://lightningaddress.com/) a la `username@host.com` with which they can send to via `/send ` and receive from other wallets. -### Link to BlueWallet or Zap +### Link to BlueWallet or Zeus 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). @@ -136,6 +169,10 @@ To pay a Lightning invoice, you can snap a photo of a QR code and send it direct 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. +## Full Guide to Install and run on a VPS + +A complete guide to install and run BitcoinDeepaBot + LNBITS (on docker with PostgreSQL) on the same VPS with an external LND funding source has been prepared by Massimo Musumeci (@massmux) and it is available: [BitcoinDeepaBot full install](https://www.massmux.com/howto-complete-lightningtipbot-lnbits-setup-vps/) + ## Made with - [LNbits](https://github.com/lnbits/lnbits) – Free and open-source lightning-network wallet/accounts system. diff --git a/amounts.go b/amounts.go deleted file mode 100644 index 53074944..00000000 --- a/amounts.go +++ /dev/null @@ -1,38 +0,0 @@ -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, err -} - -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 -} diff --git a/balance.go b/balance.go deleted file mode 100644 index 17c7f7a4..00000000 --- a/balance.go +++ /dev/null @@ -1,46 +0,0 @@ -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 -} diff --git a/bot.go b/bot.go deleted file mode 100644 index 7c4abb19..00000000 --- a/bot.go +++ /dev/null @@ -1,155 +0,0 @@ -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, - "/faucet": bot.faucetHandler, - "/zapfhahn": bot.faucetHandler, - "/kraan": bot.faucetHandler, - 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(&btnAcceptInlineSend, bot.acceptInlineSendHandler) - bot.telegram.Handle(&btnCancelInlineSend, bot.cancelInlineSendHandler) - - // button for inline receive - bot.telegram.Handle(&btnAcceptInlineReceive, bot.acceptInlineReceiveHandler) - bot.telegram.Handle(&btnCancelInlineReceive, bot.cancelInlineReceiveHandler) - - // // button for inline faucet - bot.telegram.Handle(&btnAcceptInlineFaucet, bot.accpetInlineFaucetHandler) - bot.telegram.Handle(&btnCancelInlineFaucet, bot.cancelInlineFaucetHandler) - - }) -} - -// 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() -} diff --git a/botfather-setcommands.txt b/botfather-setcommands.txt index b0b4a6b8..8afb8420 100644 --- a/botfather-setcommands.txt +++ b/botfather-setcommands.txt @@ -7,10 +7,13 @@ help - Read the help. balance - Check balance. +transactions - List transactions tip - Reply to a message to tip: /tip 50 send - Send funds to a user: /send 100 @LightningTipBot invoice - Receive with Lightning: /invoice 1000 pay - Pay with Lightning: /pay lnbc10n1ps... donate - Donate: /donate 1000 -faucet - Create a faucet: /faucet 2100 21 -advanced - Advanced help \ No newline at end of file +faucet - Create a faucet: /faucet 2100 21 +advanced - Advanced help +lkrsats - Convert LKR to satoshis +convert - Convert sats to fiat values diff --git a/config.go b/config.go deleted file mode 100644 index 5f2ca0a3..00000000 --- a/config.go +++ /dev/null @@ -1,81 +0,0 @@ -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 + "/" - } - } -} diff --git a/config.yaml.example b/config.yaml.example index f7c6c507..dcbb2d50 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -1,17 +1,71 @@ bot: + socks_proxy: + host: 127.0.0.1:9996 + username: test + password: username + tor_proxy: + host: 127.0.0.1:9050 http_proxy: "" - lnurl_public_host_name: "mylnurl.com" - lnurl_server: "https://mylnurl.com" + lnurl_public_host_name: "https://mylnurl.com" + lnurl_server: "http://127.0.0.1:5454" # or http://0.0.0.0:5454 depending on your configuration + lnurl_image: true + admin_api_host: localhost:6060 telegram: message_dispose_duration: 10 api_key: "1234" + log_group_id: -1001234567890 # Telegram group chat ID where errors will be logged + error_thread_id: 0 # Optional: specific thread ID for error messages (0 for main chat) lnbits: url: "http://127.0.0.1:5000" admin_key: "1234" admin_id: "1234" - webhook_server: "http://0.0.0.0:5588" + webhook_server: "http://0.0.0.0:5588" # Local webhook server address + webhook_public_url: "https://yourdomain.com/webhook" # Public URL for webhooks (for reverse proxy) lnbits_public_url: "link.mylnurl.com" database: db_path: "data/bot.db" buntdb_path: "data/bunt.db" - transactions_path: "data/transactions.db" \ No newline at end of file + transactions_path: "data/transactions.db" + shop_buntdb_path: "data/shop.db" + groupsdb_path: "data/groups.db" +generate: + open_ai_bearer_token: "token_here" + dalle_key: "asd" + dalle_price: 1000 + worker: 2 +nostr: + private_key: "hex private key here" + +# API Configuration +api: + # Analytics API - HMAC authenticated endpoints for data export + # Generate secrets with: openssl rand -hex 32 + analytics: + enabled: true + timestamp_tolerance: 300 # seconds (5 minutes) + api_keys: + data-team: + name: "Data Team" + hmac_secret: "your-analytics-hmac-secret-here" + # Add more keys for different consumers: + # dashboard: + # name: "Internal Dashboard" + # hmac_secret: "another-secure-secret-here" + + # Send API - wallet-based HMAC authenticated endpoints + send: + enabled: true + internal_network: "10.0.0.0/24" + max_amount: 1000000 + min_amount: 1 + admin_approval_threshold: 100000 + max_memo_length: 280 + rate_limit: 60 + whitelisted_wallets: + BiccoindeepaDSA: + username: "BiccoindeepaDSA" + hmac_secret: "your-unique-bitcoindeepa-secret-here" + CeycubeBank: + username: "CeycubeBank" + hmac_secret: "your-unique-ceycube-secret-here" + timestamp_tolerance: 300 \ No newline at end of file diff --git a/data/dalle/.placeholder b/data/dalle/.placeholder new file mode 100644 index 00000000..284cc653 --- /dev/null +++ b/data/dalle/.placeholder @@ -0,0 +1 @@ +this is where dalle images are stored diff --git a/database.go b/database.go deleted file mode 100644 index 13f6392d..00000000 --- a/database.go +++ /dev/null @@ -1,75 +0,0 @@ -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 -} diff --git a/donate.go b/donate.go deleted file mode 100644 index b0e5987f..00000000 --- a/donate.go +++ /dev/null @@ -1,162 +0,0 @@ -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 `\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 -} diff --git a/go.mod b/go.mod index e21cd11a..200021b4 100644 --- a/go.mod +++ b/go.mod @@ -1,21 +1,108 @@ module github.com/LightningTipBot/LightningTipBot -go 1.15 +go 1.18 require ( - github.com/fiatjaf/go-lnurl v1.4.0 + github.com/BurntSushi/toml v0.3.1 + github.com/PuerkitoBio/goquery v1.8.0 + github.com/btcsuite/btcd/btcec/v2 v2.2.0 + github.com/eko/gocache v1.2.0 + github.com/fiatjaf/go-lnurl v1.11.3-0.20220819192234-5c5819dd0aa7 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/nbd-wtf/go-nostr v0.13.0 + github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 + github.com/nicksnyder/go-i18n/v2 v2.1.2 + github.com/orcaman/concurrent-map v1.0.0 + github.com/patrickmn/go-cache v2.1.0+incompatible + github.com/prometheus/common v0.26.0 + github.com/r3labs/sse v0.0.0-20210224172625-26fe804710bc + github.com/satori/go.uuid v1.2.0 + github.com/sirupsen/logrus v1.7.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 + github.com/tidwall/buntdb v1.2.7 + github.com/tidwall/gjson v1.12.1 + github.com/tidwall/sjson v1.2.4 + golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f + golang.org/x/text v0.3.7 + golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba + gopkg.in/lightningtipbot/telebot.v3 v3.0.0-20220828121412-0dea11ecc6dd gorm.io/driver/sqlite v1.1.4 gorm.io/gorm v1.21.12 ) + +require ( + github.com/SaveTheRbtz/generic-sync-map-go v0.0.0-20220414055132-a37292614db8 // indirect + github.com/XiaoMi/pegasus-go-client v0.0.0-20210427083443-f3b6b08bc4c2 // indirect + github.com/aead/siphash v1.0.1 // indirect + github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 // indirect + github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d // indirect + github.com/andybalholm/cascadia v1.3.1 // indirect + github.com/bradfitz/gomemcache v0.0.0-20190913173617-a41fca850d0b // indirect + github.com/btcsuite/btcd v0.23.1 // indirect + github.com/btcsuite/btcd/btcutil v1.1.1 // indirect + github.com/btcsuite/btcd/btcutil/psbt v1.1.4 // indirect + github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1 // indirect + github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f // indirect + github.com/btcsuite/btcwallet v0.15.1 // indirect + github.com/btcsuite/btcwallet/wallet/txauthor v1.2.3 // indirect + github.com/btcsuite/btcwallet/wallet/txrules v1.2.0 // indirect + github.com/btcsuite/btcwallet/wallet/txsizes v1.1.0 // indirect + github.com/btcsuite/btcwallet/walletdb v1.4.0 // indirect + github.com/btcsuite/btcwallet/wtxmgr v1.5.0 // indirect + github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd // indirect + github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792 // indirect + github.com/cenkalti/backoff/v4 v4.1.0 // indirect + github.com/cespare/xxhash/v2 v2.1.1 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/decred/dcrd/crypto/blake256 v1.0.0 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect + github.com/decred/dcrd/lru v1.0.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/go-errors/errors v1.0.1 // indirect + github.com/go-redis/redis/v8 v8.8.2 // indirect + github.com/gorilla/websocket v1.4.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.2 // indirect + github.com/kkdai/bstream v1.0.0 // indirect + github.com/lightninglabs/gozmq v0.0.0-20191113021534-d20a764486bf // indirect + github.com/lightninglabs/neutrino v0.14.2 // indirect + github.com/lightningnetwork/lnd v0.15.0-beta // indirect + github.com/lightningnetwork/lnd/clock v1.1.0 // indirect + github.com/lightningnetwork/lnd/queue v1.1.0 // indirect + github.com/lightningnetwork/lnd/ticker v1.1.0 // indirect + github.com/lightningnetwork/lnd/tlv v1.0.3 // indirect + github.com/lightningnetwork/lnd/tor v1.0.1 // indirect + github.com/mattn/go-sqlite3 v1.14.5 // indirect + github.com/miekg/dns v1.1.43 // indirect + github.com/nbd-wtf/ln-decodepay v1.5.1 // indirect + github.com/pegasus-kv/thrift v0.13.0 // indirect + github.com/spf13/cast v1.5.0 // indirect + github.com/tidwall/btree v0.6.1 // indirect + github.com/tidwall/grect v0.1.3 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.0 // indirect + github.com/tidwall/rtred v0.1.2 // indirect + github.com/tidwall/tinyqueue v0.1.1 // indirect + github.com/valyala/fastjson v1.6.3 // indirect + go.opentelemetry.io/otel v0.20.0 // indirect + go.opentelemetry.io/otel/metric v0.20.0 // indirect + go.opentelemetry.io/otel/trace v0.20.0 // indirect + golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 // indirect + golang.org/x/exp v0.0.0-20221106115401-f9659909a136 // indirect + golang.org/x/sync v0.0.0-20220819030929-7fc1605a5dde // indirect + golang.org/x/sys v0.1.0 // indirect + golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 // indirect + golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect + gopkg.in/alecthomas/kingpin.v2 v2.2.6 // indirect + gopkg.in/cenkalti/backoff.v1 v1.1.0 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect + gopkg.in/tomb.v2 v2.0.0-20161208151619-d5d1b5820637 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + k8s.io/apimachinery v0.0.0-20191123233150-4c4803ed55e3 // indirect +) + +// replace gopkg.in/lightningtipbot/telebot.v3 => ../telebot diff --git a/go.sum b/go.sum index e3fad934..6544db96 100644 --- a/go.sum +++ b/go.sum @@ -1,107 +1,493 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= +cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= +cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= +cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= +cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= +cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= +cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= +cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= +cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= +cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= +cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= +cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= +cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= +cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= +cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= +cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= +cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= +cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= +cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= +cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= +cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= +cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk= +cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= +cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= +cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= +cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= +cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= +cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= +cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= +cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= +cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= 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/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0= +github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= +github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ= 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/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U= +github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI= +github.com/PuerkitoBio/purell v1.0.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/urlesc v0.0.0-20160726150825-5bd2802263f2/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= +github.com/SaveTheRbtz/generic-sync-map-go v0.0.0-20220414055132-a37292614db8 h1:Xa6tp8DPDhdV+k23uiTC/GrAYOe4IdyJVKtob4KW3GA= +github.com/SaveTheRbtz/generic-sync-map-go v0.0.0-20220414055132-a37292614db8/go.mod h1:ihkm1viTbO/LOsgdGoFPBSvzqvx7ibvkMzYp3CgtHik= +github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo= +github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI= +github.com/VividCortex/gohistogram v1.0.0/go.mod h1:Pf5mBqqDxYaXu3hDrrU+w6nw50o/4+TcAqDqk/vUH7g= +github.com/XiaoMi/pegasus-go-client v0.0.0-20210427083443-f3b6b08bc4c2 h1:pami0oPhVosjOu/qRHepRmdjD6hGILF7DBr+qQZeP10= +github.com/XiaoMi/pegasus-go-client v0.0.0-20210427083443-f3b6b08bc4c2/go.mod h1:jNIx5ykW1MroBuaTja9+VpglmaJOUzezumfhLlER3oY= github.com/Yawning/aez v0.0.0-20180114000226-4dad034d9db2/go.mod h1:9pIqrY6SXNL8vjRQE5Hd/OL5GyK/9MrGUWs87z/eFfk= +github.com/Yawning/aez v0.0.0-20211027044916-e49e68abd344/go.mod h1:9pIqrY6SXNL8vjRQE5Hd/OL5GyK/9MrGUWs87z/eFfk= +github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da h1:KjTM2ks9d14ZYCvmHS9iAKVt9AyzRSqNU1qabPih5BY= 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/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 h1:JYp7IbQjafoB+tBA3gMyHYHrpOtNuDiK/uB5uXxq5wM= +github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d h1:UQZhZ2O0vMHr2cI+DC1Mbh0TJxzA3RcLoMsFw+aXw7E= +github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= +github.com/allegro/bigcache/v2 v2.2.5 h1:mRc8r6GQjuJsmSKQNPsR5jQVXc8IJ1xsW5YXUYMLfqI= +github.com/allegro/bigcache/v2 v2.2.5/go.mod h1:FppZsIO+IZk7gCuj5FiIDHGygD9xvWQcqg1uIPMb6tY= +github.com/andybalholm/brotli v1.0.0/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y= +github.com/andybalholm/brotli v1.0.3 h1:fpcw+r1N1h0Poc1F/pHbW40cUm/lMEQslZtCkBQ0UnM= +github.com/andybalholm/brotli v1.0.3/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c= +github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA= +github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= +github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ= +github.com/apache/thrift v0.13.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ= +github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= +github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= +github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/aryann/difflib v0.0.0-20170710044230-e206f873d14a/go.mod h1:DAHtR1m6lCRdSC2Tm3DSWRPvIPr6xNKyeHdqDQSQT+A= github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= +github.com/aws/aws-lambda-go v1.13.3/go.mod h1:4UKl9IzQMoD+QF79YdCuzCwp8VbmG4VAQwij/eHl5CU= +github.com/aws/aws-sdk-go v1.27.0/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= +github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g= +github.com/benbjohnson/clock v1.0.3/go.mod h1:bGMdMPoPVvcYyt1gHDf4J2KE153Yf9BuiUKYMaxlTDM= 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/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= +github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84= +github.com/bradfitz/gomemcache v0.0.0-20190913173617-a41fca850d0b h1:L/QXpzIa3pOvUGt1D1lA5KjYhPBAN/3iWdP7xeFS9F0= +github.com/bradfitz/gomemcache v0.0.0-20190913173617-a41fca850d0b/go.mod h1:H0wQNHz2YrLsuXOZozoeDmnHXkNCRmMW0gwFWDfEZDA= 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/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M= +github.com/btcsuite/btcd v0.22.0-beta.0.20220204213055-eaf0459ff879/go.mod h1:osu7EoKiL36UThEgzYPqdRaxeo0NU8VoXqgcnwpey0g= +github.com/btcsuite/btcd v0.22.0-beta.0.20220207191057-4dc4ff7963b4/go.mod h1:7alexyj/lHlOtr2PJK7L/+HDJZpcGDn/pAU98r7DY08= +github.com/btcsuite/btcd v0.22.0-beta.0.20220316175102-8d5c75c28923/go.mod h1:taIcYprAW2g6Z9S0gGUxyR+zDwimyDMK5ePOX+iJ2ds= +github.com/btcsuite/btcd v0.23.0/go.mod h1:0QJIIN1wwIXF/3G/m87gIwGniDMDQqjVn4SZgnFpsYY= +github.com/btcsuite/btcd v0.23.1 h1:IB8cVQcC2X5mHbnfirLG5IZnkWYNTPlLZVrxUYSotbE= +github.com/btcsuite/btcd v0.23.1/go.mod h1:0QJIIN1wwIXF/3G/m87gIwGniDMDQqjVn4SZgnFpsYY= +github.com/btcsuite/btcd/btcec/v2 v2.1.0/go.mod h1:2VzYrv4Gm4apmbVVsSq5bqf1Ec8v56E48Vt0Y/umPgA= +github.com/btcsuite/btcd/btcec/v2 v2.1.1/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE= +github.com/btcsuite/btcd/btcec/v2 v2.1.3/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE= +github.com/btcsuite/btcd/btcec/v2 v2.2.0 h1:fzn1qaOt32TuLjFlkzYSsBC35Q3KUjT1SwPxiMSCF5k= +github.com/btcsuite/btcd/btcec/v2 v2.2.0/go.mod h1:U7MHm051Al6XmscBQ0BoNydpOTsFAn707034b5nY8zU= +github.com/btcsuite/btcd/btcutil v1.0.0/go.mod h1:Uoxwv0pqYWhD//tfTiipkxNfdhG9UrLwaeswfjfdF0A= +github.com/btcsuite/btcd/btcutil v1.1.0/go.mod h1:5OapHB7A2hBBWLm48mmw4MOHNJCcUBTwmWH/0Jn8VHE= +github.com/btcsuite/btcd/btcutil v1.1.1 h1:hDcDaXiP0uEzR8Biqo2weECKqEw0uHDZ9ixIWevVQqY= +github.com/btcsuite/btcd/btcutil v1.1.1/go.mod h1:nbKlBMNm9FGsdvKvu0essceubPiAcI57pYBNnsLAa34= +github.com/btcsuite/btcd/btcutil/psbt v1.1.4 h1:Edx4AfBn+YPam2KP5AobDitulGp4r1Oibm8oruzkMdI= +github.com/btcsuite/btcd/btcutil/psbt v1.1.4/go.mod h1:9AyU6EQVJ9Iw9zPyNT1lcdHd6cnEZdno5wLu5FY74os= +github.com/btcsuite/btcd/chaincfg/chainhash v1.0.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= +github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1 h1:q0rUy8C/TYNBQS1+CGKw68tLOFYSNEs0TFnxxnS9+4U= +github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= 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 v0.15.1 h1:SKfh/l2Bgz9sJwHZvfiVbZ8Pl3N/8fFcWWXzsAPz9GU= +github.com/btcsuite/btcwallet v0.15.1/go.mod h1:7OFsQ8ypiRwmr67hE0z98uXgJgXGAihE79jCib9x6ag= 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/txauthor v1.2.3 h1:M2yr5UlULvpqtxUqpMxTME/pA92Z9cpqeyvAFk9lAg0= +github.com/btcsuite/btcwallet/wallet/txauthor v1.2.3/go.mod h1:T2xSiKGpUkSLCh68aF+FMXmKK9mFqNdHl9VaqOr+JjU= 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/txrules v1.2.0 h1:BtEN5Empw62/RVnZ0VcJaVtVlBijnLlJY+dwjAye2Bg= +github.com/btcsuite/btcwallet/wallet/txrules v1.2.0/go.mod h1:AtkqiL7ccKWxuLYtZm8Bu8G6q82w4yIZdgq6riy60z0= github.com/btcsuite/btcwallet/wallet/txsizes v1.0.0/go.mod h1:pauEU8UuMFiThe5PB3EO+gO5kx87Me5NvdQDsTuq6cs= +github.com/btcsuite/btcwallet/wallet/txsizes v1.1.0 h1:wZnOolEAeNOHzHTnznw/wQv+j35ftCIokNrnOTOU5o8= +github.com/btcsuite/btcwallet/wallet/txsizes v1.1.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/walletdb v1.3.5/go.mod h1:oJDxAEUHVtnmIIBaa22wSBPTVcs6hUp5NKWmI8xDwwU= +github.com/btcsuite/btcwallet/walletdb v1.3.6-0.20210803004036-eebed51155ec/go.mod h1:oJDxAEUHVtnmIIBaa22wSBPTVcs6hUp5NKWmI8xDwwU= +github.com/btcsuite/btcwallet/walletdb v1.4.0 h1:/C5JRF+dTuE2CNMCO/or5N8epsrhmSM4710uBQoYPTQ= +github.com/btcsuite/btcwallet/walletdb v1.4.0/go.mod h1:oJDxAEUHVtnmIIBaa22wSBPTVcs6hUp5NKWmI8xDwwU= 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/btcwallet/wtxmgr v1.5.0 h1:WO0KyN4l6H3JWnlFxfGR7r3gDnlGT7W2cL8vl6av4SU= +github.com/btcsuite/btcwallet/wtxmgr v1.5.0/go.mod h1:TQVDhFxseiGtZwEPvLgtfyxuNUDsIdaJdshvWzR0HJ4= 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 h1:J9B4L7e3oqhXOcm+2IuNApwzQec85lE+QaikUcCs+dk= github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= +github.com/casbin/casbin/v2 v2.1.2/go.mod h1:YcPU1XXisHhLzuxH9coDNf2FbKpjGlbCg3n9yuLkIJQ= +github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= +github.com/cenkalti/backoff/v4 v4.1.0 h1:c8LkOFQTzuO0WBM/ae5HdGQuZPfPxp7lqBRwQRm4fSc= +github.com/cenkalti/backoff/v4 v4.1.0/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/certifi/gocertifi v0.0.0-20191021191039-0944d244cd40/go.mod h1:sGbDF6GwGcLpkNXPUTkMRoywsNa/ol15pxFe6ERfguA= +github.com/certifi/gocertifi v0.0.0-20200922220541-2c3bb06c6054/go.mod h1:sGbDF6GwGcLpkNXPUTkMRoywsNa/ol15pxFe6ERfguA= +github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= +github.com/cespare/xxhash/v2 v2.1.1 h1:6MnRN8NT7+YBpUIWxHtefFZOKTAPgGjpQSxqLNn0+qY= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/clbanning/x2j v0.0.0-20191024224557-825249438eec/go.mod h1:jMjuTZXRI4dUb/I5gc9Hdhagfvm9+RyrPryS/auMzxE= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/coreos/bbolt v1.3.3 h1:n6AiVyVRKQFNb6mJlwESEvvLoDyiTzXX7ORAUlkeBdY= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= +github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa/go.mod h1:zn76sxSg3SzpJ0PPJaLDCu+Bu0Lg3sKTORVIj19EIF8= +github.com/cockroachdb/datadriven v0.0.0-20200714090401-bf6692d28da5/go.mod h1:h6jFvWxBdQXxjopDMZyH2UVceIRfR84bdzbkoKrsWNo= +github.com/cockroachdb/errors v1.2.4/go.mod h1:rQD95gz6FARkaKkQXUksEje/d9a6wBJoCr5oaCLELYA= +github.com/cockroachdb/logtags v0.0.0-20190617123548-eb05cc24525f/go.mod h1:i/u985jwjWRlyHXQbwatDASoW0RMlZ/3i9yJHE2xLkI= +github.com/codahale/hdrhistogram v0.0.0-20161010025455-3a0bb77429bd/go.mod h1:sE/e/2PUdi/liOCUjSTXgM1o87ZssimdTWN964YiIeI= +github.com/coocood/freecache v1.1.1 h1:uukNF7QKCZEdZ9gAV7WQzvh0SbjwdMF6m3x3rxEkaPc= +github.com/coocood/freecache v1.1.1/go.mod h1:OKrEjkGVoxZhyWAJoeFi5BMLUJm2Tit0kpGkIr7NGYY= +github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= github.com/coreos/bbolt v1.3.3/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= +github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-semver v0.3.0 h1:wkHLiw0WNATZnSG7epLsujiMCgPAc9xhjJ4tgnAxmfM= +github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-systemd v0.0.0-20180511133405-39ca1b05acc7/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f h1:JOrtw2xFKzlg+cbHpyrpLDmnN1HqhBfnX7WDiW7eG2c= +github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/go-systemd/v22 v22.3.2 h1:D9/bQk5vlXQFZ6Kwuu6zaiXJ9oTPe68++AzAJc1DzSI= +github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/coreos/pkg v0.0.0-20160727233714-3ac0863d7acf/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= +github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v0.0.0-20151105211317-5215b55f46b2/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/decred/dcrd/crypto/blake256 v1.0.0 h1:/8DMNYp9SGi5f0w7uCm6d6M4OU2rGFK09Y2A4Xv7EE0= +github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs= +github.com/decred/dcrd/lru v1.0.0 h1:Kbsb1SFDsIlaupWPwsPp+dkxiBY1frcS07PCPgotKz8= +github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218= +github.com/dgraph-io/ristretto v0.0.3 h1:jh22xisGBjrEVnRZ1DVTpBVQm0Xndu8sMl0CWDzSIBI= +github.com/dgraph-io/ristretto v0.0.3/go.mod h1:KPxhHT9ZxKefz+PCeOGsrHpl1qZ7i70dGTu2u+Ahh6E= +github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= 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/docker/spdystream v0.0.0-20160310174837-449fdfce4d96/go.mod h1:Qh8CwZgvJUkLughtfhJv5dyTYa91l1fOUCrgjqmcifM= +github.com/dsnet/compress v0.0.1 h1:PlZu0n3Tuv04TzpfPbrnI0HW/YwodEXDS+oPKahKF0Q= +github.com/dsnet/compress v0.0.1/go.mod h1:Aw8dCMJ7RioblQeTqt88akK31OvO8Dhf5JflhBbQEHo= +github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY= +github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= +github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/dvyukov/go-fuzz v0.0.0-20210602112143-b1f3d6f4ef4e h1:qTP1telKJHlToHlwPQNmVg4yfMDMHe4Z3SYmzkrvA2M= +github.com/dvyukov/go-fuzz v0.0.0-20210602112143-b1f3d6f4ef4e/go.mod h1:11Gm+ccJnvAhCNLlf5+cS9KjtbaD5I5zaZpFMsTHWTw= +github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs= +github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU= +github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= +github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M= +github.com/eko/gocache v1.2.0 h1:SCtTs65qMXjhdtu62yHPCQuzdMkQjP+fQmkNrVutkRw= +github.com/eko/gocache v1.2.0/go.mod h1:6u8/2bnr+nOf87mRXWS710rqNNZUECF4CGsPNnsoJ78= +github.com/elazarl/goproxy v0.0.0-20170405201442-c4fc26588b6e/go.mod h1:/Zj4wYkgs4iZTTu3o/KG3Itv/qCCa8VVMlb3i9OVuzc= +github.com/emicklei/go-restful v0.0.0-20170410110728-ff4f55a20633/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs= +github.com/envoyproxy/go-control-plane v0.6.9/go.mod h1:SBwIajubJHhxtWwsL9s8ss4safvEdbitLhGGK48rN6g= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= +github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/evanphx/json-patch v4.2.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/fergusstrange/embedded-postgres v1.10.0 h1:YnwF6xAQYmKLAXXrrRx4rHDLih47YJwVPvg8jeKfdNg= +github.com/fergusstrange/embedded-postgres v1.10.0/go.mod h1:a008U8/Rws5FtIOTGYDYa7beVWsT3qVKyqExqYYjL+c= +github.com/fiatjaf/go-lnurl v1.11.3-0.20220819192234-5c5819dd0aa7 h1:0m0ph1FcZY7p89OpaXOUs8N44GhP7gE/98l+eWCNSkQ= +github.com/fiatjaf/go-lnurl v1.11.3-0.20220819192234-5c5819dd0aa7/go.mod h1:KJfs14iAY3gCgt/3T6fxfBvPhU67OfIp7PSrBg/v/R8= 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/form3tech-oss/jwt-go v3.2.3+incompatible h1:7ZaBxOI7TMoYBfyA3cQHErNNyAWIKUMIwqxEtgHOs5c= +github.com/form3tech-oss/jwt-go v3.2.3+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k= +github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= +github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= +github.com/franela/goblin v0.0.0-20200105215937-c9ffbefa60db/go.mod h1:7dvUGVsVBjqR7JHJk0brhHOZYGmfBYOrK0ZhYMEtBr4= +github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8/go.mod h1:ZhphrRTfi2rbfLwlschooIH4+wKKDR4Pdxhh+TRoA20= 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/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ= +github.com/ghodss/yaml v0.0.0-20150909031657-73d445a93680/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= 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-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/kit v0.10.0/go.mod h1:xUsJbQ/Fp4kEt7AFgCuvyX4a71u8h9jB8tj/ORgOZ7o= +github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= 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-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= +github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas= github.com/go-openapi/errors v0.19.2/go.mod h1:qX0BLWsyaKfvhluLejVpVNwNRdXZhEbTA4kxxpKBC94= +github.com/go-openapi/jsonpointer v0.0.0-20160704185906-46af16f9f7b1/go.mod h1:+35s3my2LFTysnkMfxsJBAMHj/DoqoB9knIWoYG/Vk0= +github.com/go-openapi/jsonreference v0.0.0-20160704190145-13c6e3589ad9/go.mod h1:W3Z9FmVs9qj+KR4zFKmDPGiLdk1D9Rlm7cyMvf57TTg= +github.com/go-openapi/spec v0.0.0-20160808142527-6aced65f8501/go.mod h1:J8+jY1nAiCcj+friV/PDoE1/3eeccG9LYBs0tYvLOWc= github.com/go-openapi/strfmt v0.19.5/go.mod h1:eftuHTlB/dI8Uq8JJOyRlieZf+WkkxUuk0dgdHXr2Qk= +github.com/go-openapi/swag v0.0.0-20160704191624-1d0bd113de87/go.mod h1:DXUve3Dpr1UfpPtxFw+EFuQ41HhCWZfha5jSVRG7C7I= +github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= +github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= +github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4= +github.com/go-redis/redis/v8 v8.8.2 h1:O/NcHqobw7SEptA0yA6up6spZVFtwE06SXM8rgLtsP8= +github.com/go-redis/redis/v8 v8.8.2/go.mod h1:F7resOH5Kdug49Otu24RjHWwgK7u9AmtqWMnCV1iP5Y= +github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/goccy/go-yaml v1.9.5/go.mod h1:U/jl18uSupI5rdI2jmuCswEA2htH9eXfferR3KfscvA= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/gogo/googleapis v1.1.0/go.mod h1:gf4bu3Q80BeJ6H1S1vYPm8/ELATdvryBaNFGgqEef3s= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= +github.com/gogo/protobuf v1.2.2-0.20190723190241-65acae22fc9d/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= +github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/glog v0.0.0-20210429001901-424d2337a529/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 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/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= +github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= +github.com/golang/mock v1.5.0 h1:jlYHihg//f7RRwuPfptm04yp4s7O6Kw8EZiVYIGcH0g= +github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8= +github.com/golang/protobuf v0.0.0-20161109072736-4bd1920723d7/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 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/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM= +github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.1 h1:gK4Kx5IaGY9CD5sPJ36FHiBJ6ZXl0kilRiiCj+jdYp4= +github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9uaxA= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= 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/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= +github.com/google/gofuzz v0.0.0-20161122191042-44d81051d367/go.mod h1:HP5RmnzzSNb993RKQDq4+1A4ia9nllfqcQFTQJedwGI= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= +github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= +github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= +github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= +github.com/googleapis/gnostic v0.0.0-20170729233727-0c5108395e2d/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= +github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= +github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= +github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= +github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de4/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= +github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 h1:+9834+KizmvFV7pXQGSXQTsaWhq2GjuNUt0aUU0YBYw= +github.com/grpc-ecosystem/go-grpc-middleware v1.3.0/go.mod h1:z0ButlSOZa5vEBq9m2m2hlwIgKw+rp3sdCBRoJY+30Y= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 h1:Ovs26xHkKqVztRpIrF/92BcuyuQ/YW4NSIpoGtfXNho= 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/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= +github.com/grpc-ecosystem/grpc-gateway v1.9.5/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= +github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo= +github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.5.0/go.mod h1:r1hZAcvfFXuYmcKyCJI9wlyOPIZUJl6FCB8Cpca/NLE= +github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q= +github.com/hashicorp/consul/api v1.3.0/go.mod h1:MmDNSzIMUjNpY/mQ398R4bk2FnqQLoPndWW5VkKPlCE= +github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= +github.com/hashicorp/consul/sdk v0.3.0/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= +github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= +github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU= +github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= +github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= +github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= +github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= +github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= +github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/hudl/fargo v1.3.0/go.mod h1:y3CKSmjA+wD2gak7sUSXTAoopbhU08POFhmITJgmKTg= +github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= 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/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/influxdata/influxdb1-client v0.0.0-20191209144304-8bf82d3c094d/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo= +github.com/jackc/chunkreader v1.0.0 h1:4s39bBR8ByfqH+DKm8rQA3E1LHZWB9XWcrz8fqaZbe0= +github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo= +github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= +github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8= +github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= +github.com/jackc/pgconn v0.0.0-20190420214824-7e0022ef6ba3/go.mod h1:jkELnwuX+w9qN5YIfX0fl88Ehu4XC3keFuOJJk9pcnA= +github.com/jackc/pgconn v0.0.0-20190824142844-760dd75542eb/go.mod h1:lLjNuW/+OfW9/pnVKPazfWOgNfH2aPem8YQ7ilXGvJE= +github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsUgOEh9hBm+xYTstcNHg7UPMVJqRfQxq4s= +github.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o= +github.com/jackc/pgconn v1.9.0/go.mod h1:YctiPyvzfU11JFxoXokUOOKQXQmDMoJL9vJzHH8/2JY= +github.com/jackc/pgconn v1.9.1-0.20210724152538-d89c8390a530/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI= +github.com/jackc/pgconn v1.10.0 h1:4EYhlDVEMsJ30nNj0mmgwIUXoq7e9sMJrVC2ED6QlCU= +github.com/jackc/pgconn v1.10.0/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI= +github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE= +github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8= +github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE= +github.com/jackc/pgmock v0.0.0-20201204152224-4fe30f7445fd/go.mod h1:hrBW0Enj2AZTNpt/7Y5rr2xe/9Mn757Wtb2xeBzPv2c= +github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgproto3 v1.1.0 h1:FYYE4yRw+AgI8wXIinMlNjBbp/UitDJwfj5LqqewP1A= +github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78= +github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA= +github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg= +github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM= +github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM= +github.com/jackc/pgproto3/v2 v2.0.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= +github.com/jackc/pgproto3/v2 v2.1.1 h1:7PQ/4gLoqnl87ZxL7xjO0DR5gYuviDCZxQJsUlFW1eI= +github.com/jackc/pgproto3/v2 v2.1.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= +github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b h1:C8S2+VttkHFdOOCXJe+YGfa4vHYwlt4Zx+IVXQ97jYg= +github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E= +github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg= +github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc= +github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw= +github.com/jackc/pgtype v1.8.1-0.20210724151600-32e20a603178/go.mod h1:C516IlIV9NKqfsMCXTdChteoXmwgUceqaLfjg2e3NlM= +github.com/jackc/pgtype v1.8.1 h1:9k0IXtdJXHJbyAWQgbWr1lU+MEhPXZz6RIXxfR5oxXs= +github.com/jackc/pgtype v1.8.1/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4= +github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y= +github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM= +github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc= +github.com/jackc/pgx/v4 v4.12.1-0.20210724153913-640aa07df17c/go.mod h1:1QD0+tgSXP7iUjYm9C1NxKhny7lq6ee99u/z+IHFcgs= +github.com/jackc/pgx/v4 v4.13.0 h1:JCjhT5vmhMAf/YwBHLvrBn4OGdIQBiFG6ym8Zmdx570= +github.com/jackc/pgx/v4 v4.13.0/go.mod h1:9P4X524sErlaxj0XSGZk7s+LD0eOyu1ZDUrrpznYDF0= +github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= +github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= +github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= 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/jedib0t/go-pretty/v6 v6.2.7/go.mod h1:FMkOpgGD3EZ91cW8g/96RfxoV7bdeJyzXPYgz1L1ln0= github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jessevdk/go-flags v1.4.0 h1:4IU2WS7AumrZ/40jfhf4QVDMsQwqA7VEHozFRrGARJA= 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= @@ -110,200 +496,940 @@ github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkr 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/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= +github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= +github.com/jonboulle/clockwork v0.2.2 h1:UOGuzwb1PwsrDAObMuhUnj0p5ULPj8V/xJ7Kx9qUBdQ= +github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= +github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= 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/json-iterator/go v0.0.0-20180612202835-f2b4162afba3/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.8/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.11 h1:uVUAXhF2To8cbw/3xN3pxj6kk7TYKs98NIrTqPlMWAQ= +github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/juju/ansiterm v0.0.0-20160907234532-b99631de12cf/go.mod h1:UJSiEoRfvx3hP73CvoARgeLjaIOjybY9vj8PUPPFGeU= +github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a/go.mod h1:UJSiEoRfvx3hP73CvoARgeLjaIOjybY9vj8PUPPFGeU= github.com/juju/clock v0.0.0-20190205081909-9c5c9712527c/go.mod h1:nD0vlnrUjcjJhqN5WuCWZyzfd5AHZAC9/ajvbSx69xA= +github.com/juju/cmd v0.0.0-20171107070456-e74f39857ca0/go.mod h1:yWJQHl73rdSX4DHVKGqkAip+huBslxRwS8m9CrOLq18= +github.com/juju/collections v0.0.0-20200605021417-0d0ec82b7271/go.mod h1:5XgO71dV1JClcOJE+4dzdn4HrI5LiyKd7PlVG6eZYhY= +github.com/juju/errors v0.0.0-20150916125642-1b5e39b83d18/go.mod h1:W54LbzXuIE0boCoNJfwqpmkKJ1O4TCTZMetAt6jGk7Q= github.com/juju/errors v0.0.0-20190806202954-0232dcc7464d/go.mod h1:W54LbzXuIE0boCoNJfwqpmkKJ1O4TCTZMetAt6jGk7Q= +github.com/juju/errors v0.0.0-20200330140219-3fe23663418f/go.mod h1:W54LbzXuIE0boCoNJfwqpmkKJ1O4TCTZMetAt6jGk7Q= +github.com/juju/errors v0.0.0-20220203013757-bd733f3c86b9/go.mod h1:TRm7EVGA3mQOqSVcBySRY7a9Y1/gyVhh/WTCnc5sD4U= +github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= +github.com/juju/httpprof v0.0.0-20141217160036-14bf14c30767/go.mod h1:+MaLYz4PumRkkyHYeXJ2G5g5cIW0sli2bOfpmbaMV/g= +github.com/juju/loggo v0.0.0-20170605014607-8232ab8918d9/go.mod h1:vgyd7OREkbtVEN/8IXZe5Ooef3LQePvuBm9UWj6ZL8U= github.com/juju/loggo v0.0.0-20190526231331-6e530bcce5d8/go.mod h1:vgyd7OREkbtVEN/8IXZe5Ooef3LQePvuBm9UWj6ZL8U= +github.com/juju/loggo v0.0.0-20200526014432-9ce3a2e09b5e/go.mod h1:vgyd7OREkbtVEN/8IXZe5Ooef3LQePvuBm9UWj6ZL8U= +github.com/juju/loggo v0.0.0-20210728185423-eebad3a902c4/go.mod h1:NIXFioti1SmKAlKNuUwbMenNdef59IF52+ZzuOmHYkg= +github.com/juju/mgo/v2 v2.0.0-20210302023703-70d5d206e208/go.mod h1:0OChplkvPTZ174D2FYZXg4IB9hbEwyHkD+zT+/eK+Fg= +github.com/juju/mutex v0.0.0-20171110020013-1fe2a4bf0a3a/go.mod h1:Y3oOzHH8CQ0Ppt0oCKJ2JFO81/EsWenH5AEqigLH+yY= +github.com/juju/retry v0.0.0-20151029024821-62c620325291/go.mod h1:OohPQGsr4pnxwD5YljhQ+TZnuVRYpa5irjugL1Yuif4= github.com/juju/retry v0.0.0-20180821225755-9058e192b216/go.mod h1:OohPQGsr4pnxwD5YljhQ+TZnuVRYpa5irjugL1Yuif4= +github.com/juju/testing v0.0.0-20180402130637-44801989f0f7/go.mod h1:63prj8cnj0tU0S9OHjGJn+b1h0ZghCndfnbQolrYTwA= github.com/juju/testing v0.0.0-20190723135506-ce30eb24acd2/go.mod h1:63prj8cnj0tU0S9OHjGJn+b1h0ZghCndfnbQolrYTwA= +github.com/juju/testing v0.0.0-20210302031854-2c7ee8570c07/go.mod h1:7lxZW0B50+xdGFkvhAb8bwAGt6IU87JB1H9w4t8MNVM= +github.com/juju/testing v0.0.0-20220202055744-1ad0816210a6/go.mod h1:QgWc2UdIPJ8t3rnvv95tFNOsQDfpXYEZDbP281o3b2c= +github.com/juju/testing v0.0.0-20220203020004-a0ff61f03494/go.mod h1:rUquetT0ALL48LHZhyRGvjjBH8xZaZ8dFClulKK5wK4= +github.com/juju/utils v0.0.0-20180424094159-2000ea4ff043/go.mod h1:6/KLg8Wz/y2KVGWEpkK9vMNGkOnu4k/cqs8Z1fKjTOk= github.com/juju/utils v0.0.0-20180820210520-bf9cc5bdd62d/go.mod h1:6/KLg8Wz/y2KVGWEpkK9vMNGkOnu4k/cqs8Z1fKjTOk= +github.com/juju/utils v0.0.0-20200116185830-d40c2fe10647/go.mod h1:6/KLg8Wz/y2KVGWEpkK9vMNGkOnu4k/cqs8Z1fKjTOk= +github.com/juju/utils/v2 v2.0.0-20200923005554-4646bfea2ef1/go.mod h1:fdlDtQlzundleLLz/ggoYinEt/LmnrpNKcNTABQATNI= +github.com/juju/utils/v3 v3.0.0-20220130232349-cd7ecef0e94a/go.mod h1:LzwbbEN7buYjySp4nqnti6c6olSqRXUk6RkbSUUP1n8= +github.com/juju/version v0.0.0-20161031051906-1f41e27e54f2/go.mod h1:kE8gK5X0CImdr7qpSKl3xB2PmpySSmfj7zVbkZFs81U= github.com/juju/version v0.0.0-20180108022336-b64dbd566305/go.mod h1:kE8gK5X0CImdr7qpSKl3xB2PmpySSmfj7zVbkZFs81U= +github.com/juju/version v0.0.0-20191219164919-81c1be00b9a6/go.mod h1:kE8gK5X0CImdr7qpSKl3xB2PmpySSmfj7zVbkZFs81U= +github.com/juju/version/v2 v2.0.0-20211007103408-2e8da085dc23/go.mod h1:Ljlbryh9sYaUSGXucslAEDf0A2XUSGvDbHJgW8ps6nc= +github.com/julienschmidt/httprouter v1.1.1-0.20151013225520-77a895ad01eb/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= +github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= 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/kkdai/bstream v1.0.0 h1:Se5gHwgp2VT2uHfDrkbbgbgEvV9cimLELwrPJctSjg8= +github.com/kkdai/bstream v1.0.0/go.mod h1:FDnDOHt5Yx4p3FaHcioFT0QjDOtgUpvjeZqAs+NVZZA= +github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= +github.com/klauspost/compress v1.10.10/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= +github.com/klauspost/compress v1.13.6 h1:P76CopJELS0TiO2mebmnzgWaajssP/EszplttgQxcgc= +github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= +github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= +github.com/klauspost/pgzip v1.2.4/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= +github.com/klauspost/pgzip v1.2.5 h1:qnWYvvKqedOF2ulHpMG72XQol4ILEJ8k2wwRl/Km8oE= +github.com/klauspost/pgzip v1.2.5/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.3/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/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= +github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.8.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lib/pq v1.10.3 h1:v9QZf2Sn6AmjXtQeFpdoq/eaNtYP6IN+7lcrygsIAtg= +github.com/lib/pq v1.10.3/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 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/neutrino v0.14.2 h1:yrnZUCYMZ5ECtXhgDrzqPq2oX8awoAN2D/cgCewJcCo= +github.com/lightninglabs/neutrino v0.14.2/go.mod h1:OICUeTCn+4Tu27YRJIpWvvqySxx4oH4vgdP33Sw9RDc= github.com/lightninglabs/protobuf-hex-display v1.3.3-0.20191212020323-b444784ce75d/go.mod h1:KDb67YMzoh4eudnzClmvs2FbiLG9vxISmLApUkCa4uI= +github.com/lightninglabs/protobuf-hex-display v1.4.3-hex-display/go.mod h1:2oKOBU042GKFHrdbgGiKax4xVrFiZu51lhacUZQ9MnE= 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/lightning-onion v1.0.2-0.20220211021909-bb84a1ccb0c5 h1:TkKwqFcQTGYoI+VEqyxA8rxpCin8qDaYX0AfVRinT3k= +github.com/lightningnetwork/lightning-onion v1.0.2-0.20220211021909-bb84a1ccb0c5/go.mod h1:7dDx73ApjEZA0kcknI799m2O5kkpfg4/gr7N092ojNo= github.com/lightningnetwork/lnd v0.10.1-beta/go.mod h1:F9er1DrpOHdQVQBqYqyBqIFyl6q16xgBM8yTioHj2Cg= +github.com/lightningnetwork/lnd v0.15.0-beta h1:smzYjJqL4nGuj4qrAWdikrPzPJ8fcPRFHQ86S2tHR1M= +github.com/lightningnetwork/lnd v0.15.0-beta/go.mod h1:Tm7LZrYeR2JQH1gEOKmd0NTCgjJ1Bnujkx4lcz9b5+A= github.com/lightningnetwork/lnd/cert v1.0.2/go.mod h1:fmtemlSMf5t4hsQmcprSoOykypAPp+9c+0d0iqTScMo= +github.com/lightningnetwork/lnd/cert v1.1.1/go.mod h1:1P46svkkd73oSoeI4zjkVKgZNwGq8bkGuPR8z+5vQUs= +github.com/lightningnetwork/lnd/clock v1.0.1/go.mod h1:KnQudQ6w0IAMZi1SgvecLZQZ43ra2vpDNj7H/aasemg= +github.com/lightningnetwork/lnd/clock v1.1.0 h1:/yfVAwtPmdx45aQBoXQImeY7sOIEr7IXlImRMBOZ7GQ= +github.com/lightningnetwork/lnd/clock v1.1.0/go.mod h1:KnQudQ6w0IAMZi1SgvecLZQZ43ra2vpDNj7H/aasemg= +github.com/lightningnetwork/lnd/healthcheck v1.0.0/go.mod h1:u92p1JGFJNMSkMvztKEwmt1P3TRnLeJBXZ3M85xkU1E= +github.com/lightningnetwork/lnd/healthcheck v1.2.2 h1:im+qcpgSuteqRCGeorT9yqVXuLrS6A7/acYzGgarMS4= +github.com/lightningnetwork/lnd/healthcheck v1.2.2/go.mod h1:IWY0GChlarRbXFkFDdE4WY5POYJabe/7/H1iCZt4ZKs= +github.com/lightningnetwork/lnd/kvdb v1.3.1 h1:gEz3zudNNRrCLEvqRaktYoKwsUblyHX+MKjR0aI3QnM= +github.com/lightningnetwork/lnd/kvdb v1.3.1/go.mod h1:x+IpsuDynubjokUofavLXroeGfS/WrqUXXTK6vN/gp4= 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/queue v1.1.0 h1:YpCJjlIvVxN/R7ww2aNiY8ex7U2fucZDLJ67tI3HFx8= +github.com/lightningnetwork/lnd/queue v1.1.0/go.mod h1:YTkTVZCxz8tAYreH27EO3s8572ODumWrNdYW2E/YKxg= github.com/lightningnetwork/lnd/ticker v1.0.0/go.mod h1:iaLXJiVgI1sPANIF2qYYUJXjoksPNvGNYowB8aRbpX0= +github.com/lightningnetwork/lnd/ticker v1.1.0 h1:ShoBiRP3pIxZHaETndfQ5kEe+S4NdAY1hiX7YbZ4QE4= +github.com/lightningnetwork/lnd/ticker v1.1.0/go.mod h1:ubqbSVCn6RlE0LazXuBr7/Zi6QT0uQo++OgIRBxQUrk= +github.com/lightningnetwork/lnd/tlv v1.0.2/go.mod h1:fICAfsqk1IOsC1J7G9IdsWX1EqWRMqEDCNxZJSKr9C4= +github.com/lightningnetwork/lnd/tlv v1.0.3 h1:0xBZcPuXagP6f7TY/RnLNR4igE21ov6qUdTr5NyvhhI= +github.com/lightningnetwork/lnd/tlv v1.0.3/go.mod h1:dzR/aZetBri+ZY/fHbwV06fNn/3UID6htQzbHfREFdo= +github.com/lightningnetwork/lnd/tor v1.0.0/go.mod h1:RDtaAdwfAm+ONuPYwUhNIH1RAvKPv+75lHPOegUcz64= +github.com/lightningnetwork/lnd/tor v1.0.1 h1:A11FrpU0Y//g+fA827W4VnjOeoIvExONdchlLX8wYkA= +github.com/lightningnetwork/lnd/tor v1.0.1/go.mod h1:RDtaAdwfAm+ONuPYwUhNIH1RAvKPv+75lHPOegUcz64= +github.com/lightstep/lightstep-tracer-common/golang/gogo v0.0.0-20190605223551-bc2310a04743/go.mod h1:qklhhLq1aX+mtWk9cPHPzaBjWImj5ULL6C7HFJtXQMM= +github.com/lightstep/lightstep-tracer-go v0.18.1/go.mod h1:jlF1pusYV4pidLvZ+XD0UBX0ZE6WURAspgAczcDHrL4= 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/lunixbochs/vtclean v0.0.0-20160125035106-4fbf7632a2c6/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= +github.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ= +github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/mailru/easyjson v0.0.0-20160728113105-d5b7844b561a/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= 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/masterzen/azure-sdk-for-go v3.2.0-beta.0.20161014135628-ee4f0065d00c+incompatible/go.mod h1:mf8fjOu33zCqxUjuiU3I8S1lJMyEAlH+0F2+M5xl3hE= +github.com/masterzen/simplexml v0.0.0-20160608183007-4572e39b1ab9/go.mod h1:kCEbxUJlNDEBNbdQMkPSp6yaKcRXVI6f4ddk8Riv4bc= +github.com/masterzen/winrm v0.0.0-20161014151040-7a535cd943fc/go.mod h1:CfZSN7zwz5gJiFhZJz49Uzk7mEBHIceWmbFmYx7Hf7E= +github.com/masterzen/xmlpath v0.0.0-20140218185901-13f4951698ad/go.mod h1:A0zPC53iKKKcXYxr4ROjpQRQ5FgJXtelNdSmHHuq/tY= +github.com/mattn/go-colorable v0.0.6/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= +github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-isatty v0.0.0-20160806122752-66b8e73f3f5c/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 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 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= 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/mholt/archiver/v3 v3.5.0 h1:nE8gZIrw66cu4osS/U7UW7YDuGMHssxKutU8IfWxwWE= +github.com/mholt/archiver/v3 v3.5.0/go.mod h1:qqTTPUK/HZPFgFQ/TJ3BzvTpF/dPtFVJXdQbCmeMxwc= github.com/miekg/dns v0.0.0-20171125082028-79bfde677fa8/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= +github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= +github.com/miekg/dns v1.1.43 h1:JKfpVSCB84vrAmHzyrsxB5NAr5kLoMXZArPSw7Qlgyg= +github.com/miekg/dns v1.1.43/go.mod h1:+evo5L0630/F6ca/Z9+GAqzhjGyn8/c+TBaOyfEl0V4= +github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= +github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= +github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg= +github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= +github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180320133207-05fbef0ca5da/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/munnerz/goautoneg v0.0.0-20120707110453-a547fc61f48d/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= +github.com/nats-io/jwt v0.3.0/go.mod h1:fRYCDE99xlTsqUzISS1Bi75UBJ6ljOJQOAAu5VglpSg= +github.com/nats-io/jwt v0.3.2/go.mod h1:/euKqTS1ZD+zzjYrY7pseZrTtWQSjujC7xjPc8wL6eU= +github.com/nats-io/nats-server/v2 v2.1.2/go.mod h1:Afk+wRZqkMQs/p45uXdrVLuab3gwv3Z8C4HTBu8GD/k= +github.com/nats-io/nats.go v1.9.1/go.mod h1:ZjDU1L/7fJ09jvUSRVBR2e7+RnLiiIQyqyzEE/Zbp4w= +github.com/nats-io/nkeys v0.1.0/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w= +github.com/nats-io/nkeys v0.1.3/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/nbd-wtf/go-nostr v0.13.0 h1:3OcUknuSLm8prTk2u/kUvbMQYtaSjLKemADRQ8TBIXk= +github.com/nbd-wtf/go-nostr v0.13.0/go.mod h1:qFFTIxh15H5GGN0WsBI/P73DteqsevnhSEW/yk8nEf4= +github.com/nbd-wtf/ln-decodepay v1.5.1 h1:i/SMR94AXIL21KxE/CyWLg/1kKUOVfxHD4QJswaNRDc= +github.com/nbd-wtf/ln-decodepay v1.5.1/go.mod h1:xzBXPaCj/7oRRaui+iYSIxy5LYUjoPfAyAGq2WCyNKk= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= +github.com/nicksnyder/go-i18n/v2 v2.1.2 h1:QHYxcUJnGHBaq7XbvgunmZ2Pn0focXFqTD61CkH146c= +github.com/nicksnyder/go-i18n/v2 v2.1.2/go.mod h1:d++QJC9ZVf7pa48qrsRWhMJ5pSHIPmS3OLqK1niyLxs= +github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U= +github.com/nwaples/rardecode v1.1.0/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0= +github.com/nwaples/rardecode v1.1.2 h1:Cj0yZY6T1Zx1R7AhTbyGSALm44/Mmq+BAPc4B/p/d3M= +github.com/nwaples/rardecode v1.1.2/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0= +github.com/nxadm/tail v1.4.4 h1:DQuhQpB1tVlglWS2hLQ5OV6B5r8aGxSrPc5Qo6uTN78= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/oklog/oklog v0.3.2/go.mod h1:FCV+B7mhrz4o+ueLpx+KqkyXRGMWOYEvfiXtdGtbWGs= +github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/olekukonko/tablewriter v0.0.0-20170122224234-a0225b3f23b5/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo= +github.com/onsi/ginkgo v0.0.0-20170829012221-11459a886d9c/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 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/ginkgo v1.10.1/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY= +github.com/onsi/ginkgo v1.15.0 h1:1V1NfVQR87RtWAgp1lv9JZJ5Jap+XFGKPi00andXGi4= +github.com/onsi/ginkgo v1.15.0/go.mod h1:hF8qUzuuC8DJGygJH3726JnCZX4MYbRB8yFfISqnKUg= +github.com/onsi/gomega v0.0.0-20170829124025-dcabb60a477c/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= 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/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/onsi/gomega v1.10.5 h1:7n6FEkpFmfCoo2t+YYqXH0evK+a9ICQz0xcAy9dYcaQ= +github.com/onsi/gomega v1.10.5/go.mod h1:gza4q3jKQJijlu05nKWRCW/GavJumGt8aNRxWg7mt48= +github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk= +github.com/opentracing-contrib/go-observer v0.0.0-20170622124052-a52f23424492/go.mod h1:Ngi6UdF0k5OKD5t5wlmGhe/EDKPoUM3BXZSSfIuJbis= +github.com/opentracing/basictracer-go v1.0.0/go.mod h1:QfBfYuafItcjQuMwinw9GhYKwFXS9KnPs5lxoYwgW74= +github.com/opentracing/opentracing-go v1.0.2/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= +github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= +github.com/openzipkin-contrib/zipkin-go-opentracing v0.4.5/go.mod h1:/wsWhb9smxSfWAKL3wpBW7V8scJMt8N8gnaMCS9E/cA= +github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw= +github.com/openzipkin/zipkin-go v0.2.1/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnhQw8ySjnjRyN4= +github.com/openzipkin/zipkin-go v0.2.2/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnhQw8ySjnjRyN4= +github.com/orcaman/concurrent-map v1.0.0 h1:I/2A2XPCb4IuQWcQhBhSwGfiuybl/J0ev9HDbW65HOY= +github.com/orcaman/concurrent-map v1.0.0/go.mod h1:Lu3tH6HLW3feq74c2GC+jIMS/K2CFcDWnWD9XkenwhI= +github.com/pact-foundation/pact-go v1.0.4/go.mod h1:uExwJY4kCzNPcHRj+hCR/HBbOOIwwtUjcrb0b5/5kLM= +github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= +github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= +github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= +github.com/pegasus-kv/thrift v0.13.0 h1:4ESwaNoHImfbHa9RUGJiJZ4hrxorihZHk5aarYwY8d4= +github.com/pegasus-kv/thrift v0.13.0/go.mod h1:Gl9NT/WHG6ABm6NsrbfE8LiJN0sAyneCrvB4qN4NPqQ= +github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= +github.com/performancecopilot/speed v3.0.0+incompatible/go.mod h1:/CLtqpZ5gBg1M9iaPbIdPPGyKcA8hKdoy6hAWba7Yac= +github.com/pierrec/lz4 v1.0.2-0.20190131084431-473cd7ce01a1/go.mod h1:3/3N9NVKO0jef7pBehbT1qWhCMrIgbYNnFAZCqQ5LRc= +github.com/pierrec/lz4 v2.0.5+incompatible h1:2xWsjqPFWcplujydGg4WmhC/6fZqK42wMM8aXeqhl0I= +github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= +github.com/pierrec/lz4/v4 v4.0.3/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pierrec/lz4/v4 v4.1.8 h1:ieHkV+i2BRzngO4Wd/3HGowuZStgq6QkPsD1eolNAO4= +github.com/pierrec/lz4/v4 v4.1.8/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= 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/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/profile v1.2.1/go.mod h1:hJw3o1OdXxsrSjjVksARp5W95eeEaEfptyVZyv6JUPA= +github.com/pkg/profile v1.6.0/go.mod h1:qBsxPvzyUincmltOk6iyRVxHYg4adc0OFOv72ZdLa18= +github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs= github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= +github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= +github.com/prometheus/client_golang v1.3.0/go.mod h1:hJaj2vgQTGQmVCsAACORcieXFeDPbaTKGT+JTgUa3og= +github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= +github.com/prometheus/client_golang v1.10.0/go.mod h1:WJM3cc3yu7XKBKa/I8WeZm+V3eltZnBwfENSU7mdogU= +github.com/prometheus/client_golang v1.11.0 h1:HNkLOAEQMIDv/K+04rukrLx6ch7msSRwf3/SASFAGtQ= +github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.1.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.2.0 h1:uq5h0d+GuxiXLJLNABMgp2qUWDPiLvgCzz2dUR+/W/M= +github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= +github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.7.0/go.mod h1:DjGbpBbp5NYNiECxcL/VnbXCCaQpKd3tt26CguLLsqA= +github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= +github.com/prometheus/common v0.18.0/go.mod h1:U+gB1OBLb1lF3O42bTCL+FK18tX9Oar16Clt/msog/s= +github.com/prometheus/common v0.26.0 h1:iMAkS2TDoNWnKM+Kopnx/8tnEStIfpYA0ur0xQzzhMQ= +github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= +github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= +github.com/prometheus/procfs v0.6.0 h1:mxy4L2jP6qMonqmq+aTtOx1ifVWUgG/TAmntgbh3xv4= +github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= +github.com/r3labs/sse v0.0.0-20210224172625-26fe804710bc h1:zAsgcP8MhzAbhMnB1QQ2O7ZhWYVGYSR2iVcjzQuPV+o= +github.com/r3labs/sse v0.0.0-20210224172625-26fe804710bc/go.mod h1:S8xSOnV3CgpNrWd0GQ/OoQfMtlg2uPRSuTzcSGrzwK8= +github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 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/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k= +github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= +github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= +github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= +github.com/samuel/go-zookeeper v0.0.0-20190923202752-2cc03de413da/go.mod h1:gi+0XIa01GRL2eRQVjQkKGqKF3SF9vZR/HnPullcV2E= +github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww= +github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= +github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= +github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= +github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= +github.com/sirupsen/logrus v1.7.0 h1:ShrD1U9pZB12TX0cVy0DtePoCH97K8EtX+mg7ZARUtM= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= 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/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= +github.com/soheilhy/cmux v0.1.5 h1:jjzc5WVemNEDTLwv9tlmemhC73tI08BNOIGwBOo10Js= +github.com/soheilhy/cmux v0.1.5/go.mod h1:T7TcVDs9LWfQgPlPsdngu6I6QIoyIFZDDC6sNE1GqG0= +github.com/sony/gobreaker v0.4.1/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= +github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w= +github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU= +github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= +github.com/spf13/cobra v1.1.3/go.mod h1:pGADOWyqRD/YMrPZigI/zbliZ2wVD/23d+is3pSWzOo= +github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= +github.com/spf13/pflag v0.0.0-20170130214245-9ff6c6923cff/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/pflag v1.0.1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg= +github.com/streadway/amqp v0.0.0-20190404075320-75d898a42a94/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw= +github.com/streadway/amqp v0.0.0-20190827072141-edfb9018d271/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw= +github.com/streadway/handy v0.0.0-20190108123426-d5acb3125c2a/go.mod h1:qNTQ5P5JnDBl6z3cMAg/SywNDC5ABu5ApDIw6lUbRmI= 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 h1:Hbg2NidpLE8veEBkEZTL3CvlkUIVzuU9jDplZO54c48= github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= +github.com/stretchr/testify v0.0.0-20151208002404-e3a8ff8ce365/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 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.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 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/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= +github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 h1:epCh84lMvA70Z7CTTCmYQn2CKbY8j86K7/FAIr141uY= +github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= +github.com/tidwall/assert v0.1.0 h1:aWcKyRBUAdLoVebxo95N7+YZVTFF/ASTr7BN4sLP6XI= +github.com/tidwall/assert v0.1.0/go.mod h1:QLYtGyeqse53vuELQheYl9dngGCJQ+mTtlxcktb+Kj8= 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/buntdb v1.2.7 h1:SIyObKAymzLyGhDeIhVk2Yc1/EwfCC75Uyu77CHlVoA= +github.com/tidwall/buntdb v1.2.7/go.mod h1:b6KvZM27x/8JLI5hgRhRu60pa3q0Tz9c50TyD46OHUM= 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/gjson v1.10.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.12.1 h1:ikuZsLdhr8Ws0IdROXUS1Gi4v9Z4pGqpX/CvJkxvfpo= +github.com/tidwall/gjson v1.12.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/grect v0.1.3 h1:z9YwQAMUxVSBde3b7Sl8Da37rffgNfZ6Fq6h9t6KdXE= +github.com/tidwall/grect v0.1.3/go.mod h1:8GMjwh3gPZVpLBI/jDz9uslCe0dpxRpWDdtN0lWAS/E= 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/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/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/sjson v1.2.4 h1:cuiLzLnaMeBhRmEv00Lpk3tkYrcxpmbU81tAY4Dw0tc= +github.com/tidwall/sjson v1.2.4/go.mod h1:098SZ494YoMWPmMO6ct4dcFnqxwj9r/gF0Etp19pSNM= github.com/tidwall/tinyqueue v0.1.1 h1:SpNEvEggbpyN5DIReaJ2/1ndroY8iyEGxPYxoSaymYE= github.com/tidwall/tinyqueue v0.1.1/go.mod h1:O/QNHwrnjqr6IHItYrzoHAKYhBkLI67Q096fQP5zMYw= +github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= +github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= +github.com/tmc/grpc-websocket-proxy v0.0.0-20201229170055-e5319fda7802 h1:uruHq4dN7GR16kFc5fp3d1RIYzJW5onx8Ybykw2YQFA= +github.com/tmc/grpc-websocket-proxy v0.0.0-20201229170055-e5319fda7802/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/tv42/zbase32 v0.0.0-20160707012821-501572607d02/go.mod h1:tHlrkM198S068ZqfrO6S8HsoJq2bF3ETfTL+kt4tInY= +github.com/ulikunitz/xz v0.5.6/go.mod h1:2bypXElzHzzJZwzH67Y6wb67pO62Rzfn7BSiF4ABRW8= +github.com/ulikunitz/xz v0.5.7/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/ulikunitz/xz v0.5.10 h1:t92gobL9l3HE202wg3rlk19F6X+JOxl9BBrCCMYEYd8= +github.com/ulikunitz/xz v0.5.10/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/urfave/cli v1.18.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= -go.etcd.io/bbolt v1.3.3 h1:MUGmc65QhB3pIlaQ5bB4LwqSj6GIonVJXpZiaKNyaKk= +github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= +github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= +github.com/urfave/cli v1.22.4/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= +github.com/valyala/fastjson v1.6.3 h1:tAKFnnwmeMGPbwJ7IwxcTPCNr3uIzoIj3/Fh90ra4xc= +github.com/valyala/fastjson v1.6.3/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= +github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= +github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo= +github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos= +github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2 h1:eY9dn8+vbi4tKz5Qo6v2eYzo7kUS51QINcR5jNpbZS8= +github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= +github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= +gitlab.com/yawning/bsaes.git v0.0.0-20190805113838-0a714cd429ec/go.mod h1:BZ1RAoRPbCxum9Grlv5aeksu2H8BiKehBYooU2LFiOQ= +go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= +go.etcd.io/bbolt v1.3.5-0.20200615073812-232d8fc87f50/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ= +go.etcd.io/bbolt v1.3.6 h1:/ecaJf0sk1l4l6V4awd65v2C3ILy7MSj+s/x1ADCIMU= +go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4= +go.etcd.io/etcd v0.0.0-20191023171146-3cf2f69b5738 h1:VcrIfasaLFkyjk6KNlXQSzO+B0fZcnECiDrKJsfxka0= +go.etcd.io/etcd v0.0.0-20191023171146-3cf2f69b5738/go.mod h1:dnLIgRNXwCJa5e+c6mIZCrds/GIG4ncV9HhK5PX7jPg= +go.etcd.io/etcd/api/v3 v3.5.0 h1:GsV3S+OfZEOCNXdtNkBSR7kgLobAa/SO6tCxRa0GAYw= +go.etcd.io/etcd/api/v3 v3.5.0/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs= +go.etcd.io/etcd/client/pkg/v3 v3.5.0 h1:2aQv6F436YnN7I4VbI8PPYrBhu+SmrTaADcf8Mi/6PU= +go.etcd.io/etcd/client/pkg/v3 v3.5.0/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g= +go.etcd.io/etcd/client/v2 v2.305.0 h1:ftQ0nOOHMcbMS3KIaDQ0g5Qcd6bhaBrQT6b89DfwLTs= +go.etcd.io/etcd/client/v2 v2.305.0/go.mod h1:h9puh54ZTgAKtEbut2oe9P4L/oqKCVB6xsXlzd7alYQ= +go.etcd.io/etcd/client/v3 v3.5.0 h1:62Eh0XOro+rDwkrypAGDfgmNh5Joq+z+W9HZdlXMzek= +go.etcd.io/etcd/client/v3 v3.5.0/go.mod h1:AIKXXVX/DQXtfTEqBryiLTUXwON+GuvO6Z7lLS/oTh0= +go.etcd.io/etcd/pkg/v3 v3.5.0 h1:ntrg6vvKRW26JRmHTE0iNlDgYK6JX3hg/4cD62X0ixk= +go.etcd.io/etcd/pkg/v3 v3.5.0/go.mod h1:UzJGatBQ1lXChBkQF0AuAtkRQMYnHubxAEYIrC3MSsE= +go.etcd.io/etcd/raft/v3 v3.5.0 h1:kw2TmO3yFTgE+F0mdKkG7xMxkit2duBDa2Hu6D/HMlw= +go.etcd.io/etcd/raft/v3 v3.5.0/go.mod h1:UFOHSIvO/nKwd4lhkwabrTD3cqW5yVyYYf/KlD00Szc= +go.etcd.io/etcd/server/v3 v3.5.0 h1:jk8D/lwGEDlQU9kZXUFMSANkE22Sg5+mW27ip8xcF9E= +go.etcd.io/etcd/server/v3 v3.5.0/go.mod h1:3Ah5ruV+M+7RZr0+Y/5mNLwC+eQlni+mQmOVdCRJoS4= go.mongodb.org/mongo-driver v1.0.3/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM= +go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk= +go.opencensus.io v0.20.2/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk= +go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= +go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= +go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opentelemetry.io/contrib v0.20.0 h1:ubFQUn0VCZ0gPwIoJfBJVpeBlyRMxu8Mm/huKWYd9p0= +go.opentelemetry.io/contrib v0.20.0/go.mod h1:G/EtFaa6qaN7+LxqfIAT3GiZa7Wv5DTBUzl5H4LY0Kc= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.20.0 h1:sO4WKdPAudZGKPcpZT4MJn6JaDmpyLrMPDGGyA1SttE= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.20.0/go.mod h1:oVGt1LRbBOBq1A5BQLlUg9UaU/54aiHw8cgjV3aWZ/E= +go.opentelemetry.io/otel v0.19.0/go.mod h1:j9bF567N9EfomkSidSfmMwIwIBuP37AMAIzVW85OxSg= +go.opentelemetry.io/otel v0.20.0 h1:eaP0Fqu7SXHwvjiqDq83zImeehOHX8doTvU9AwXON8g= +go.opentelemetry.io/otel v0.20.0/go.mod h1:Y3ugLH2oa81t5QO+Lty+zXf8zC9L26ax4Nzoxm/dooo= +go.opentelemetry.io/otel/exporters/otlp v0.20.0 h1:PTNgq9MRmQqqJY0REVbZFvwkYOA85vbdQU/nVfxDyqg= +go.opentelemetry.io/otel/exporters/otlp v0.20.0/go.mod h1:YIieizyaN77rtLJra0buKiNBOm9XQfkPEKBeuhoMwAM= +go.opentelemetry.io/otel/metric v0.19.0/go.mod h1:8f9fglJPRnXuskQmKpnad31lcLJ2VmNNqIsx/uIwBSc= +go.opentelemetry.io/otel/metric v0.20.0 h1:4kzhXFP+btKm4jwxpjIqjs41A7MakRFUS86bqLHTIw8= +go.opentelemetry.io/otel/metric v0.20.0/go.mod h1:598I5tYlH1vzBjn+BTuhzTCSb/9debfNp6R3s7Pr1eU= +go.opentelemetry.io/otel/oteltest v0.19.0/go.mod h1:tI4yxwh8U21v7JD6R3BcA/2+RBoTKFexE/PJ/nSO7IA= +go.opentelemetry.io/otel/oteltest v0.20.0 h1:HiITxCawalo5vQzdHfKeZurV8x7ljcqAgiWzF6Vaeaw= +go.opentelemetry.io/otel/oteltest v0.20.0/go.mod h1:L7bgKf9ZB7qCwT9Up7i9/pn0PWIa9FqQ2IQ8LoxiGnw= +go.opentelemetry.io/otel/sdk v0.20.0 h1:JsxtGXd06J8jrnya7fdI/U/MR6yXA5DtbZy+qoHQlr8= +go.opentelemetry.io/otel/sdk v0.20.0/go.mod h1:g/IcepuwNsoiX5Byy2nNV0ySUF1em498m7hBWC279Yc= +go.opentelemetry.io/otel/sdk/export/metric v0.20.0 h1:c5VRjxCXdQlx1HjzwGdQHzZaVI82b5EbBgOu2ljD92g= +go.opentelemetry.io/otel/sdk/export/metric v0.20.0/go.mod h1:h7RBNMsDJ5pmI1zExLi+bJK+Dr8NQCh0qGhm1KDnNlE= +go.opentelemetry.io/otel/sdk/metric v0.20.0 h1:7ao1wpzHRVKf0OQ7GIxiQJA6X7DLX9o14gmVon7mMK8= +go.opentelemetry.io/otel/sdk/metric v0.20.0/go.mod h1:knxiS8Xd4E/N+ZqKmUPf3gTTZ4/0TjTXukfxjzSTpHE= +go.opentelemetry.io/otel/trace v0.19.0/go.mod h1:4IXiNextNOpPnRlI4ryK69mn5iC84bjBWZQA5DXz/qg= +go.opentelemetry.io/otel/trace v0.20.0 h1:1DL6EXUdcg95gukhuRRvLDO/4X5THh/5dIV52lqtnbw= +go.opentelemetry.io/otel/trace v0.20.0/go.mod h1:6GjCW8zgDjwGHGa6GkyeB8+/5vjT16gUEi0Nf1iBdgw= +go.opentelemetry.io/proto/otlp v0.7.0 h1:rwOQPCuKAKmwGKq2aVNnYIibI6wnV7EvzgfTCzcdGg8= +go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= +go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= +go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= +go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= +go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= +go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4= +go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= +go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= +go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM= +go.uber.org/zap v1.17.0 h1:MTjgFu6ZLKvY6Pvaqk97GlxNBuMpV4Hy/3P6tRGlI2U= +go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20180214000028-650f4a345ab4/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/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-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 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/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= +golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 h1:7I4JAnoQBe7ZtJcBaYHi5UtiO8tQHbUSXxL+pnGRANg= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= +golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= +golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= +golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= +golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= +golang.org/x/exp v0.0.0-20220426173459-3bcf042a4bf5/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE= +golang.org/x/exp v0.0.0-20221106115401-f9659909a136 h1:Fq7F/w7MAa1KJ5bt2aJ62ihqp9HDcRuyILskkpIAurw= +golang.org/x/exp v0.0.0-20221106115401-f9659909a136/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= 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/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= +golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= +golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= +golang.org/x/mod v0.6.0-dev.0.20211013180041-c96bc1413d57/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= +golang.org/x/net v0.0.0-20170114055629-f2499483f923/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180406214816-61147c48b25b/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/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-20181023162649-9b4f9f5ad519/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-20181201002055-351d144fa1fc/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-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190125091013-d26f9f9a57f3/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-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191004110552-13f9640d40b9/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191105084925-a882066a44e0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191116160921-f9c825593386/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200904194848-62affa334b73/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f h1:OfiFi4JbukWwe3lzw+xunroH1mnC1e2Gy5cxNJApiSY= +golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20210615190721-d04028783cf1/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= 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-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220819030929-7fc1605a5dde h1:ejfdSekXMDxDLbRrJMwUk6KnSLZ2McaUCVcIKM+N6jc= +golang.org/x/sync v0.0.0-20220819030929-7fc1605a5dde/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20170830134202-bb24a47a89ea/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180816055513-1c9583448a9c/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 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-20181026203630-95b1ffbd15a5/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-20181122145206-62eef0e2fa9b/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-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190804053845-51ab0e2deafa/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/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-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191010194322-b09406accb47/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191220142924-d4481acd189f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210309074719-68d13333faf2/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210426080607-c94f62235c83/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.0.0-20160726164857-2910a502d2bf/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 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.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba h1:O8mE0/t419eoIwhTFpKVkHiTs/Igowgfkj25AcZrtiE= +golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 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-20181011042414-1f849cf54d09/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= +golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.8-0.20211029000441-d6a9af8af023/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= +golang.org/x/tools v0.1.8/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= +golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 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/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk= +google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= +google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= +google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= +google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= +google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= 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/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190530194941-fb225487d101/go.mod h1:z3L6/3dTEVtUr6QSP8miRzeRqwQOioJ9I66odjN4I7s= +google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= +google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= +google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200423170343-7949de9c1215/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= +google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= +google.golang.org/genproto v0.0.0-20210617175327-b9e0b3197ced h1:c5geK1iMU3cDKtFrCVQIcjR3W+JOZMuhIyICMCTbtus= +google.golang.org/genproto v0.0.0-20210617175327-b9e0b3197ced/go.mod h1:SzzZ/N+nwJDaO1kznhnlzqS8ocJICar6hYhVyhi++24= google.golang.org/grpc v1.16.0/go.mod h1:0JHn/cJsOMiMfNA9+DeHDlAU7KAAB5GDlYFpa9MZMio= +google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= 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= +google.golang.org/grpc v1.20.0/go.mod h1:chYK+tFQF0nDUGJgXMSgLCQk3phJEuONr2DCgLDdAQM= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +google.golang.org/grpc v1.22.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.23.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= +google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= +google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= +google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= +google.golang.org/grpc v1.37.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= +google.golang.org/grpc v1.38.0 h1:/9BgsAsa5nWe26HqOlvlgJnqBuktYOLCgjCPqsa56W0= +google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0 h1:bxAC2xTBsZGibn2RTntX0oH50xLsqy1OxA9tTL3p/lk= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +gopkg.in/alecthomas/kingpin.v2 v2.2.6 h1:jMFz6MfLP0/4fUyZle81rXUoxOBFi19VUFKVDOQfozc= 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/cenkalti/backoff.v1 v1.1.0 h1:Arh75ttbsvlpVA7WtVpH4u9h6Zl46xuptxqLxPiSo4Y= +gopkg.in/cenkalti/backoff.v1 v1.1.0/go.mod h1:J6Vskwqd+OMVJl8C33mmtxTBs2gyzfv7UDAkHu8BrjI= 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-20160105164936-4f90aeace3a2/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/cheggaaa/pb.v1 v1.0.25/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw= +gopkg.in/errgo.v1 v1.0.0-20161222125816-442357a80af5/go.mod h1:u0ALmqvLRxLI95fkdCEWrE6mhWYZW1aMOJHp5YXLHTg= 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/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/gcfg.v1 v1.2.3/go.mod h1:yesOnuUOFQAhST5vPY4nbZsb/huCgGGXlipJsBn0b3o= +gopkg.in/httprequest.v1 v1.1.1/go.mod h1:/CkavNL+g3qLOrpFHVrEx4NKepeqR4XTZWNj4sGGjz0= +gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/lightningtipbot/telebot.v3 v3.0.0-20220828121412-0dea11ecc6dd h1:LI1Q9RRmTKusLTpLGAHFUVT+wgzc0GHFKd3+34fARE8= +gopkg.in/lightningtipbot/telebot.v3 v3.0.0-20220828121412-0dea11ecc6dd/go.mod h1:BefN0q4hcqXE1jXo4mTXZ9+29zl7IGsoSFXEQF455Oc= 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-20160818015218-f2b6f6c918c4/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA= gopkg.in/mgo.v2 v2.0.0-20190816093944-a6b53ec6cb22/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA= +gopkg.in/natefinch/lumberjack.v2 v2.0.0 h1:1Lc07Kr7qY4U2YPouBjpCLxpiyxIVoxqXgkXLknAOE8= +gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= 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/tomb.v2 v2.0.0-20161208151619-d5d1b5820637 h1:yiW+nvdHb9LVqSHQBXfZCieqV4fzYhNBql77zY0ykqs= +gopkg.in/tomb.v2 v2.0.0-20161208151619-d5d1b5820637/go.mod h1:BHsqpu/nsuzkT5BpiH1EMZPLyqSMM8JbIavyFACoFNk= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v2 v2.0.0-20170712054546-1be3d31502d6/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= 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= +gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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= @@ -311,3 +1437,25 @@ 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= +honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +k8s.io/apimachinery v0.0.0-20191123233150-4c4803ed55e3 h1:FErmbNIJruD5GT2oVEjtPn5Ar5+rcWJsC8/PPUkR0s4= +k8s.io/apimachinery v0.0.0-20191123233150-4c4803ed55e3/go.mod h1:b9qmWdKlLuU9EBh+06BtLcSf/Mu89rWL33naRxs1uZg= +k8s.io/gengo v0.0.0-20190128074634-0689ccc1d7d6/go.mod h1:ezvh/TsK7cY6rbqRK0oQQ8IAqLxYwwyPxAX1Pzy0ii0= +k8s.io/klog v0.0.0-20181102134211-b9b56d5dfc92/go.mod h1:Gq+BEi5rUBO/HRz0bTSXDUcqjScdoY3a9IHpCEIOOfk= +k8s.io/klog v1.0.0/go.mod h1:4Bi6QPql/J/LkTDqv7R/cd3hPo4k2DG6Ptcz060Ez5I= +k8s.io/kube-openapi v0.0.0-20191107075043-30be4d16710a/go.mod h1:1TqjTSzOxsLGIKfj0lK8EeCP7K1iUG65v09OM0/WG5E= +launchpad.net/gocheck v0.0.0-20140225173054-000000000087/go.mod h1:hj7XX3B/0A+80Vse0e+BUHsHMTEhd0O4cpUHr/e/BUM= +launchpad.net/xmlpath v0.0.0-20130614043138-000000000004/go.mod h1:vqyExLOM3qBx7mvYRkoxjSCF945s0mbe7YynlKYXtsA= +rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= +rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= +rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= +sigs.k8s.io/structured-merge-diff v0.0.0-20190525122527-15d366b2352e/go.mod h1:wWxsB5ozmmv/SG7nM11ayaAW51xMvak/t1r0CSlcokI= +sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o= +sigs.k8s.io/yaml v1.2.0 h1:kr/MCeFWJWTwyaHoR9c8EjH9OumOmoF9YGiZd7lFm/Q= +sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc= +sourcegraph.com/sourcegraph/appdash v0.0.0-20190731080439-ebfcffb1b5c0/go.mod h1:hI742Nqp5OhwiqlzhgfbWU4mW4yO10fP+LoT9WOswdU= diff --git a/help.go b/help.go deleted file mode 100644 index ceca3833..00000000 --- a/help.go +++ /dev/null @@ -1,132 +0,0 @@ -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 []`\n" + - "*/balance* 👑 Check your balance: `/balance`\n" + - "*/send* 💸 Send funds to a user: `/send @user or user@ln.tips []`\n" + - "*/invoice* ⚡️ Receive with Lightning: `/invoice []`\n" + - "*/pay* ⚡️ Pay with Lightning: `/pay `\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 []`\n" + - "*receive* 🏅 Request a payment: `%s receive []`\n" + - "*faucet* 🚰 Create a faucet: `%s faucet `\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 `\n" + - "*/faucet* 🚰 Create a faucet `/faucet `" -) - -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) - } - - } - // this is so stupid: - return fmt.Sprintf(advancedMessage, dynamicHelpMessage, GetUserStrMd(bot.telegram.Me), GetUserStrMd(bot.telegram.Me), 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 -} diff --git a/inline_faucet.go b/inline_faucet.go deleted file mode 100644 index 99e997e2..00000000 --- a/inline_faucet.go +++ /dev/null @@ -1,425 +0,0 @@ -package main - -import ( - "fmt" - "strconv" - "time" - - "github.com/LightningTipBot/LightningTipBot/internal/runtime" - log "github.com/sirupsen/logrus" - tb "gopkg.in/tucnak/telebot.v2" -) - -const ( - inlineFaucetMessage = "Press ✅ to collect %d sat from this faucet.\n\n🚰 Remaining: %d/%d sat (given to %d/%d users)\n%s" - inlineFaucetEndedMessage = "🏅 Faucet empty 🏅\n\n🚰 %d sat given to %d users." - inlineFaucetAppendMemo = "\n✉️ %s" - inlineFaucetCreateWalletMessage = "Chat with %s 👈 to manage your wallet." - inlineFaucetCancelledMessage = "🚫 Faucet cancelled." - inlineFaucetInvalidPeruserAmountMessage = "🚫 Peruser amount not divisor of capacity." - inlineFaucetInvalidAmountMessage = "🚫 Invalid amount." - inlineFaucetSentMessage = "🚰 %d sat sent to %s." - inlineFaucetReceivedMessage = "🚰 %s sent you %d sat." - inlineFaucetHelpFaucetInGroup = "Create a faucet in a group with the bot inside or use 👉 inline commands (/advanced for more)." - inlineFaucetHelpText = "📖 Oops, that didn't work. %s\n\n" + - "*Usage:* `/faucet `\n" + - "*Example:* `/faucet 210 21`" -) - -const ( - inlineQueryFaucetTitle = "🚰 Create a faucet." - inlineQueryFaucetDescription = "Usage: @%s faucet " - inlineResultFaucetTitle = "💸 Create a %d sat faucet." - inlineResultFaucetDescription = "👉 Click here to create a faucet worth %d sat in this chat." -) - -var ( - inlineFaucetMenu = &tb.ReplyMarkup{ResizeReplyKeyboard: true} - btnCancelInlineFaucet = inlineFaucetMenu.Data("🚫 Cancel", "cancel_faucet_inline") - btnAcceptInlineFaucet = inlineFaucetMenu.Data("✅ Collect", "confirm_faucet_inline") -) - -type InlineFaucet struct { - Message string `json:"inline_faucet_message"` - Amount int `json:"inline_faucet_amount"` - RemainingAmount int `json:"inline_faucet_remainingamount"` - PerUserAmount int `json:"inline_faucet_peruseramount"` - From *tb.User `json:"inline_faucet_from"` - To []*tb.User `json:"inline_faucet_to"` - Memo string `json:"inline_faucet_memo"` - ID string `json:"inline_faucet_id"` - Active bool `json:"inline_faucet_active"` - NTotal int `json:"inline_faucet_ntotal"` - NTaken int `json:"inline_faucet_ntaken"` - UserNeedsWallet bool `json:"inline_faucet_userneedswallet"` - InTransaction bool `json:"inline_faucet_intransaction"` -} - -func NewInlineFaucet() *InlineFaucet { - inlineFaucet := &InlineFaucet{ - Message: "", - NTaken: 0, - UserNeedsWallet: false, - InTransaction: false, - Active: true, - } - return inlineFaucet - -} - -func (msg InlineFaucet) Key() string { - return msg.ID -} - -func (bot *TipBot) LockFaucet(tx *InlineFaucet) error { - // immediatelly set intransaction to block duplicate calls - tx.InTransaction = true - err := bot.bunt.Set(tx) - if err != nil { - return err - } - return nil -} - -func (bot *TipBot) ReleaseFaucet(tx *InlineFaucet) error { - // immediatelly set intransaction to block duplicate calls - tx.InTransaction = false - err := bot.bunt.Set(tx) - if err != nil { - return err - } - return nil -} - -func (bot *TipBot) inactivateFaucet(tx *InlineFaucet) error { - tx.Active = false - err := bot.bunt.Set(tx) - if err != nil { - return err - } - return nil -} - -// tipTooltipExists checks if this tip is already known -func (bot *TipBot) getInlineFaucet(c *tb.Callback) (*InlineFaucet, error) { - inlineFaucet := NewInlineFaucet() - inlineFaucet.ID = c.Data - err := bot.bunt.Get(inlineFaucet) - - // to avoid race conditions, we block the call if there is - // already an active transaction by loop until InTransaction is false - ticker := time.NewTicker(time.Second * 10) - - for inlineFaucet.InTransaction { - select { - case <-ticker.C: - return nil, fmt.Errorf("[faucet] faucet %s timeout", inlineFaucet.ID) - default: - log.Infof("[faucet] faucet %s already in transaction", inlineFaucet.ID) - time.Sleep(time.Duration(500) * time.Millisecond) - err = bot.bunt.Get(inlineFaucet) - } - } - if err != nil { - return nil, fmt.Errorf("could not get inline faucet: %s", err) - } - return inlineFaucet, nil - -} - -func (bot TipBot) faucetHandler(m *tb.Message) { - if m.Private() { - bot.trySendMessage(m.Sender, fmt.Sprintf(inlineFaucetHelpText, inlineFaucetHelpFaucetInGroup)) - return - } - inlineFaucet := NewInlineFaucet() - var err error - inlineFaucet.Amount, err = decodeAmountFromCommand(m.Text) - if err != nil { - bot.trySendMessage(m.Sender, fmt.Sprintf(inlineFaucetHelpText, inlineFaucetInvalidAmountMessage)) - bot.tryDeleteMessage(m) - return - } - peruserStr, err := getArgumentFromCommand(m.Text, 2) - if err != nil { - bot.trySendMessage(m.Sender, fmt.Sprintf(inlineFaucetHelpText, "")) - bot.tryDeleteMessage(m) - return - } - inlineFaucet.PerUserAmount, err = strconv.Atoi(peruserStr) - if err != nil { - bot.trySendMessage(m.Sender, fmt.Sprintf(inlineFaucetHelpText, inlineFaucetInvalidAmountMessage)) - bot.tryDeleteMessage(m) - return - } - // peruser amount must be >1 and a divisor of amount - if inlineFaucet.PerUserAmount < 1 || inlineFaucet.Amount%inlineFaucet.PerUserAmount != 0 { - bot.trySendMessage(m.Sender, fmt.Sprintf(inlineFaucetHelpText, inlineFaucetInvalidPeruserAmountMessage)) - bot.tryDeleteMessage(m) - return - } - inlineFaucet.NTotal = inlineFaucet.Amount / inlineFaucet.PerUserAmount - - fromUserStr := GetUserStr(m.Sender) - balance, err := bot.GetUserBalance(m.Sender) - if err != nil { - errmsg := fmt.Sprintf("could not get balance of user %s", fromUserStr) - log.Errorln(errmsg) - bot.tryDeleteMessage(m) - return - } - // check if fromUser has balance - if balance < inlineFaucet.Amount { - log.Errorln("Balance of user %s too low", fromUserStr) - bot.trySendMessage(m.Sender, fmt.Sprintf(inlineSendBalanceLowMessage, balance)) - bot.tryDeleteMessage(m) - return - } - - // // check for memo in command - memo := GetMemoFromCommand(m.Text, 3) - - inlineMessage := fmt.Sprintf(inlineFaucetMessage, inlineFaucet.PerUserAmount, inlineFaucet.Amount, inlineFaucet.Amount, 0, inlineFaucet.NTotal, MakeProgressbar(inlineFaucet.Amount, inlineFaucet.Amount)) - if len(memo) > 0 { - inlineMessage = inlineMessage + fmt.Sprintf(inlineFaucetAppendMemo, memo) - } - - inlineFaucet.ID = fmt.Sprintf("inl-faucet-%d-%d-%s", m.Sender.ID, inlineFaucet.Amount, RandStringRunes(5)) - - btnAcceptInlineFaucet.Data = inlineFaucet.ID - btnCancelInlineFaucet.Data = inlineFaucet.ID - inlineFaucetMenu.Inline(inlineFaucetMenu.Row(btnAcceptInlineFaucet, btnCancelInlineFaucet)) - bot.trySendMessage(m.Chat, inlineMessage, inlineFaucetMenu) - log.Infof("[faucet] %s created faucet %s: %d sat (%d per user)", fromUserStr, inlineFaucet.ID, inlineFaucet.Amount, inlineFaucet.PerUserAmount) - inlineFaucet.Message = inlineMessage - inlineFaucet.From = m.Sender - inlineFaucet.Memo = memo - inlineFaucet.RemainingAmount = inlineFaucet.Amount - runtime.IgnoreError(bot.bunt.Set(inlineFaucet)) - -} - -func (bot TipBot) handleInlineFaucetQuery(q *tb.Query) { - inlineFaucet := NewInlineFaucet() - var err error - inlineFaucet.Amount, err = decodeAmountFromCommand(q.Text) - if err != nil { - bot.inlineQueryReplyWithError(q, inlineQueryFaucetTitle, fmt.Sprintf(inlineQueryFaucetDescription, bot.telegram.Me.Username)) - return - } - if inlineFaucet.Amount < 1 { - bot.inlineQueryReplyWithError(q, inlineSendInvalidAmountMessage, fmt.Sprintf(inlineQueryFaucetDescription, bot.telegram.Me.Username)) - return - } - - peruserStr, err := getArgumentFromCommand(q.Text, 2) - if err != nil { - bot.inlineQueryReplyWithError(q, inlineQueryFaucetTitle, fmt.Sprintf(inlineQueryFaucetDescription, bot.telegram.Me.Username)) - return - } - inlineFaucet.PerUserAmount, err = strconv.Atoi(peruserStr) - if err != nil { - bot.inlineQueryReplyWithError(q, inlineQueryFaucetTitle, fmt.Sprintf(inlineQueryFaucetDescription, bot.telegram.Me.Username)) - return - } - // peruser amount must be >1 and a divisor of amount - if inlineFaucet.PerUserAmount < 1 || inlineFaucet.Amount%inlineFaucet.PerUserAmount != 0 { - bot.inlineQueryReplyWithError(q, inlineFaucetInvalidPeruserAmountMessage, fmt.Sprintf(inlineQueryFaucetDescription, bot.telegram.Me.Username)) - return - } - inlineFaucet.NTotal = inlineFaucet.Amount / inlineFaucet.PerUserAmount - - 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 < inlineFaucet.Amount { - log.Errorln("Balance of user %s too low", fromUserStr) - bot.inlineQueryReplyWithError(q, fmt.Sprintf(inlineSendBalanceLowMessage, balance), fmt.Sprintf(inlineQueryFaucetDescription, bot.telegram.Me.Username)) - return - } - - // check for memo in command - memo := GetMemoFromCommand(q.Text, 3) - - urls := []string{ - queryImage, - } - results := make(tb.Results, len(urls)) // []tb.Result - for i, url := range urls { - inlineMessage := fmt.Sprintf(inlineFaucetMessage, inlineFaucet.PerUserAmount, inlineFaucet.Amount, inlineFaucet.Amount, 0, inlineFaucet.NTotal, MakeProgressbar(inlineFaucet.Amount, inlineFaucet.Amount)) - if len(memo) > 0 { - inlineMessage = inlineMessage + fmt.Sprintf(inlineFaucetAppendMemo, memo) - } - result := &tb.ArticleResult{ - // URL: url, - Text: inlineMessage, - Title: fmt.Sprintf(inlineResultFaucetTitle, inlineFaucet.Amount), - Description: fmt.Sprintf(inlineResultFaucetDescription, inlineFaucet.Amount), - // required for photos - ThumbURL: url, - } - id := fmt.Sprintf("inl-faucet-%d-%d-%s", q.From.ID, inlineFaucet.Amount, RandStringRunes(5)) - btnAcceptInlineFaucet.Data = id - btnCancelInlineFaucet.Data = id - inlineFaucetMenu.Inline(inlineFaucetMenu.Row(btnAcceptInlineFaucet, btnCancelInlineFaucet)) - result.ReplyMarkup = &tb.InlineKeyboardMarkup{InlineKeyboard: inlineFaucetMenu.InlineKeyboard} - results[i] = result - - // needed to set a unique string ID for each result - results[i].SetResultID(id) - - // create persistend inline send struct - inlineFaucet.Message = inlineMessage - inlineFaucet.ID = id - inlineFaucet.From = &q.From - inlineFaucet.RemainingAmount = inlineFaucet.Amount - inlineFaucet.Memo = memo - runtime.IgnoreError(bot.bunt.Set(inlineFaucet)) - } - - err = bot.telegram.Answer(q, &tb.QueryResponse{ - Results: results, - CacheTime: 1, - }) - log.Infof("[faucet] %s created inline faucet %s: %d sat (%d per user)", fromUserStr, inlineFaucet.ID, inlineFaucet.Amount, inlineFaucet.PerUserAmount) - if err != nil { - log.Errorln(err) - } -} - -func (bot *TipBot) accpetInlineFaucetHandler(c *tb.Callback) { - inlineFaucet, err := bot.getInlineFaucet(c) - if err != nil { - log.Errorf("[faucet] %s", err) - return - } - err = bot.LockFaucet(inlineFaucet) - if err != nil { - log.Errorf("[faucet] %s", err) - return - } - if !inlineFaucet.Active { - log.Errorf("[faucet] inline send not active anymore") - return - } - // release faucet no matter what - defer bot.ReleaseFaucet(inlineFaucet) - - to := c.Sender - from := inlineFaucet.From - - if from.ID == to.ID { - bot.trySendMessage(from, sendYourselfMessage) - return - } - // check if to user has already taken from the faucet - for _, a := range inlineFaucet.To { - if a.ID == to.ID { - // to user is already in To slice, has taken from facuet - log.Infof("[faucet] %s already took from faucet %s", GetUserStr(to), inlineFaucet.ID) - return - } - } - - if inlineFaucet.RemainingAmount >= inlineFaucet.PerUserAmount { - 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("[faucet] User %s has no wallet.", toUserStr) - err = bot.CreateWalletForTelegramUser(to) - if err != nil { - errmsg := fmt.Errorf("[faucet] Error: Could not create wallet for %s", toUserStr) - log.Errorln(errmsg) - return - } - } - - if !bot.UserInitializedWallet(to) { - inlineFaucet.UserNeedsWallet = true - } - - // todo: user new get username function to get userStrings - transactionMemo := fmt.Sprintf("Faucet from %s to %s (%d sat).", fromUserStr, toUserStr, inlineFaucet.PerUserAmount) - t := NewTransaction(bot, from, to, inlineFaucet.PerUserAmount, TransactionType("faucet")) - 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("[faucet] Transaction failed: %s", err) - log.Errorln(errMsg) - return - } - - log.Infof("[faucet] faucet %s: %d sat from %s to %s ", inlineFaucet.ID, inlineFaucet.PerUserAmount, fromUserStr, toUserStr) - inlineFaucet.NTaken += 1 - inlineFaucet.To = append(inlineFaucet.To, to) - inlineFaucet.RemainingAmount = inlineFaucet.RemainingAmount - inlineFaucet.PerUserAmount - - _, err = bot.telegram.Send(to, fmt.Sprintf(inlineFaucetReceivedMessage, fromUserStrMd, inlineFaucet.PerUserAmount)) - _, err = bot.telegram.Send(from, fmt.Sprintf(inlineFaucetSentMessage, inlineFaucet.PerUserAmount, toUserStrMd)) - if err != nil { - errmsg := fmt.Errorf("[faucet] Error: Send message to %s: %s", toUserStr, err) - log.Errorln(errmsg) - return - } - - // build faucet message - inlineFaucet.Message = fmt.Sprintf(inlineFaucetMessage, inlineFaucet.PerUserAmount, inlineFaucet.RemainingAmount, inlineFaucet.Amount, inlineFaucet.NTaken, inlineFaucet.NTotal, MakeProgressbar(inlineFaucet.RemainingAmount, inlineFaucet.Amount)) - memo := inlineFaucet.Memo - if len(memo) > 0 { - inlineFaucet.Message = inlineFaucet.Message + fmt.Sprintf(inlineFaucetAppendMemo, memo) - } - if inlineFaucet.UserNeedsWallet { - inlineFaucet.Message += "\n\n" + fmt.Sprintf(inlineFaucetCreateWalletMessage, GetUserStrMd(bot.telegram.Me)) - } - - // register new inline buttons - inlineFaucetMenu = &tb.ReplyMarkup{ResizeReplyKeyboard: true} - btnCancelInlineFaucet.Data = inlineFaucet.ID - btnAcceptInlineFaucet.Data = inlineFaucet.ID - inlineFaucetMenu.Inline(inlineFaucetMenu.Row(btnAcceptInlineFaucet, btnCancelInlineFaucet)) - // update message - log.Infoln(inlineFaucet.Message) - bot.tryEditMessage(c.Message, inlineFaucet.Message, inlineFaucetMenu) - } - if inlineFaucet.RemainingAmount < inlineFaucet.PerUserAmount { - // faucet is depleted - inlineFaucet.Message = fmt.Sprintf(inlineFaucetEndedMessage, inlineFaucet.Amount, inlineFaucet.NTaken) - if inlineFaucet.UserNeedsWallet { - inlineFaucet.Message += "\n\n" + fmt.Sprintf(inlineFaucetCreateWalletMessage, GetUserStrMd(bot.telegram.Me)) - } - bot.tryEditMessage(c.Message, inlineFaucet.Message) - inlineFaucet.Active = false - } - -} - -func (bot *TipBot) cancelInlineFaucetHandler(c *tb.Callback) { - inlineFaucet, err := bot.getInlineFaucet(c) - if err != nil { - log.Errorf("[cancelInlineSendHandler] %s", err) - return - } - if c.Sender.ID == inlineFaucet.From.ID { - bot.tryEditMessage(c.Message, inlineFaucetCancelledMessage, &tb.ReplyMarkup{}) - // set the inlineFaucet inactive - inlineFaucet.Active = false - inlineFaucet.InTransaction = false - runtime.IgnoreError(bot.bunt.Set(inlineFaucet)) - } - return -} diff --git a/inline_query.go b/inline_query.go deleted file mode 100644 index 515fb926..00000000 --- a/inline_query.go +++ /dev/null @@ -1,111 +0,0 @@ -package main - -import ( - "fmt" - "strconv" - "strings" - - log "github.com/sirupsen/logrus" - tb "gopkg.in/tucnak/telebot.v2" -) - -const queryImage = "https://avatars.githubusercontent.com/u/88730856?v=4" - -func (bot TipBot) inlineQueryInstructions(q *tb.Query) { - instructions := []struct { - url string - title string - description string - }{ - { - url: queryImage, - title: inlineQuerySendTitle, - description: fmt.Sprintf(inlineQuerySendDescription, bot.telegram.Me.Username), - }, - { - url: queryImage, - title: inlineQueryReceiveTitle, - description: fmt.Sprintf(inlineQueryReceiveDescription, bot.telegram.Me.Username), - }, - { - url: queryImage, - title: inlineQueryFaucetTitle, - description: fmt.Sprintf(inlineQueryFaucetDescription, 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) inlineQueryReplyWithError(q *tb.Query, message string, help string) { - results := make(tb.Results, 1) // []tb.Result - result := &tb.ArticleResult{ - // URL: url, - Text: help, - Title: message, - Description: help, - // required for photos - ThumbURL: queryImage, - } - id := fmt.Sprintf("inl-error-%d-%s", q.From.ID, RandStringRunes(5)) - result.SetResultID(id) - results[0] = result - 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) - } -} - -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 - } - - // create the inline send result - if strings.HasPrefix(q.Text, "/") { - q.Text = strings.TrimPrefix(q.Text, "/") - } - if strings.HasPrefix(q.Text, "send") || strings.HasPrefix(q.Text, "pay") { - bot.handleInlineSendQuery(q) - } - - if strings.HasPrefix(q.Text, "faucet") || strings.HasPrefix(q.Text, "giveaway") || strings.HasPrefix(q.Text, "zapfhahn") || strings.HasPrefix(q.Text, "kraan") { - bot.handleInlineFaucetQuery(q) - } - - if strings.HasPrefix(q.Text, "receive") || strings.HasPrefix(q.Text, "get") || strings.HasPrefix(q.Text, "payme") || strings.HasPrefix(q.Text, "request") { - bot.handleInlineReceiveQuery(q) - } -} diff --git a/inline_receive.go b/inline_receive.go deleted file mode 100644 index 56f202c3..00000000 --- a/inline_receive.go +++ /dev/null @@ -1,285 +0,0 @@ -package main - -import ( - "fmt" - "time" - - "github.com/LightningTipBot/LightningTipBot/internal/runtime" - log "github.com/sirupsen/logrus" - tb "gopkg.in/tucnak/telebot.v2" -) - -const ( - inlineReceiveMessage = "Press 💸 to pay to %s.\n\n💸 Amount: %d sat" - inlineReceiveAppendMemo = "\n✉️ %s" - inlineReceiveUpdateMessageAccept = "💸 %d sat sent from %s to %s." - inlineReceiveCreateWalletMessage = "Chat with %s 👈 to manage your wallet." - inlineReceiveYourselfMessage = "📖 You can't pay to yourself." - inlineReceiveFailedMessage = "🚫 Receive failed." -) - -var ( - inlineQueryReceiveTitle = "🏅 Request a payment in a chat." - inlineQueryReceiveDescription = "Usage: @%s receive []" - inlineResultReceiveTitle = "🏅 Receive %d sat." - inlineResultReceiveDescription = "👉 Click to request a payment of %d sat." - inlineReceiveMenu = &tb.ReplyMarkup{ResizeReplyKeyboard: true} - btnCancelInlineReceive = inlineReceiveMenu.Data("🚫 Cancel", "cancel_receive_inline") - btnAcceptInlineReceive = inlineReceiveMenu.Data("💸 Pay", "confirm_receive_inline") -) - -type InlineReceive struct { - Message string `json:"inline_receive_message"` - Amount int `json:"inline_receive_amount"` - From *tb.User `json:"inline_receive_from"` - To *tb.User `json:"inline_receive_to"` - Memo string - ID string `json:"inline_receive_id"` - Active bool `json:"inline_receive_active"` - InTransaction bool `json:"inline_receive_intransaction"` -} - -func NewInlineReceive() *InlineReceive { - inlineReceive := &InlineReceive{ - Message: "", - Active: true, - InTransaction: false, - } - return inlineReceive - -} - -func (msg InlineReceive) Key() string { - return msg.ID -} - -func (bot *TipBot) LockReceive(tx *InlineReceive) error { - // immediatelly set intransaction to block duplicate calls - tx.InTransaction = true - err := bot.bunt.Set(tx) - if err != nil { - return err - } - return nil -} - -func (bot *TipBot) ReleaseReceive(tx *InlineReceive) error { - // immediatelly set intransaction to block duplicate calls - tx.InTransaction = false - err := bot.bunt.Set(tx) - if err != nil { - return err - } - return nil -} - -func (bot *TipBot) inactivateReceive(tx *InlineReceive) error { - tx.Active = false - err := bot.bunt.Set(tx) - if err != nil { - return err - } - return nil -} - -// tipTooltipExists checks if this tip is already known -func (bot *TipBot) getInlineReceive(c *tb.Callback) (*InlineReceive, error) { - inlineReceive := NewInlineReceive() - inlineReceive.ID = c.Data - err := bot.bunt.Get(inlineReceive) - // to avoid race conditions, we block the call if there is - // already an active transaction by loop until InTransaction is false - ticker := time.NewTicker(time.Second * 10) - - for inlineReceive.InTransaction { - select { - case <-ticker.C: - return nil, fmt.Errorf("inline send timeout") - default: - log.Infoln("in transaction") - time.Sleep(time.Duration(500) * time.Millisecond) - err = bot.bunt.Get(inlineReceive) - } - } - if err != nil { - return nil, fmt.Errorf("could not get inline receive message") - } - return inlineReceive, nil - -} - -func (bot TipBot) handleInlineReceiveQuery(q *tb.Query) { - inlineReceive := NewInlineReceive() - var err error - inlineReceive.Amount, err = decodeAmountFromCommand(q.Text) - if err != nil { - bot.inlineQueryReplyWithError(q, inlineQueryReceiveTitle, fmt.Sprintf(inlineQueryReceiveDescription, bot.telegram.Me.Username)) - return - } - if inlineReceive.Amount < 1 { - bot.inlineQueryReplyWithError(q, inlineSendInvalidAmountMessage, fmt.Sprintf(inlineQueryReceiveDescription, bot.telegram.Me.Username)) - return - } - - fromUserStr := GetUserStr(&q.From) - - // check for memo in command - inlineReceive.Memo = GetMemoFromCommand(q.Text, 2) - - urls := []string{ - queryImage, - } - results := make(tb.Results, len(urls)) // []tb.Result - for i, url := range urls { - - inlineMessage := fmt.Sprintf(inlineReceiveMessage, fromUserStr, inlineReceive.Amount) - - if len(inlineReceive.Memo) > 0 { - inlineMessage = inlineMessage + fmt.Sprintf(inlineReceiveAppendMemo, inlineReceive.Memo) - } - - result := &tb.ArticleResult{ - // URL: url, - Text: inlineMessage, - Title: fmt.Sprintf(inlineResultReceiveTitle, inlineReceive.Amount), - Description: fmt.Sprintf(inlineResultReceiveDescription, inlineReceive.Amount), - // required for photos - ThumbURL: url, - } - id := fmt.Sprintf("inl-receive-%d-%d-%s", q.From.ID, inlineReceive.Amount, RandStringRunes(5)) - btnAcceptInlineReceive.Data = id - btnCancelInlineReceive.Data = id - inlineReceiveMenu.Inline(inlineReceiveMenu.Row(btnAcceptInlineReceive, btnCancelInlineReceive)) - result.ReplyMarkup = &tb.InlineKeyboardMarkup{InlineKeyboard: inlineReceiveMenu.InlineKeyboard} - - results[i] = result - - // needed to set a unique string ID for each result - results[i].SetResultID(id) - - // create persistend inline send struct - // add data to persistent object - inlineReceive.ID = id - inlineReceive.To = &q.From // The user who wants to receive - // add result to persistent struct - inlineReceive.Message = inlineMessage - runtime.IgnoreError(bot.bunt.Set(inlineReceive)) - } - - 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) - } -} - -func (bot *TipBot) acceptInlineReceiveHandler(c *tb.Callback) { - inlineReceive, err := bot.getInlineReceive(c) - // immediatelly set intransaction to block duplicate calls - if err != nil { - log.Errorf("[getInlineReceive] %s", err) - return - } - err = bot.LockReceive(inlineReceive) - if err != nil { - log.Errorf("[acceptInlineReceiveHandler] %s", err) - return - } - - if !inlineReceive.Active { - log.Errorf("[acceptInlineReceiveHandler] inline receive not active anymore") - return - } - - defer bot.ReleaseReceive(inlineReceive) - - // user `from` is the one who is SENDING - // user `to` is the one who is RECEIVING - from := c.Sender - to := inlineReceive.To - toUserStrMd := GetUserStrMd(to) - fromUserStrMd := GetUserStrMd(from) - toUserStr := GetUserStr(to) - fromUserStr := GetUserStr(from) - - if from.ID == to.ID { - bot.trySendMessage(from, sendYourselfMessage) - return - } - - // balance check of the user - balance, err := bot.GetUserBalance(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 < inlineReceive.Amount { - log.Errorln("[acceptInlineReceiveHandler] balance of user %s too low", fromUserStr) - bot.trySendMessage(from, fmt.Sprintf(inlineSendBalanceLowMessage, balance)) - return - } - - // set inactive to avoid double-sends - bot.inactivateReceive(inlineReceive) - - // todo: user new get username function to get userStrings - transactionMemo := fmt.Sprintf("Send from %s to %s (%d sat).", fromUserStr, toUserStr, inlineReceive.Amount) - t := NewTransaction(bot, from, to, inlineReceive.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("[acceptInlineReceiveHandler] Transaction failed: %s", err) - log.Errorln(errMsg) - bot.tryEditMessage(c.Message, inlineReceiveFailedMessage, &tb.ReplyMarkup{}) - return - } - - log.Infof("[acceptInlineReceiveHandler] %d sat from %s to %s", inlineReceive.Amount, fromUserStr, toUserStr) - - inlineReceive.Message = fmt.Sprintf("%s", fmt.Sprintf(inlineSendUpdateMessageAccept, inlineReceive.Amount, fromUserStrMd, toUserStrMd)) - memo := inlineReceive.Memo - if len(memo) > 0 { - inlineReceive.Message = inlineReceive.Message + fmt.Sprintf(inlineReceiveAppendMemo, memo) - } - - if !bot.UserInitializedWallet(to) { - inlineReceive.Message += "\n\n" + fmt.Sprintf(inlineSendCreateWalletMessage, GetUserStrMd(bot.telegram.Me)) - } - - bot.tryEditMessage(c.Message, inlineReceive.Message, &tb.ReplyMarkup{}) - // notify users - _, err = bot.telegram.Send(to, fmt.Sprintf(sendReceivedMessage, fromUserStrMd, inlineReceive.Amount)) - _, err = bot.telegram.Send(from, fmt.Sprintf(tipSentMessage, inlineReceive.Amount, toUserStrMd)) - if err != nil { - errmsg := fmt.Errorf("[acceptInlineReceiveHandler] Error: Receive message to %s: %s", toUserStr, err) - log.Errorln(errmsg) - return - } -} - -func (bot *TipBot) cancelInlineReceiveHandler(c *tb.Callback) { - inlineReceive, err := bot.getInlineReceive(c) - if err != nil { - log.Errorf("[cancelInlineReceiveHandler] %s", err) - return - } - if c.Sender.ID == inlineReceive.To.ID { - bot.tryEditMessage(c.Message, sendCancelledMessage, &tb.ReplyMarkup{}) - // set the inlineReceive inactive - inlineReceive.Active = false - inlineReceive.InTransaction = false - runtime.IgnoreError(bot.bunt.Set(inlineReceive)) - } - return -} diff --git a/inline_send.go b/inline_send.go deleted file mode 100644 index 80d54192..00000000 --- a/inline_send.go +++ /dev/null @@ -1,296 +0,0 @@ -package main - -import ( - "fmt" - "time" - - "github.com/LightningTipBot/LightningTipBot/internal/runtime" - log "github.com/sirupsen/logrus" - tb "gopkg.in/tucnak/telebot.v2" -) - -const ( - inlineSendMessage = "Press ✅ to receive payment from %s.\n\n💸 Amount: %d sat" - inlineSendAppendMemo = "\n✉️ %s" - inlineSendUpdateMessageAccept = "💸 %d sat sent from %s to %s." - inlineSendCreateWalletMessage = "Chat with %s 👈 to manage your wallet." - sendYourselfMessage = "📖 You can't pay to yourself." - inlineSendFailedMessage = "🚫 Send failed." - inlineSendInvalidAmountMessage = "🚫 Amount must be larger than 0." - inlineSendBalanceLowMessage = "🚫 Your balance is too low (👑 %d sat)." -) - -var ( - inlineQuerySendTitle = "💸 Send payment to a chat." - inlineQuerySendDescription = "Usage: @%s send []" - inlineResultSendTitle = "💸 Send %d sat." - inlineResultSendDescription = "👉 Click to send %d sat to this chat." - inlineSendMenu = &tb.ReplyMarkup{ResizeReplyKeyboard: true} - btnCancelInlineSend = inlineSendMenu.Data("🚫 Cancel", "cancel_send_inline") - btnAcceptInlineSend = inlineSendMenu.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 `json:"inline_send_memo"` - ID string `json:"inline_send_id"` - Active bool `json:"inline_send_active"` - InTransaction bool `json:"inline_send_intransaction"` -} - -func NewInlineSend() *InlineSend { - inlineSend := &InlineSend{ - Message: "", - Active: true, - InTransaction: false, - } - return inlineSend - -} - -func (msg InlineSend) Key() string { - return msg.ID -} - -func (bot *TipBot) LockSend(tx *InlineSend) error { - // immediatelly set intransaction to block duplicate calls - tx.InTransaction = true - err := bot.bunt.Set(tx) - if err != nil { - return err - } - return nil -} - -func (bot *TipBot) ReleaseSend(tx *InlineSend) error { - // immediatelly set intransaction to block duplicate calls - tx.InTransaction = false - err := bot.bunt.Set(tx) - if err != nil { - return err - } - return nil -} - -func (bot *TipBot) inactivateSend(tx *InlineSend) error { - tx.Active = false - err := bot.bunt.Set(tx) - if err != nil { - return err - } - return nil -} - -func (bot *TipBot) getInlineSend(c *tb.Callback) (*InlineSend, error) { - inlineSend := NewInlineSend() - inlineSend.ID = c.Data - - err := bot.bunt.Get(inlineSend) - - // to avoid race conditions, we block the call if there is - // already an active transaction by loop until InTransaction is false - ticker := time.NewTicker(time.Second * 10) - - for inlineSend.InTransaction { - select { - case <-ticker.C: - return nil, fmt.Errorf("inline send timeout") - default: - log.Infoln("in transaction") - time.Sleep(time.Duration(500) * time.Millisecond) - err = bot.bunt.Get(inlineSend) - } - } - if err != nil { - return nil, fmt.Errorf("could not get inline send message") - } - - return inlineSend, nil - -} - -func (bot TipBot) handleInlineSendQuery(q *tb.Query) { - inlineSend := NewInlineSend() - var err error - inlineSend.Amount, err = decodeAmountFromCommand(q.Text) - if err != nil { - bot.inlineQueryReplyWithError(q, inlineQuerySendTitle, fmt.Sprintf(inlineQuerySendDescription, bot.telegram.Me.Username)) - return - } - if inlineSend.Amount < 1 { - bot.inlineQueryReplyWithError(q, inlineSendInvalidAmountMessage, fmt.Sprintf(inlineQuerySendDescription, bot.telegram.Me.Username)) - 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 < inlineSend.Amount { - log.Errorln("Balance of user %s too low", fromUserStr) - bot.inlineQueryReplyWithError(q, fmt.Sprintf(inlineSendBalanceLowMessage, balance), fmt.Sprintf(inlineQuerySendDescription, bot.telegram.Me.Username)) - return - } - - // check for memo in command - inlineSend.Memo = GetMemoFromCommand(q.Text, 2) - - urls := []string{ - queryImage, - } - results := make(tb.Results, len(urls)) // []tb.Result - for i, url := range urls { - - inlineMessage := fmt.Sprintf(inlineSendMessage, fromUserStr, inlineSend.Amount) - - if len(inlineSend.Memo) > 0 { - inlineMessage = inlineMessage + fmt.Sprintf(inlineSendAppendMemo, inlineSend.Memo) - } - - result := &tb.ArticleResult{ - // URL: url, - Text: inlineMessage, - Title: fmt.Sprintf(inlineResultSendTitle, inlineSend.Amount), - Description: fmt.Sprintf(inlineResultSendDescription, inlineSend.Amount), - // required for photos - ThumbURL: url, - } - id := fmt.Sprintf("inl-send-%d-%d-%s", q.From.ID, inlineSend.Amount, RandStringRunes(5)) - btnAcceptInlineSend.Data = id - btnCancelInlineSend.Data = id - inlineSendMenu.Inline(inlineSendMenu.Row(btnAcceptInlineSend, btnCancelInlineSend)) - result.ReplyMarkup = &tb.InlineKeyboardMarkup{InlineKeyboard: inlineSendMenu.InlineKeyboard} - - results[i] = result - - // needed to set a unique string ID for each result - results[i].SetResultID(id) - - // add data to persistent object - inlineSend.Message = inlineMessage - inlineSend.ID = id - inlineSend.From = &q.From - // add result to persistent struct - 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) - } -} - -func (bot *TipBot) acceptInlineSendHandler(c *tb.Callback) { - inlineSend, err := bot.getInlineSend(c) - if err != nil { - log.Errorf("[acceptInlineSendHandler] %s", err) - return - } - // immediatelly set intransaction to block duplicate calls - err = bot.LockSend(inlineSend) - if err != nil { - log.Errorf("[getInlineSend] %s", err) - return - } - if !inlineSend.Active { - log.Errorf("[acceptInlineSendHandler] inline send not active anymore") - return - } - - defer bot.ReleaseSend(inlineSend) - - amount := inlineSend.Amount - to := c.Sender - from := inlineSend.From - - inlineSend.To = to - - 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 - } - } - // set inactive to avoid double-sends - bot.inactivateSend(inlineSend) - - // todo: user new get username function to get userStrings - transactionMemo := fmt.Sprintf("Send 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(inlineSendUpdateMessageAccept, 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(inlineSendCreateWalletMessage, GetUserStrMd(bot.telegram.Me)) - } - - bot.tryEditMessage(c.Message, inlineSend.Message, &tb.ReplyMarkup{}) - // 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 - } -} - -func (bot *TipBot) cancelInlineSendHandler(c *tb.Callback) { - inlineSend, err := bot.getInlineSend(c) - if err != nil { - log.Errorf("[cancelInlineSendHandler] %s", err) - return - } - if c.Sender.ID == inlineSend.From.ID { - bot.tryEditMessage(c.Message, sendCancelledMessage, &tb.ReplyMarkup{}) - // set the inlineSend inactive - inlineSend.Active = false - inlineSend.InTransaction = false - runtime.IgnoreError(bot.bunt.Set(inlineSend)) - } - return -} diff --git a/internal/api/admin/admin.go b/internal/api/admin/admin.go new file mode 100644 index 00000000..b34ca610 --- /dev/null +++ b/internal/api/admin/admin.go @@ -0,0 +1,15 @@ +package admin + +import ( + "github.com/LightningTipBot/LightningTipBot/internal/telegram" +) + +type Service struct { + bot *telegram.TipBot +} + +func New(b *telegram.TipBot) Service { + return Service{ + bot: b, + } +} diff --git a/internal/api/admin/ban.go b/internal/api/admin/ban.go new file mode 100644 index 00000000..80d41f38 --- /dev/null +++ b/internal/api/admin/ban.go @@ -0,0 +1,76 @@ +package admin + +import ( + "fmt" + "net/http" + "strings" + + "github.com/LightningTipBot/LightningTipBot/internal/lnbits" + "github.com/LightningTipBot/LightningTipBot/internal/telegram" + "github.com/gorilla/mux" + log "github.com/sirupsen/logrus" +) + +func (s Service) UnbanUser(w http.ResponseWriter, r *http.Request) { + user, err := s.getUserByTelegramId(r) + if err != nil { + log.Errorf("[ADMIN] could not ban user: %v", err) + w.WriteHeader(http.StatusBadRequest) + return + } + if !user.Banned && !strings.HasPrefix(user.Wallet.Adminkey, "banned_") { + log.Infof("[ADMIN] user is not banned. Aborting.") + w.WriteHeader(http.StatusBadRequest) + return + } + user.Banned = false + adminSlice := strings.Split(user.Wallet.Adminkey, "_") + user.Wallet.Adminkey = adminSlice[len(adminSlice)-1] + err = telegram.UpdateUserRecord(user, *s.bot) + if err != nil { + log.Errorf("[ADMIN] could not update user: %v", err) + return + } + log.Infof("[ADMIN] Unbanned user (%s)", user.ID) + w.WriteHeader(http.StatusOK) +} + +func (s Service) BanUser(w http.ResponseWriter, r *http.Request) { + user, err := s.getUserByTelegramId(r) + if err != nil { + log.Errorf("[ADMIN] could not ban user: %v", err) + w.WriteHeader(http.StatusBadRequest) + return + } + if user.Banned { + w.WriteHeader(http.StatusBadRequest) + log.Infof("[ADMIN] user is already banned. Aborting.") + return + } + user.Banned = true + if reason := r.URL.Query().Get("reason"); reason != "" { + user.Wallet.Adminkey = fmt.Sprintf("%s_%s", reason, user.Wallet.Adminkey) + } + user.Wallet.Adminkey = fmt.Sprintf("%s_%s", "banned", user.Wallet.Adminkey) + err = telegram.UpdateUserRecord(user, *s.bot) + if err != nil { + log.Errorf("[ADMIN] could not update user: %v", err) + return + } + + log.Infof("[ADMIN] Banned user (%s)", user.ID) + w.WriteHeader(http.StatusOK) +} + +func (s Service) getUserByTelegramId(r *http.Request) (*lnbits.User, error) { + user := &lnbits.User{} + v := mux.Vars(r) + if v["id"] == "" { + return nil, fmt.Errorf("invalid id") + } + tx := s.bot.DB.Users.Where("telegram_id = ? COLLATE NOCASE", v["id"]).First(user) + if tx.Error != nil { + return nil, tx.Error + } + return user, nil +} diff --git a/internal/api/admin/dalle.go b/internal/api/admin/dalle.go new file mode 100644 index 00000000..385b0a17 --- /dev/null +++ b/internal/api/admin/dalle.go @@ -0,0 +1,14 @@ +package admin + +import ( + "github.com/LightningTipBot/LightningTipBot/internal/dalle" + "net/http" +) + +func (s Service) DisableDalle(w http.ResponseWriter, r *http.Request) { + dalle.Enabled = false +} + +func (s Service) EnableDalle(w http.ResponseWriter, r *http.Request) { + dalle.Enabled = true +} diff --git a/internal/api/admin/pending_transactions.go b/internal/api/admin/pending_transactions.go new file mode 100644 index 00000000..21d2a80e --- /dev/null +++ b/internal/api/admin/pending_transactions.go @@ -0,0 +1,224 @@ +package admin + +import ( + "fmt" + "net/http" + "strings" + + "github.com/LightningTipBot/LightningTipBot/internal/api" + "github.com/LightningTipBot/LightningTipBot/internal/telegram" + "github.com/LightningTipBot/LightningTipBot/internal/thirdparty" + "github.com/gorilla/mux" + log "github.com/sirupsen/logrus" +) + +// ApprovePendingTransaction handles admin approval of pending transactions +func (s Service) ApprovePendingTransaction(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + transactionID := vars["id"] + + if transactionID == "" { + http.Error(w, "Transaction ID is required", http.StatusBadRequest) + return + } + + // Load pending transaction + pendingTx, err := api.LoadPendingTransaction(transactionID, s.bot) + if err != nil { + log.Errorf("[ADMIN] Failed to load pending transaction %s: %v", transactionID, err) + http.Error(w, "Transaction not found", http.StatusNotFound) + return + } + + // Check if transaction can be approved + if !pendingTx.CanBeApproved() { + log.Warnf("[ADMIN] Transaction %s cannot be approved: status=%s, expired=%v", + transactionID, pendingTx.Status, pendingTx.IsExpired()) + http.Error(w, fmt.Sprintf("Transaction cannot be approved: status=%s", pendingTx.Status), http.StatusBadRequest) + return + } + + // Approve the transaction + approverIP := getClientIP(r) + err = pendingTx.Approve(fmt.Sprintf("admin:%s", approverIP)) + if err != nil { + log.Errorf("[ADMIN] Failed to approve transaction %s: %v", transactionID, err) + http.Error(w, "Failed to approve transaction", http.StatusInternalServerError) + return + } + + // Save the updated transaction + err = pendingTx.SaveToDB(s.bot) + if err != nil { + log.Errorf("[ADMIN] Failed to save approved transaction %s: %v", transactionID, err) + http.Error(w, "Failed to save approval", http.StatusInternalServerError) + return + } + + // Execute the transaction + err = s.executePendingTransaction(pendingTx) + if err != nil { + log.Errorf("[ADMIN] Failed to execute approved transaction %s: %v", transactionID, err) + http.Error(w, fmt.Sprintf("Transaction approved but execution failed: %v", err), http.StatusInternalServerError) + return + } + + log.Infof("[ADMIN] Transaction %s approved and executed successfully", transactionID) + w.WriteHeader(http.StatusOK) + w.Write([]byte(fmt.Sprintf("Transaction %s approved and executed successfully", transactionID))) +} + +// RejectPendingTransaction handles admin rejection of pending transactions +func (s Service) RejectPendingTransaction(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + transactionID := vars["id"] + + if transactionID == "" { + http.Error(w, "Transaction ID is required", http.StatusBadRequest) + return + } + + // Load pending transaction + pendingTx, err := api.LoadPendingTransaction(transactionID, s.bot) + if err != nil { + log.Errorf("[ADMIN] Failed to load pending transaction %s: %v", transactionID, err) + http.Error(w, "Transaction not found", http.StatusNotFound) + return + } + + // Check if transaction can be rejected + if pendingTx.Status != api.StatusPending { + log.Warnf("[ADMIN] Transaction %s cannot be rejected: status=%s", transactionID, pendingTx.Status) + http.Error(w, fmt.Sprintf("Transaction cannot be rejected: status=%s", pendingTx.Status), http.StatusBadRequest) + return + } + + // Reject the transaction + rejectorIP := getClientIP(r) + err = pendingTx.Reject(fmt.Sprintf("admin:%s", rejectorIP)) + if err != nil { + log.Errorf("[ADMIN] Failed to reject transaction %s: %v", transactionID, err) + http.Error(w, "Failed to reject transaction", http.StatusInternalServerError) + return + } + + // Save the updated transaction + err = pendingTx.SaveToDB(s.bot) + if err != nil { + log.Errorf("[ADMIN] Failed to save rejected transaction %s: %v", transactionID, err) + http.Error(w, "Failed to save rejection", http.StatusInternalServerError) + return + } + + log.Infof("[ADMIN] Transaction %s rejected successfully", transactionID) + w.WriteHeader(http.StatusOK) + w.Write([]byte(fmt.Sprintf("Transaction %s rejected successfully", transactionID))) +} + +// ListPendingTransactions lists all pending transactions requiring approval +func (s Service) ListPendingTransactions(w http.ResponseWriter, r *http.Request) { + // This would need to be implemented to query all pending transactions + // For now, we'll return a placeholder response + log.Info("[ADMIN] List pending transactions requested") + w.WriteHeader(http.StatusOK) + w.Write([]byte("Pending transactions list - implementation needed")) +} + +// executePendingTransaction executes an approved pending transaction +func (s Service) executePendingTransaction(pendingTx *api.PendingTransaction) error { + // Load the users again to ensure they still exist and have wallets + fromUser, err := telegram.GetUserByTelegramUsername(pendingTx.FromUsername, *s.bot) + if err != nil { + return fmt.Errorf("sender user %s no longer exists or has no wallet: %v", pendingTx.FromUsername, err) + } + + toUser, err := telegram.GetUserByTelegramUsername(pendingTx.ToUsername, *s.bot) + if err != nil { + return fmt.Errorf("recipient user %s no longer exists or has no wallet: %v", pendingTx.ToUsername, err) + } + + // Check sender's balance again + balance, err := s.bot.GetUserBalance(fromUser) + if err != nil { + return fmt.Errorf("could not check sender balance: %v", err) + } + + if balance < pendingTx.Amount { + return fmt.Errorf("insufficient balance: %d sat available, %d sat required", balance, pendingTx.Amount) + } + + // Create transaction memo + fromUserStr := telegram.GetUserStr(fromUser.Telegram) + toUserStr := telegram.GetUserStr(toUser.Telegram) + transactionMemo := fmt.Sprintf("💸 Admin-approved API Send from %s to %s. TX ID: %s", fromUserStr, toUserStr, pendingTx.ID) + if pendingTx.Memo != "" { + transactionMemo += fmt.Sprintf(" Memo: %s", pendingTx.Memo) + } + + // Create and execute transaction + t := telegram.NewTransaction(s.bot, fromUser, toUser, pendingTx.Amount, telegram.TransactionType("api_send_admin_approved")) + t.Memo = transactionMemo + + success, err := t.Send() + if !success || err != nil { + return fmt.Errorf("transaction execution failed: %v", err) + } + + // Mark as executed + err = pendingTx.Execute() + if err != nil { + log.Warnf("[ADMIN] Failed to mark transaction as executed: %v", err) + } + + // Save the final state + err = pendingTx.SaveToDB(s.bot) + if err != nil { + log.Warnf("[ADMIN] Failed to save executed transaction state: %v", err) + } + + // Send notifications + fromUserStrMd := telegram.GetUserStrMd(fromUser.Telegram) + _, err = s.bot.Telegram.Send(toUser.Telegram, fmt.Sprintf("💰 You received %s from %s via admin-approved API payment", thirdparty.FormatSatsWithLKR(pendingTx.Amount), fromUserStrMd)) + if err != nil { + log.Warnf("[ADMIN] Could not send notification to recipient: %v", err) + } + + // Send memo if provided + if pendingTx.Memo != "" { + _, err = s.bot.Telegram.Send(toUser.Telegram, fmt.Sprintf("✉️ %s", pendingTx.Memo)) + if err != nil { + log.Warnf("[ADMIN] Could not send memo to recipient: %v", err) + } + } + + log.Infof("[ADMIN] ✅ Admin-approved API Send executed: %s -> %s (%d sat) [TX: %s]", + fromUserStr, toUserStr, pendingTx.Amount, pendingTx.ID) + + return nil +} + +// getClientIP extracts the real client IP from the request +func getClientIP(r *http.Request) string { + // Check X-Forwarded-For header first + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + // X-Forwarded-For can contain multiple IPs, take the first one + if ips := strings.Split(xff, ","); len(ips) > 0 { + return strings.TrimSpace(ips[0]) + } + } + + // Check X-Real-IP header + if xri := r.Header.Get("X-Real-IP"); xri != "" { + return xri + } + + // Fall back to RemoteAddr + if host := r.RemoteAddr; host != "" { + if idx := strings.LastIndex(host, ":"); idx != -1 { + return host[:idx] + } + return host + } + + return "unknown" +} diff --git a/internal/api/analytics.go b/internal/api/analytics.go new file mode 100644 index 00000000..7c9483d2 --- /dev/null +++ b/internal/api/analytics.go @@ -0,0 +1,620 @@ +package api + +import ( + "encoding/csv" + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "github.com/LightningTipBot/LightningTipBot/internal/lnbits" + "github.com/LightningTipBot/LightningTipBot/internal/telegram" + "github.com/gorilla/mux" + log "github.com/sirupsen/logrus" +) + +const ( + // maxAnalyticsLimit caps the maximum number of records per request to prevent memory exhaustion + maxAnalyticsLimit = 10000 + // maxAnalyticsOffset caps the offset to prevent abuse + maxAnalyticsOffset = 100000 + // minValidTimestamp is 2009-01-03 (Bitcoin genesis block) - no valid data before this + minValidTimestamp int64 = 1230940800 + // maxValidTimestamp is 2100-01-01 - reasonable upper bound + maxValidTimestamp int64 = 4102444800 +) + +// TransactionAnalyticsResponse represents the analytics data response +type TransactionAnalyticsResponse struct { + Status string `json:"status"` + ExternalPayments []ExternalPaymentData `json:"external_payments,omitempty"` + InternalTxs []InternalTransactionData `json:"internal_transactions,omitempty"` + Summary TransactionSummary `json:"summary"` + Filters map[string]string `json:"filters_applied"` +} + +// ExternalPaymentData represents external LNbits payment data +type ExternalPaymentData struct { + UserID int64 `json:"user_id"` + Username string `json:"username"` + CheckingID string `json:"checking_id"` + Pending bool `json:"pending"` + Amount int64 `json:"amount_msats"` + AmountSats int64 `json:"amount_sats"` + Fee int64 `json:"fee_msats"` + FeeSats int64 `json:"fee_sats"` + Memo string `json:"memo"` + Time int `json:"time"` + Timestamp string `json:"timestamp"` + PaymentType string `json:"payment_type"` // "incoming" or "outgoing" + Bolt11 string `json:"bolt11,omitempty"` + PaymentHash string `json:"payment_hash"` + WalletID string `json:"wallet_id"` +} + +// InternalTransactionData represents internal bot transactions +type InternalTransactionData struct { + ID uint `json:"id"` + Time string `json:"time"` + FromID int64 `json:"from_id"` + ToID int64 `json:"to_id"` + FromUser string `json:"from_user"` + ToUser string `json:"to_user"` + Type string `json:"type"` + Amount int64 `json:"amount_sats"` + ChatID int64 `json:"chat_id,omitempty"` + ChatName string `json:"chat_name,omitempty"` + Memo string `json:"memo"` + Success bool `json:"success"` +} + +// TransactionSummary provides aggregate statistics +type TransactionSummary struct { + TotalExternalCount int `json:"total_external_count"` + TotalInternalCount int `json:"total_internal_count"` + ExternalIncoming int64 `json:"external_incoming_sats"` + ExternalOutgoing int64 `json:"external_outgoing_sats"` + InternalVolume int64 `json:"internal_volume_sats"` + UniqueUsers int `json:"unique_users"` +} + +// GetTransactionAnalytics retrieves transaction data for analytics +// Endpoint: GET /api/v1/analytics/transactions +// Query Parameters: +// - user_id: Filter by specific user Telegram ID +// - username: Filter by username (without @) +// - start_date: Start date (YYYY-MM-DD or Unix timestamp) +// - end_date: End date (YYYY-MM-DD or Unix timestamp) +// - payment_type: Filter external payments by type (incoming/outgoing/all) +// - include_external: Include external LNbits payments (true/false, default: true) +// - include_internal: Include internal bot transactions (true/false, default: true) +// - limit: Maximum number of transactions per type (default: 1000) +// - offset: Number of transactions to skip for pagination (default: 0) +// - format: Response format - "json" (default) or "csv" +func (s Service) GetTransactionAnalytics(w http.ResponseWriter, r *http.Request) { + // Parse query parameters + params := r.URL.Query() + + userIDStr := params.Get("user_id") + username := params.Get("username") + startDateStr := params.Get("start_date") + endDateStr := params.Get("end_date") + paymentTypeFilter := params.Get("payment_type") + includeExternal := params.Get("include_external") != "false" + includeInternal := params.Get("include_internal") != "false" + limitStr := params.Get("limit") + offsetStr := params.Get("offset") + outputFormat := params.Get("format") + + // Set default limit with max cap + limit := 1000 + if limitStr != "" { + if parsedLimit, err := strconv.Atoi(limitStr); err == nil && parsedLimit > 0 { + limit = parsedLimit + } + } + if limit > maxAnalyticsLimit { + limit = maxAnalyticsLimit + } + + // Set default offset with max cap + offset := 0 + if offsetStr != "" { + if parsedOffset, err := strconv.Atoi(offsetStr); err == nil && parsedOffset >= 0 { + offset = parsedOffset + } + } + if offset > maxAnalyticsOffset { + offset = maxAnalyticsOffset + } + + // Parse dates + var startDate, endDate time.Time + var err error + + if startDateStr != "" { + startDate, err = parseDate(startDateStr) + if err != nil { + RespondError(w, "Invalid start_date format. Use YYYY-MM-DD or Unix timestamp") + return + } + } + + if endDateStr != "" { + endDate, err = parseDate(endDateStr) + if err != nil { + RespondError(w, "Invalid end_date format. Use YYYY-MM-DD or Unix timestamp") + return + } + } + + response := TransactionAnalyticsResponse{ + Status: StatusOk, + Filters: make(map[string]string), + } + + // Track applied filters + if userIDStr != "" { + response.Filters["user_id"] = userIDStr + } + if username != "" { + response.Filters["username"] = username + } + if startDateStr != "" { + response.Filters["start_date"] = startDateStr + } + if endDateStr != "" { + response.Filters["end_date"] = endDateStr + } + if paymentTypeFilter != "" { + response.Filters["payment_type"] = paymentTypeFilter + } + response.Filters["limit"] = strconv.Itoa(limit) + response.Filters["offset"] = strconv.Itoa(offset) + + var targetUsers []*lnbits.User + + // Find target user(s) + if userIDStr != "" { + userID, err := strconv.ParseInt(userIDStr, 10, 64) + if err != nil { + RespondError(w, "Invalid user_id") + return + } + user := &lnbits.User{} + tx := s.Bot.DB.Users.Where("telegram_id = ?", userID).First(user) + if tx.Error != nil { + RespondError(w, "User not found") + return + } + targetUsers = append(targetUsers, user) + } else if username != "" { + user := &lnbits.User{} + tx := s.Bot.DB.Users.Where("telegram_username = ?", username).First(user) + if tx.Error != nil { + RespondError(w, "User not found") + return + } + targetUsers = append(targetUsers, user) + } else { + // Get all users if no specific user requested + var allUsers []*lnbits.User + tx := s.Bot.DB.Users.Find(&allUsers) + if tx.Error != nil { + RespondError(w, "Error fetching users") + return + } + targetUsers = allUsers + } + + uniqueUserMap := make(map[int64]bool) + + // Fetch external payments from LNbits with configurable limit+offset + if includeExternal { + for _, user := range targetUsers { + if user.Wallet == nil { + continue + } + + uniqueUserMap[user.Telegram.ID] = true + + payments, err := s.Bot.Client.PaymentsWithOptions(*user.Wallet, limit+offset, 0) + if err != nil { + log.Errorf("[Analytics] Error fetching payments for user %d: %s", user.Telegram.ID, err.Error()) + continue + } + + for _, payment := range payments { + // Apply date filters + paymentTime := time.Unix(int64(payment.Time), 0) + if !startDate.IsZero() && paymentTime.Before(startDate) { + continue + } + if !endDate.IsZero() && paymentTime.After(endDate) { + continue + } + + // Determine payment type + paymentType := "outgoing" + if payment.Amount > 0 { + paymentType = "incoming" + } + + // Apply payment type filter + if paymentTypeFilter != "" && paymentTypeFilter != "all" && paymentTypeFilter != paymentType { + continue + } + + // Check limit + if len(response.ExternalPayments) >= limit { + break + } + + externalPayment := ExternalPaymentData{ + UserID: user.Telegram.ID, + Username: user.Telegram.Username, + CheckingID: payment.CheckingID, + Pending: payment.Pending, + Amount: payment.Amount, + AmountSats: payment.Amount / 1000, + Fee: payment.Fee, + FeeSats: payment.Fee / 1000, + Memo: payment.Memo, + Time: payment.Time, + Timestamp: paymentTime.Format(time.RFC3339), + PaymentType: paymentType, + Bolt11: payment.Bolt11, + PaymentHash: payment.PaymentHash, + WalletID: payment.WalletID, + } + + response.ExternalPayments = append(response.ExternalPayments, externalPayment) + + // Update summary + response.Summary.TotalExternalCount++ + if paymentType == "incoming" { + response.Summary.ExternalIncoming += externalPayment.AmountSats + } else { + response.Summary.ExternalOutgoing += abs(externalPayment.AmountSats) + } + } + } + } + + // Fetch internal transactions from bot database + if includeInternal { + var internalTxs []telegram.Transaction + dbQuery := s.Bot.DB.Transactions.Model(&telegram.Transaction{}) + + // Apply filters + if userIDStr != "" { + userID, _ := strconv.ParseInt(userIDStr, 10, 64) + dbQuery = dbQuery.Where("from_id = ? OR to_id = ?", userID, userID) + } else if username != "" { + dbQuery = dbQuery.Where("from_user = ? OR to_user = ?", username, username) + } + + if !startDate.IsZero() { + dbQuery = dbQuery.Where("time >= ?", startDate) + } + + if !endDate.IsZero() { + dbQuery = dbQuery.Where("time <= ?", endDate) + } + + dbQuery = dbQuery.Order("time desc").Limit(limit).Offset(offset).Find(&internalTxs) + + if dbQuery.Error != nil { + log.Errorf("[Analytics] Error fetching internal transactions: %s", dbQuery.Error) + } else { + for _, tx := range internalTxs { + uniqueUserMap[tx.FromId] = true + uniqueUserMap[tx.ToId] = true + + internalTx := InternalTransactionData{ + ID: tx.ID, + Time: tx.Time.Format(time.RFC3339), + FromID: tx.FromId, + ToID: tx.ToId, + FromUser: tx.FromUser, + ToUser: tx.ToUser, + Type: tx.Type, + Amount: tx.Amount, + ChatID: tx.ChatID, + ChatName: tx.ChatName, + Memo: tx.Memo, + Success: tx.Success, + } + + response.InternalTxs = append(response.InternalTxs, internalTx) + + // Update summary + response.Summary.TotalInternalCount++ + if tx.Success { + response.Summary.InternalVolume += tx.Amount + } + } + } + } + + response.Summary.UniqueUsers = len(uniqueUserMap) + + // Respond in requested format + if outputFormat == "csv" { + writeCSVResponse(w, response) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(response) +} + +// GetUserTransactionHistory retrieves all transactions for a specific user +// Endpoint: GET /api/v1/analytics/user/{user_id}/transactions +// Query Parameters: +// - limit: Maximum number of transactions per type (default: 1000) +// - offset: Number of transactions to skip for pagination (default: 0) +// - format: Response format - "json" (default) or "csv" +func (s Service) GetUserTransactionHistory(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + userIDStr := vars["user_id"] + params := r.URL.Query() + outputFormat := params.Get("format") + + // Parse limit and offset with max caps + limit := 1000 + if limitStr := params.Get("limit"); limitStr != "" { + if parsedLimit, err := strconv.Atoi(limitStr); err == nil && parsedLimit > 0 { + limit = parsedLimit + } + } + if limit > maxAnalyticsLimit { + limit = maxAnalyticsLimit + } + offset := 0 + if offsetStr := params.Get("offset"); offsetStr != "" { + if parsedOffset, err := strconv.Atoi(offsetStr); err == nil && parsedOffset >= 0 { + offset = parsedOffset + } + } + if offset > maxAnalyticsOffset { + offset = maxAnalyticsOffset + } + + userID, err := strconv.ParseInt(userIDStr, 10, 64) + if err != nil { + RespondError(w, "Invalid user_id") + return + } + + user := &lnbits.User{} + tx := s.Bot.DB.Users.Where("telegram_id = ?", userID).First(user) + if tx.Error != nil { + RespondError(w, "User not found") + return + } + + response := TransactionAnalyticsResponse{ + Status: StatusOk, + Filters: map[string]string{ + "user_id": userIDStr, + "limit": strconv.Itoa(limit), + "offset": strconv.Itoa(offset), + }, + } + + // Fetch external payments from LNbits + if user.Wallet != nil { + payments, err := s.Bot.Client.PaymentsWithOptions(*user.Wallet, limit+offset, 0) + if err != nil { + log.Errorf("[Analytics] Error fetching payments for user %d: %s", userID, err.Error()) + } else { + for _, payment := range payments { + paymentTime := time.Unix(int64(payment.Time), 0) + paymentType := "outgoing" + if payment.Amount > 0 { + paymentType = "incoming" + } + + if len(response.ExternalPayments) >= limit { + break + } + + externalPayment := ExternalPaymentData{ + UserID: user.Telegram.ID, + Username: user.Telegram.Username, + CheckingID: payment.CheckingID, + Pending: payment.Pending, + Amount: payment.Amount, + AmountSats: payment.Amount / 1000, + Fee: payment.Fee, + FeeSats: payment.Fee / 1000, + Memo: payment.Memo, + Time: payment.Time, + Timestamp: paymentTime.Format(time.RFC3339), + PaymentType: paymentType, + Bolt11: payment.Bolt11, + PaymentHash: payment.PaymentHash, + WalletID: payment.WalletID, + } + + response.ExternalPayments = append(response.ExternalPayments, externalPayment) + response.Summary.TotalExternalCount++ + + if paymentType == "incoming" { + response.Summary.ExternalIncoming += externalPayment.AmountSats + } else { + response.Summary.ExternalOutgoing += abs(externalPayment.AmountSats) + } + } + } + } + + // Fetch internal transactions + var internalTxs []telegram.Transaction + s.Bot.DB.Transactions.Where("from_id = ? OR to_id = ?", userID, userID). + Order("time desc"). + Limit(limit).Offset(offset). + Find(&internalTxs) + + for _, tx := range internalTxs { + internalTx := InternalTransactionData{ + ID: tx.ID, + Time: tx.Time.Format(time.RFC3339), + FromID: tx.FromId, + ToID: tx.ToId, + FromUser: tx.FromUser, + ToUser: tx.ToUser, + Type: tx.Type, + Amount: tx.Amount, + ChatID: tx.ChatID, + ChatName: tx.ChatName, + Memo: tx.Memo, + Success: tx.Success, + } + + response.InternalTxs = append(response.InternalTxs, internalTx) + response.Summary.TotalInternalCount++ + + if tx.Success { + response.Summary.InternalVolume += tx.Amount + } + } + + response.Summary.UniqueUsers = 1 + + // Respond in requested format + if outputFormat == "csv" { + writeCSVResponse(w, response) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(response) +} + +// parseDate parses a date string and validates it falls within reasonable bounds. +func parseDate(dateStr string) (time.Time, error) { + var t time.Time + + // Try parsing as Unix timestamp first + if timestamp, err := strconv.ParseInt(dateStr, 10, 64); err == nil { + if timestamp < minValidTimestamp || timestamp > maxValidTimestamp { + return time.Time{}, fmt.Errorf("timestamp out of range: %d", timestamp) + } + return time.Unix(timestamp, 0), nil + } + + // Try parsing as date string + layouts := []string{ + "2006-01-02", + "2006-01-02T15:04:05", + time.RFC3339, + } + + for _, layout := range layouts { + if parsed, err := time.Parse(layout, dateStr); err == nil { + t = parsed + break + } + } + + if t.IsZero() { + return time.Time{}, fmt.Errorf("unsupported date format: %s", dateStr) + } + + // Validate parsed date is within reasonable bounds + if t.Unix() < minValidTimestamp || t.Unix() > maxValidTimestamp { + return time.Time{}, fmt.Errorf("date out of range: %s", dateStr) + } + + return t, nil +} + +// Helper function to get absolute value +func abs(n int64) int64 { + if n < 0 { + return -n + } + return n +} + +// sanitizeCSVField prevents CSV formula injection by prefixing dangerous characters +// with a single quote. Fields starting with =, +, -, @, tab, or carriage return +// can be interpreted as formulas by spreadsheet software. +func sanitizeCSVField(s string) string { + s = strings.ReplaceAll(s, "\n", " ") + s = strings.ReplaceAll(s, "\r", " ") + if len(s) > 0 { + switch s[0] { + case '=', '+', '-', '@', '\t': + return "'" + s + } + } + return s +} + +// writeCSVResponse writes the analytics response as a CSV file +func writeCSVResponse(w http.ResponseWriter, response TransactionAnalyticsResponse) { + w.Header().Set("Content-Type", "text/csv") + w.Header().Set("Content-Disposition", "attachment; filename=transactions.csv") + w.WriteHeader(http.StatusOK) + + writer := csv.NewWriter(w) + defer writer.Flush() + + // Write header + writer.Write([]string{ + "source", "id", "time", "user_id", "username", "from_id", "from_user", + "to_id", "to_user", "type", "amount_sats", "fee_sats", "memo", + "payment_type", "pending", "success", "payment_hash", "chat_id", "chat_name", + }) + + // Write external payments + for _, p := range response.ExternalPayments { + writer.Write([]string{ + "external", + p.CheckingID, + p.Timestamp, + strconv.FormatInt(p.UserID, 10), + sanitizeCSVField(p.Username), + "", "", "", "", // from/to fields not applicable + p.PaymentType, + strconv.FormatInt(p.AmountSats, 10), + strconv.FormatInt(p.FeeSats, 10), + sanitizeCSVField(p.Memo), + p.PaymentType, + strconv.FormatBool(p.Pending), + "", // success not applicable + p.PaymentHash, + "", "", // chat fields not applicable + }) + } + + // Write internal transactions + for _, t := range response.InternalTxs { + writer.Write([]string{ + "internal", + strconv.FormatUint(uint64(t.ID), 10), + t.Time, + "", "", // user_id/username not applicable + strconv.FormatInt(t.FromID, 10), + sanitizeCSVField(t.FromUser), + strconv.FormatInt(t.ToID, 10), + sanitizeCSVField(t.ToUser), + sanitizeCSVField(t.Type), + strconv.FormatInt(t.Amount, 10), + "", // fee not applicable + sanitizeCSVField(t.Memo), + "", "", // payment_type/pending not applicable + strconv.FormatBool(t.Success), + "", // payment_hash not applicable + strconv.FormatInt(t.ChatID, 10), + sanitizeCSVField(t.ChatName), + }) + } +} diff --git a/internal/api/invoice.go b/internal/api/invoice.go new file mode 100644 index 00000000..0bd42018 --- /dev/null +++ b/internal/api/invoice.go @@ -0,0 +1,27 @@ +package api + +type BalanceResponse struct { + Balance int64 `json:"balance"` +} + +type InvoiceStatusResponse struct { + State string `json:"state,omitempty"` + PaymentHash string `json:"payment_hash"` + Preimage int64 `json:"preimage"` +} + +type CreateInvoiceResponse struct { + PaymentHash string `json:"payment_hash"` + PayRequest string `json:"pay_request"` + Preimage string `json:"preimage,omitempty"` +} +type CreateInvoiceRequest struct { + Memo string `json:"memo"` + Amount int64 `json:"amount"` + DescriptionHash string `json:"description_hash"` + UnhashedDescription string `json:"unhashed_description"` +} + +type PayInvoiceRequest struct { + PayRequest string `json:"pay_req"` +} diff --git a/internal/api/lightning.go b/internal/api/lightning.go new file mode 100644 index 00000000..bbdcb1d7 --- /dev/null +++ b/internal/api/lightning.go @@ -0,0 +1,193 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/LightningTipBot/LightningTipBot/internal" + "github.com/LightningTipBot/LightningTipBot/internal/lnbits" + "github.com/LightningTipBot/LightningTipBot/internal/telegram" + "github.com/LightningTipBot/LightningTipBot/internal/utils" + "github.com/gorilla/mux" + "github.com/r3labs/sse" +) + +type Service struct { + Bot *telegram.TipBot + MemoCache *utils.Cache +} + +type ErrorResponse struct { + Message string `json:"error"` +} + +func RespondError(w http.ResponseWriter, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(ErrorResponse{Message: message}) +} + +func (s Service) Balance(w http.ResponseWriter, r *http.Request) { + user := telegram.LoadUser(r.Context()) + balance, err := s.Bot.GetUserBalance(user) + if err != nil { + RespondError(w, "balance check failed") + return + } + + balanceResponse := BalanceResponse{ + Balance: balance, + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(balanceResponse) +} + +func (s Service) CreateInvoice(w http.ResponseWriter, r *http.Request) { + user := telegram.LoadUser(r.Context()) + var createInvoiceRequest CreateInvoiceRequest + err := json.NewDecoder(r.Body).Decode(&createInvoiceRequest) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + invoice, err := user.Wallet.Invoice( + lnbits.InvoiceParams{ + Amount: createInvoiceRequest.Amount, + Out: false, + DescriptionHash: createInvoiceRequest.DescriptionHash, + UnhashedDescription: createInvoiceRequest.UnhashedDescription, + Memo: createInvoiceRequest.Memo, + Webhook: internal.GetWebhookURL()}, + s.Bot.Client) + if err != nil { + RespondError(w, "could not create invoice") + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(invoice) +} + +func (s Service) PayInvoice(w http.ResponseWriter, r *http.Request) { + user := telegram.LoadUser(r.Context()) + var payInvoiceRequest PayInvoiceRequest + err := json.NewDecoder(r.Body).Decode(&payInvoiceRequest) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + invoice, err := user.Wallet.Pay(lnbits.PaymentParams{Out: true, Bolt11: payInvoiceRequest.PayRequest}, s.Bot.Client) + if err != nil { + if s.Bot.ErrorLogger != nil { + s.Bot.ErrorLogger.LogAPIError(err, "PayInvoice API", user.Telegram) + } + RespondError(w, "could not pay invoice: "+err.Error()) + return + } + + payment, _ := s.Bot.Client.Payment(*user.Wallet, invoice.PaymentHash) + if err != nil { + // we assume that it's paid since thre was no error earlier + payment.Paid = true + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(payment) +} + +func (s Service) PaymentStatus(w http.ResponseWriter, r *http.Request) { + user := telegram.LoadUser(r.Context()) + payment_hash := mux.Vars(r)["payment_hash"] + payment, err := s.Bot.Client.Payment(*user.Wallet, payment_hash) + if err != nil { + RespondError(w, "could not get payment") + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(payment) +} + +// InvoiceStatus +func (s Service) InvoiceStatus(w http.ResponseWriter, r *http.Request) { + user := telegram.LoadUser(r.Context()) + payment_hash := mux.Vars(r)["payment_hash"] + user.Wallet = &lnbits.Wallet{} + payment, err := s.Bot.Client.Payment(*user.Wallet, payment_hash) + if err != nil { + RespondError(w, "could not get invoice") + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(payment) +} + +type InvoiceStream struct { + CheckingID string `json:"checking_id"` + Pending bool `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"` +} + +func (s Service) InvoiceStream(w http.ResponseWriter, r *http.Request) { + user := telegram.LoadUser(r.Context()) + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "Streaming unsupported!", http.StatusInternalServerError) + return + } + client := sse.NewClient(fmt.Sprintf("%s/api/v1/payments/sse", internal.Configuration.Lnbits.Url)) + client.Connection.Transport = &http.Transport{DisableCompression: true} + client.Headers = map[string]string{"X-Api-Key": user.Wallet.Inkey} + c := make(chan *sse.Event) + err := client.SubscribeChan("", c) + if err != nil { + http.Error(w, "Streaming unsupported!", http.StatusInternalServerError) + return + } +out: + for { + select { + case msg := <-c: + select { + case <-r.Context().Done(): + client.Unsubscribe(c) + break out + default: + written, err := fmt.Fprintf(w, "event: %s\n", string(msg.Event)) + if err != nil || written == 0 { + break out + } + written, err = fmt.Fprintf(w, "data: %s\n", string(msg.Data)) + if err != nil || written == 0 { + break out + } + written, err = fmt.Fprint(w, "\n") + if err != nil || written == 0 { + break out + } + flusher.Flush() + } + } + + } + close(c) + time.Sleep(time.Second * 5) +} diff --git a/internal/api/middleware.go b/internal/api/middleware.go new file mode 100644 index 00000000..c4c87299 --- /dev/null +++ b/internal/api/middleware.go @@ -0,0 +1,297 @@ +package api + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "fmt" + "io" + "net/http" + "net/http/httputil" + "strconv" + "strings" + "time" + + "github.com/LightningTipBot/LightningTipBot/internal" + "github.com/LightningTipBot/LightningTipBot/internal/lnbits" + "github.com/LightningTipBot/LightningTipBot/internal/telegram" + "gorm.io/gorm" + + log "github.com/sirupsen/logrus" +) + +func LoggingMiddleware(prefix string, next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + log.Tracef("[%s] %s %s", prefix, r.Method, r.URL.Path) + log.Tracef("[%s]\n%s", prefix, dump(r)) + r.BasicAuth() + next.ServeHTTP(w, r) + } +} + +type AuthType struct { + Type string + Decoder func(s string) ([]byte, error) +} + +var AuthTypeBasic = AuthType{Type: "Basic"} +var AuthTypeBearerBase64 = AuthType{Type: "Bearer", Decoder: base64.StdEncoding.DecodeString} +var AuthTypeNone = AuthType{} + +// invoice key or admin key requirement +type AccessKeyType struct { + Type string +} + +var AccessKeyTypeInvoice = AccessKeyType{Type: "invoice"} +var AccessKeyTypeAdmin = AccessKeyType{Type: "admin"} +var AccessKeyTypeNone = AccessKeyType{Type: "none"} // no authorization required + +func AuthorizationMiddleware(database *gorm.DB, authType AuthType, accessType AccessKeyType, next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if accessType.Type == "none" { + next.ServeHTTP(w, r) + return + } + auth := r.Header.Get("Authorization") + // check if the user is banned + if auth == "" { + w.WriteHeader(401) + log.Warn("[api] no auth") + return + } + _, password, ok := parseAuth(authType, auth) + if !ok { + w.WriteHeader(401) + return + } + // first we make sure that the password is not already "banned_" + if strings.Contains(password, "_") || strings.HasPrefix(password, "banned_") { + w.WriteHeader(401) + log.Warnf("[api] Banned user %s. Not forwarding request", password) + return + } + // then we check whether the "normal" password provided is in the database (it should be not if the user is banned) + + user := &lnbits.User{} + var tx *gorm.DB + if accessType.Type == "admin" { + tx = database.Where("wallet_adminkey = ? COLLATE NOCASE", password).First(user) + } else if accessType.Type == "invoice" { + tx = database.Where("wallet_inkey = ? OR wallet_adminkey = ? COLLATE NOCASE", password, password).First(user) + } else { + log.Errorf("[api] route without access type") + w.WriteHeader(401) + return + } + if tx.Error != nil { + log.Warnf("[api] could not load access key: %v", tx.Error) + w.WriteHeader(401) + return + } + + log.Debugf("[api] User: %s Endpoint: %s %s %s", telegram.GetUserStr(user.Telegram), r.Method, r.URL.Path, r.URL.RawQuery) + r = r.WithContext(context.WithValue(r.Context(), "user", user)) + next.ServeHTTP(w, r) + } +} + +// parseAuth parses an HTTP Basic Authentication string. +// "Bearer QWxhZGRpbjpvcGVuIHNlc2FtZQ==" returns ("Aladdin", "open sesame", true). +func parseAuth(authType AuthType, auth string) (username, password string, ok bool) { + parse := func(prefix string) (username, password string, ok bool) { + // Case insensitive prefix match. See Issue 22736. + if len(auth) < len(prefix) || !strings.EqualFold(auth[:len(prefix)], prefix) { + return + } + if authType.Decoder != nil { + c, err := authType.Decoder(auth[len(prefix):]) + if err != nil { + return + } + cs := string(c) + s := strings.IndexByte(cs, ':') + if s < 0 { + return + } + return cs[:s], cs[s+1:], true + } + return auth[len(prefix):], auth[len(prefix):], true + + } + return parse(fmt.Sprintf("%s ", authType.Type)) + +} + +func dump(r *http.Request) string { + x, err := httputil.DumpRequest(r, true) + if err != nil { + return "" + } + return string(x) +} + +// WalletHMACMiddleware validates HMAC signatures for wallet-based API endpoints +// It identifies the sending wallet by validating the signature against each whitelisted wallet's secret +func WalletHMACMiddleware(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Get timestamp from header for replay attack prevention + timestampStr := r.Header.Get("X-Timestamp") + if timestampStr == "" { + log.Warn("Missing timestamp in wallet API request") + http.Error(w, "Missing timestamp", http.StatusUnauthorized) + return + } + + // Parse timestamp + timestamp, err := strconv.ParseInt(timestampStr, 10, 64) + if err != nil { + log.Warn("Invalid timestamp format in wallet API request") + http.Error(w, "Invalid timestamp", http.StatusBadRequest) + return + } + + // Check if request is not too old (prevent replay attacks) + now := time.Now().Unix() + tolerance := internal.Configuration.API.Send.TimestampTolerance + if tolerance == 0 { + tolerance = 300 // Default 5 minutes + } + + if now-timestamp > tolerance { + log.Warnf("Request timestamp too old (age: %d seconds, tolerance: %d)", now-timestamp, tolerance) + http.Error(w, "Request expired", http.StatusUnauthorized) + return + } + + // Get signature from header + signature := r.Header.Get("X-HMAC-Signature") + if signature == "" { + log.Warn("Missing HMAC signature in wallet API request") + http.Error(w, "Missing signature", http.StatusUnauthorized) + return + } + + // Read request body + body, err := io.ReadAll(r.Body) + if err != nil { + log.Error("Failed to read request body for HMAC verification: ", err) + http.Error(w, "Failed to read request", http.StatusBadRequest) + return + } + + // Restore body for next handler + r.Body = io.NopCloser(strings.NewReader(string(body))) + + // Create message to sign: METHOD + PATH + TIMESTAMP + BODY + message := fmt.Sprintf("%s%s%s%s", r.Method, r.URL.Path, timestampStr, string(body)) + + // Try to validate signature against each whitelisted wallet + var authenticatedWallet string + for walletID, wallet := range internal.Configuration.API.Send.WhitelistedWallets { + expectedSignature := calculateHMAC(message, wallet.HMACSecret) + if hmac.Equal([]byte(signature), []byte(expectedSignature)) { + authenticatedWallet = walletID + log.Debugf("HMAC signature verified for wallet: %s", walletID) + break + } + } + + if authenticatedWallet == "" { + log.Warn("HMAC signature verification failed - no matching wallet found") + http.Error(w, "Invalid signature", http.StatusUnauthorized) + return + } + + // Add authenticated wallet info to request context + ctx := context.WithValue(r.Context(), "authenticated_wallet", authenticatedWallet) + r = r.WithContext(ctx) + + log.Debugf("Wallet API request authenticated for wallet: %s", authenticatedWallet) + next.ServeHTTP(w, r) + } +} + +// calculateHMAC calculates HMAC-SHA256 signature +func calculateHMAC(message, secret string) string { + h := hmac.New(sha256.New, []byte(secret)) + h.Write([]byte(message)) + return hex.EncodeToString(h.Sum(nil)) +} + +// GenerateHMACSignature helper function for clients +func GenerateHMACSignature(method, path, timestamp, body, secret string) string { + message := fmt.Sprintf("%s%s%s%s", method, path, timestamp, body) + return calculateHMAC(message, secret) +} + +// AnalyticsHMACMiddleware validates HMAC signatures for analytics API endpoints +func AnalyticsHMACMiddleware(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Get timestamp from header + timestampStr := r.Header.Get("X-Timestamp") + if timestampStr == "" { + log.Warn("[Analytics] Missing timestamp in request") + http.Error(w, "Missing timestamp", http.StatusUnauthorized) + return + } + + timestamp, err := strconv.ParseInt(timestampStr, 10, 64) + if err != nil { + log.Warn("[Analytics] Invalid timestamp format") + http.Error(w, "Invalid timestamp", http.StatusBadRequest) + return + } + + // Check if request is not too old (prevent replay attacks) + now := time.Now().Unix() + tolerance := internal.Configuration.API.Analytics.TimestampTolerance + if tolerance == 0 { + tolerance = 300 + } + + if now-timestamp > tolerance { + log.Warnf("[Analytics] Request timestamp too old (age: %d seconds)", now-timestamp) + http.Error(w, "Request expired", http.StatusUnauthorized) + return + } + + // Get signature from header + signature := r.Header.Get("X-HMAC-Signature") + if signature == "" { + log.Warn("[Analytics] Missing HMAC signature") + http.Error(w, "Missing signature", http.StatusUnauthorized) + return + } + + // For GET requests, use query string as the body component + bodyComponent := r.URL.RawQuery + + // Create message to sign: METHOD + PATH + TIMESTAMP + QUERY + message := fmt.Sprintf("%s%s%s%s", r.Method, r.URL.Path, timestampStr, bodyComponent) + + // Try to validate signature against each configured analytics API key + var authenticatedKey string + for keyID, apiKey := range internal.Configuration.API.Analytics.APIKeys { + expectedSignature := calculateHMAC(message, apiKey.HMACSecret) + if hmac.Equal([]byte(signature), []byte(expectedSignature)) { + authenticatedKey = keyID + log.Debugf("[Analytics] HMAC verified for key: %s (%s)", keyID, apiKey.Name) + break + } + } + + if authenticatedKey == "" { + log.Warn("[Analytics] HMAC signature verification failed") + http.Error(w, "Invalid signature", http.StatusUnauthorized) + return + } + + ctx := context.WithValue(r.Context(), "analytics_api_key", authenticatedKey) + r = r.WithContext(ctx) + + next.ServeHTTP(w, r) + } +} diff --git a/internal/api/pending_transaction.go b/internal/api/pending_transaction.go new file mode 100644 index 00000000..43147b59 --- /dev/null +++ b/internal/api/pending_transaction.go @@ -0,0 +1,163 @@ +package api + +import ( + "fmt" + "time" + + "github.com/LightningTipBot/LightningTipBot/internal/lnbits" + "github.com/LightningTipBot/LightningTipBot/internal/storage" + "github.com/LightningTipBot/LightningTipBot/internal/telegram" + log "github.com/sirupsen/logrus" +) + +// PendingTransaction represents a transaction awaiting admin approval +type PendingTransaction struct { + *storage.Base + ID string `json:"id"` + FromUser *lnbits.User `json:"from_user" gorm:"-"` + ToUser *lnbits.User `json:"to_user" gorm:"-"` + FromUsername string `json:"from_username"` + ToUsername string `json:"to_username"` + Amount int64 `json:"amount"` + Memo string `json:"memo"` + RequestTimestamp time.Time `json:"request_timestamp"` + Status string `json:"status"` // "pending", "approved", "rejected", "expired" + ApprovedBy string `json:"approved_by,omitempty"` + ApprovalTime *time.Time `json:"approval_time,omitempty"` + ExpiryTime time.Time `json:"expiry_time"` + ClientIP string `json:"client_ip"` + OriginalRequest *SendRequest `json:"original_request" gorm:"-"` +} + +const ( + PendingTransactionExpiry = 24 * time.Hour // Pending transactions expire after 24 hours + StatusPending = "pending" + StatusApproved = "approved" + StatusRejected = "rejected" + StatusExpired = "expired" + StatusExecuted = "executed" +) + +// NewPendingTransaction creates a new pending transaction +func NewPendingTransaction(req *SendRequest, fromUser, toUser *lnbits.User, clientIP string) *PendingTransaction { + fromUsername := "" + if fromUser != nil { + fromUsername = fromUser.Telegram.Username + } + + id := fmt.Sprintf("pending-%s-%s-%d-%d", fromUsername, req.To, req.Amount, time.Now().Unix()) + + return &PendingTransaction{ + Base: storage.New(storage.ID(id)), + ID: id, + FromUser: fromUser, + ToUser: toUser, + FromUsername: fromUsername, + ToUsername: req.To, + Amount: req.Amount, + Memo: req.Memo, + RequestTimestamp: time.Now(), + Status: StatusPending, + ExpiryTime: time.Now().Add(PendingTransactionExpiry), + ClientIP: clientIP, + OriginalRequest: req, + } +} + +// IsExpired checks if the pending transaction has expired +func (pt *PendingTransaction) IsExpired() bool { + return time.Now().After(pt.ExpiryTime) +} + +// CanBeApproved checks if the transaction can still be approved +func (pt *PendingTransaction) CanBeApproved() bool { + return pt.Status == StatusPending && !pt.IsExpired() +} + +// Approve marks the transaction as approved +func (pt *PendingTransaction) Approve(approvedBy string) error { + if !pt.CanBeApproved() { + return fmt.Errorf("transaction cannot be approved: status=%s, expired=%v", pt.Status, pt.IsExpired()) + } + + now := time.Now() + pt.Status = StatusApproved + pt.ApprovedBy = approvedBy + pt.ApprovalTime = &now + + return nil +} + +// Reject marks the transaction as rejected +func (pt *PendingTransaction) Reject(rejectedBy string) error { + if pt.Status != StatusPending { + return fmt.Errorf("transaction cannot be rejected: status=%s", pt.Status) + } + + now := time.Now() + pt.Status = StatusRejected + pt.ApprovedBy = rejectedBy // Store who rejected it + pt.ApprovalTime = &now + + return nil +} + +// Execute marks the transaction as executed (actually processed) +func (pt *PendingTransaction) Execute() error { + if pt.Status != StatusApproved { + return fmt.Errorf("transaction cannot be executed: status=%s", pt.Status) + } + + pt.Status = StatusExecuted + return nil +} + +// SaveToDB saves the pending transaction to the database +func (pt *PendingTransaction) SaveToDB(bot *telegram.TipBot) error { + return pt.Set(pt, bot.Bunt) +} + +// LoadFromDB loads a pending transaction from the database +func LoadPendingTransaction(id string, bot *telegram.TipBot) (*PendingTransaction, error) { + pt := &PendingTransaction{Base: storage.New(storage.ID(id))} + sn, err := pt.Get(pt, bot.Bunt) + if err != nil { + return nil, err + } + + pendingTx := sn.(*PendingTransaction) + + // Load user objects from usernames + fromUser, err := telegram.GetUserByTelegramUsername(pendingTx.FromUsername, *bot) + if err != nil { + log.Warnf("[ADMIN APPROVAL] Could not load from user @%s: %v", pendingTx.FromUsername, err) + // Continue with nil user - this will be handled in the calling functions + } else { + pendingTx.FromUser = fromUser + } + + toUser, err := telegram.GetUserByTelegramUsername(pendingTx.ToUsername, *bot) + if err != nil { + log.Warnf("[ADMIN APPROVAL] Could not load to user @%s: %v", pendingTx.ToUsername, err) + // Continue with nil user - this will be handled in the calling functions + } else { + pendingTx.ToUser = toUser + } + + return pendingTx, nil +} + +// CleanupExpiredTransactions removes expired pending transactions +func CleanupExpiredTransactions(bot *telegram.TipBot) error { + // This would need to be implemented to iterate through all pending transactions + // and mark expired ones as expired. For now, we'll just log the intent. + log.Debug("[ADMIN APPROVAL] Cleanup expired transactions called") + + // Implementation would involve: + // 1. Query all pending transactions from database + // 2. Check which ones are expired + // 3. Update their status to "expired" + // 4. Optionally notify the original requester + + return nil +} diff --git a/internal/api/proxy.go b/internal/api/proxy.go new file mode 100644 index 00000000..acf32773 --- /dev/null +++ b/internal/api/proxy.go @@ -0,0 +1,73 @@ +package api + +import ( + "fmt" + "io" + "net/http" + "net/url" + "time" + + log "github.com/sirupsen/logrus" +) + +func Proxy(wr http.ResponseWriter, req *http.Request, rawUrl string) error { + + client := &http.Client{Timeout: time.Second * 30} + + //http: Request.RequestURI can't be set in client requests. + //http://golang.org/src/pkg/net/http/client.go + req.RequestURI = "" + + u, err := url.Parse(rawUrl) + if err != nil { + http.Error(wr, "Server Error", http.StatusInternalServerError) + log.Println("ServeHTTP:", err) + return err + } + req.URL.Host = u.Host + req.URL.Scheme = u.Scheme + req.Host = req.URL.Host + resp, err := client.Do(req) + if err != nil { + http.Error(wr, "Server Error", http.StatusInternalServerError) + log.Println("ServeHTTP:", err) + return err + } + defer resp.Body.Close() + log.Tracef("[Proxy] Proxy request status: %s", resp.Status) + if resp.StatusCode > 300 { + return fmt.Errorf("invalid response") + } + delHopHeaders(resp.Header) + copyHeader(wr.Header(), resp.Header) + wr.WriteHeader(resp.StatusCode) + _, err = io.Copy(wr, resp.Body) + return err +} + +// Hop-by-hop headers. These are removed when sent to the backend. +// http://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html +var hopHeaders = []string{ + "Connection", + "Keep-Alive", + "Proxy-Authenticate", + "Proxy-Authorization", + "Te", // canonicalized version of "TE" + "Trailers", + "Transfer-Encoding", + "Upgrade", +} + +func copyHeader(dst, src http.Header) { + for k, vv := range src { + for _, v := range vv { + dst.Add(k, v) + } + } +} + +func delHopHeaders(header http.Header) { + for _, h := range hopHeaders { + header.Del(h) + } +} diff --git a/internal/api/send.go b/internal/api/send.go new file mode 100644 index 00000000..0c3c353f --- /dev/null +++ b/internal/api/send.go @@ -0,0 +1,387 @@ +package api + +import ( + "encoding/json" + "fmt" + "net" + "net/http" + "regexp" + "strconv" + "strings" + + "github.com/LightningTipBot/LightningTipBot/internal/lnbits" + "github.com/LightningTipBot/LightningTipBot/internal/str" + "github.com/LightningTipBot/LightningTipBot/internal/telegram" + "github.com/LightningTipBot/LightningTipBot/internal/thirdparty" + "github.com/LightningTipBot/LightningTipBot/internal/utils" + "github.com/LightningTipBot/LightningTipBot/pkg/lightning" + log "github.com/sirupsen/logrus" +) + +// SendRequest represents the JSON request for the send API +type SendRequest struct { + To string `json:"to"` // Telegram username (without @), Telegram ID, or wallet ID + Amount int64 `json:"amount"` // Amount in satoshis + Memo string `json:"memo"` // Optional memo +} + +// SendResponse represents the JSON response for the send API +type SendResponse struct { + Success bool `json:"success"` + TransactionHash string `json:"transaction_hash,omitempty"` + Message string `json:"message"` + FromUser string `json:"from_user"` + ToUser string `json:"to_user"` + Amount int64 `json:"amount"` + AmountLKR string `json:"amount_lkr,omitempty"` // LKR conversion + Memo string `json:"memo,omitempty"` +} + +// InternalNetworkMiddleware restricts access to internal network IPs (configurable) +func InternalNetworkMiddleware(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + clientIP := getClientIP(r) + + // Parse the client IP + ip := net.ParseIP(clientIP) + if ip == nil { + log.Warnf("[api/send] Invalid client IP: %s", clientIP) + http.Error(w, "Invalid client IP", http.StatusForbidden) + return + } + + // Check if IP is in the internal network range defined in config + _, internalNet, err := net.ParseCIDR(GetInternalNetworkCIDR()) + if err != nil { + log.Errorf("[api/send] Invalid internal network CIDR configuration: %s, error: %v", GetInternalNetworkCIDR(), err) + http.Error(w, "Internal server configuration error", http.StatusInternalServerError) + return + } + + if !internalNet.Contains(ip) { + log.Warnf("[api/send] Access denied for IP: %s (not in internal network %s)", clientIP, GetInternalNetworkCIDR()) + http.Error(w, "Access denied: Internal network only", http.StatusForbidden) + return + } + + log.Debugf("[api/send] Access granted for internal IP: %s", clientIP) + next.ServeHTTP(w, r) + } +} + +// getClientIP extracts the real client IP from the request +func getClientIP(r *http.Request) string { + // Check X-Forwarded-For header first + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + // X-Forwarded-For can contain multiple IPs, take the first one + if ips := strings.Split(xff, ","); len(ips) > 0 { + return strings.TrimSpace(ips[0]) + } + } + + // Check X-Real-IP header + if xri := r.Header.Get("X-Real-IP"); xri != "" { + return xri + } + + // Fall back to RemoteAddr + ip, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return ip +} + +// isTelegramID checks if the given string is a valid Telegram ID (numeric) +func isTelegramID(identifier string) bool { + // Remove @ prefix if present + identifier = strings.TrimPrefix(identifier, "@") + // Check if it's all digits and has reasonable length for Telegram ID + match, _ := regexp.MatchString(`^[0-9]{5,15}$`, identifier) + return match +} + +// Send handles the /api/send endpoint for programmatic Bitcoin Lightning payments +func (s Service) Send(w http.ResponseWriter, r *http.Request) { + var req SendRequest + err := json.NewDecoder(r.Body).Decode(&req) + if err != nil { + log.Errorf("[api/send] Invalid JSON request: %v", err) + RespondError(w, "Invalid JSON request") + return + } + + // Get authenticated wallet from context (set by WalletHMACMiddleware) + authenticatedWallet := r.Context().Value("authenticated_wallet") + if authenticatedWallet == nil { + log.Error("[api/send] No authenticated wallet found in request context") + RespondError(w, "Authentication failed") + return + } + + walletID := authenticatedWallet.(string) + wallet, exists := GetWhitelistedWallets()[walletID] + if !exists { + log.Errorf("[api/send] Authenticated wallet %s not found in configuration", walletID) + RespondError(w, "Invalid wallet configuration") + return + } + + fromUsername := wallet.Username + + // Validate request + if req.To == "" { + RespondError(w, "Missing 'to' field") + return + } + if req.Amount <= GetMinAPITransactionAmount() { + RespondError(w, fmt.Sprintf("Amount must be greater than %s", thirdparty.FormatSatsWithLKR(GetMinAPITransactionAmount()))) + return + } + if req.Amount > GetMaxAPITransactionAmount() { + RespondError(w, fmt.Sprintf("Amount cannot exceed %s", thirdparty.FormatSatsWithLKR(GetMaxAPITransactionAmount()))) + return + } + + // Check if amount requires admin approval + requiresApproval := req.Amount > GetAdminApprovalThreshold() + if len(req.Memo) > GetMaxMemoLength() { + RespondError(w, fmt.Sprintf("Memo cannot exceed %d characters", GetMaxMemoLength())) + return + } + + if req.Memo != "" { + memoLockKey := fmt.Sprintf("api_send_memo_%s", req.Memo) + + // Try to acquire lock first to prevent concurrent processing + if success := s.MemoCache.SetNX(memoLockKey, "locked"); !success { + log.Warnf("[api/send] Transaction with memo '%s' is already processing", req.Memo) + RespondError(w, fmt.Sprintf("Transaction with memo '%s' is already processing", req.Memo)) + return + } + // Unlock when done + defer s.MemoCache.Delete(memoLockKey) + + // Check if transaction with this memo already exists in database + // We search for the memo in the transaction memo field + // The stored memo format is: "💸 API Send from @User to @User. Memo: " + // So we search for the suffix "Memo: " + var count int64 + memoSearch := fmt.Sprintf("%%Memo: %s", req.Memo) + err := s.Bot.DB.Transactions.Model(&telegram.Transaction{}).Where("memo LIKE ? AND success = ?", memoSearch, true).Count(&count).Error + if err != nil { + log.Errorf("[api/send] Database error checking for duplicate memo: %v", err) + // Continue but log error - fail open or closed? Let's fail closed for safety + RespondError(w, "Internal server error checking transaction history") + return + } + if count > 0 { + log.Warnf("[api/send] Transaction with memo '%s' already completed", req.Memo) + RespondError(w, fmt.Sprintf("Transaction with memo '%s' already completed", req.Memo)) + return + } + } + + // Clean usernames (remove @ if present) + toIdentifier := strings.TrimPrefix(req.To, "@") + + // Get the sender user + fromUser, err := telegram.GetUserByTelegramUsername(fromUsername, *s.Bot) + if err != nil { + log.Errorf("[api/send] Could not find sender user %s: %v", fromUsername, err) + RespondError(w, fmt.Sprintf("Sender '@%s' not found or has no wallet", fromUsername)) + return + } + + // Check sender's available balance (wallet balance - pot balance) + balance, err := s.Bot.GetUserAvailableBalance(fromUser) + if err != nil { + log.Errorf("[api/send] Could not get available balance for %s: %v", fromUsername, err) + RespondError(w, "Could not check sender balance") + return + } + + if balance < req.Amount { + log.Warnf("[api/send] Insufficient available balance for %s: %d < %d", fromUsername, balance, req.Amount) + RespondError(w, fmt.Sprintf("Insufficient balance: %s available, %s required", thirdparty.FormatSatsWithLKR(balance), thirdparty.FormatSatsWithLKR(req.Amount))) + return + } + + // Check if 'to' is a Lightning address + if lightning.IsLightningAddress(toIdentifier) { + log.Infof("[api/send] Sending to Lightning address: %s", toIdentifier) + err = s.sendToLightningAddress(fromUser, toIdentifier, req.Amount, req.Memo) + if err != nil { + log.Errorf("[api/send] Lightning address payment failed: %v", err) + RespondError(w, fmt.Sprintf("Lightning address payment failed: %v", err)) + return + } + + response := SendResponse{ + Success: true, + Message: "Payment sent successfully to Lightning address", + FromUser: fromUsername, + ToUser: toIdentifier, + Amount: req.Amount, + AmountLKR: getLKRValue(req.Amount), + Memo: req.Memo, + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(response) + return + } + + // Try to find recipient by Telegram username or ID + var toUser *lnbits.User + if isTelegramID(toIdentifier) { + // It's a Telegram ID + telegramID, err := strconv.ParseInt(toIdentifier, 10, 64) + if err != nil { + log.Errorf("[api/send] Invalid Telegram ID %s: %v", toIdentifier, err) + RespondError(w, fmt.Sprintf("Invalid Telegram ID '%s'", toIdentifier)) + return + } + toUser, err = telegram.GetUserByTelegramID(telegramID, *s.Bot) + if err != nil { + log.Errorf("[api/send] Could not find recipient user with ID %d: %v", telegramID, err) + RespondError(w, fmt.Sprintf("Recipient '%s' not found or has no wallet", toIdentifier)) + return + } + log.Infof("[api/send] Found recipient by Telegram ID: %d", telegramID) + } else { + // It's a Telegram username + toUser, err = telegram.GetUserByTelegramUsername(toIdentifier, *s.Bot) + if err != nil { + log.Errorf("[api/send] Could not find recipient user %s: %v", toIdentifier, err) + RespondError(w, fmt.Sprintf("Recipient '@%s' not found or has no wallet", toIdentifier)) + return + } + log.Infof("[api/send] Found recipient by username: %s", toIdentifier) + } + + // Check if trying to send to self + if fromUser.ID == toUser.ID { + RespondError(w, "Cannot send to yourself") + return + } + + // Check if amount requires admin approval + if requiresApproval { + log.Infof("[api/send] Large transaction requires admin approval: %s -> %s (%d sat(s))", fromUsername, toIdentifier, req.Amount) + + // Create pending transaction + clientIP := getClientIP(r) + pendingTx := NewPendingTransaction(&req, fromUser, toUser, clientIP) + + // Save to database + err = pendingTx.SaveToDB(s.Bot) + if err != nil { + log.Errorf("[api/send] Failed to save pending transaction: %v", err) + RespondError(w, "Failed to create pending transaction") + return + } + + // Send approval request using Telegram callback buttons (same as /send command) + err = telegram.CreateAPIApprovalRequest(s.Bot, fromUser, toIdentifier, req.Amount, req.Memo, pendingTx.ID, clientIP) + if err != nil { + log.Warnf("[api/send] Failed to send approval request: %v", err) + } + + response := SendResponse{ + Success: false, + Message: fmt.Sprintf("Transaction requires admin approval (amount: %s > threshold: %s). Approval request sent to you via Telegram. Transaction ID: %s", + thirdparty.FormatSatsWithLKR(req.Amount), thirdparty.FormatSatsWithLKR(GetAdminApprovalThreshold()), pendingTx.ID), + FromUser: fromUsername, + ToUser: toIdentifier, + Amount: req.Amount, + AmountLKR: getLKRValue(req.Amount), + Memo: req.Memo, + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) // 202 Accepted - request received but needs approval + json.NewEncoder(w).Encode(response) + return + } + + // Create transaction memo + fromUserStr := telegram.GetUserStr(fromUser.Telegram) + toUserStr := telegram.GetUserStr(toUser.Telegram) + transactionMemo := fmt.Sprintf("💸 API Send from %s to %s.", fromUserStr, toUserStr) + if req.Memo != "" { + transactionMemo += fmt.Sprintf(" Memo: %s", req.Memo) + } + + // Create and execute transaction + t := telegram.NewTransaction(s.Bot, fromUser, toUser, req.Amount, telegram.TransactionType("api_send")) + t.Memo = transactionMemo + + success, err := t.Send() + if !success || err != nil { + log.Errorf("[api/send] Transaction failed from %s to %s: %v", fromUserStr, toUserStr, err) + if s.Bot.ErrorLogger != nil { + s.Bot.ErrorLogger.LogTransactionError(err, "api_send", req.Amount, fromUser.Telegram, toUser.Telegram) + } + RespondError(w, fmt.Sprintf("Transaction failed: %v", err)) + return + } + + log.Infof("[api/send] ✅ API Send successful: %s -> %s (%d sat(s))", fromUserStr, toUserStr, req.Amount) + + // Send notification to recipient with memo included in same message + fromUserStrMd := telegram.GetUserStrMd(fromUser.Telegram) + notificationMsg := fmt.Sprintf("💰 You received %s from %s via Automated API", thirdparty.FormatSatsWithLKR(req.Amount), fromUserStrMd) + if req.Memo != "" { + notificationMsg += fmt.Sprintf("\n✉️ Memo: %s", str.MarkdownEscape(req.Memo)) + } + + _, err = s.Bot.Telegram.Send(toUser.Telegram, notificationMsg) + if err != nil { + log.Warnf("[api/send] Could not send notification to recipient: %v", err) + } + + // Send confirmation to sender (from user) - same format as /send command + toUserStrMd := telegram.GetUserStrMd(toUser.Telegram) + senderConfirmationMsg := fmt.Sprintf("✅ Payment sent successfully!\n\n💸 Amount: %s\n👤 To: %s", thirdparty.FormatSatsWithLKR(req.Amount), toUserStrMd) + if req.Memo != "" { + senderConfirmationMsg += fmt.Sprintf("\n✉️ Memo: %s", str.MarkdownEscape(req.Memo)) + } + + _, err = s.Bot.Telegram.Send(fromUser.Telegram, senderConfirmationMsg) + if err != nil { + log.Warnf("[api/send] Could not send confirmation to sender: %v", err) + } + + response := SendResponse{ + Success: true, + Message: "Payment sent successfully", + FromUser: fromUsername, + ToUser: toIdentifier, + Amount: req.Amount, + AmountLKR: getLKRValue(req.Amount), + Memo: req.Memo, + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(response) +} + +// sendToLightningAddress handles sending to Lightning addresses +func (s Service) sendToLightningAddress(fromUser *lnbits.User, lightningAddress string, amount int64, memo string) error { + // This is a simplified implementation - you may need to implement the full Lightning address protocol + // For now, we'll return an error as this requires additional Lightning address handling logic + return fmt.Errorf("Lightning address payments not yet implemented in API") +} + +// getLKRValue converts satoshi amount to LKR string or returns empty string if price unavailable +func getLKRValue(amount int64) string { + lkrPerSat, _, err := thirdparty.GetSatPrice() + if err != nil { + return "" // Return empty string if LKR price is unavailable + } + lkrValue := lkrPerSat * float64(amount) + return utils.FormatFloatWithCommas(lkrValue) +} diff --git a/internal/api/send_config.go b/internal/api/send_config.go new file mode 100644 index 00000000..94a26126 --- /dev/null +++ b/internal/api/send_config.go @@ -0,0 +1,55 @@ +package api + +import ( + "github.com/LightningTipBot/LightningTipBot/internal" +) + +// GetWhitelistedWallets returns the map of whitelisted wallets with their HMAC secrets +func GetWhitelistedWallets() map[string]internal.WhitelistedWallet { + return internal.Configuration.API.Send.WhitelistedWallets +} + +// GetWalletHMACSecret returns the HMAC secret for a specific wallet +func GetWalletHMACSecret(walletID string) (string, bool) { + wallet, exists := internal.Configuration.API.Send.WhitelistedWallets[walletID] + if !exists { + return "", false + } + return wallet.HMACSecret, true +} + +// IsWhitelistedWallet checks if a wallet ID is whitelisted +func IsWhitelistedWallet(walletID string) bool { + _, exists := internal.Configuration.API.Send.WhitelistedWallets[walletID] + return exists +} + +// GetInternalNetworkCIDR returns the allowed internal network range +func GetInternalNetworkCIDR() string { + return internal.Configuration.API.Send.InternalNetwork +} + +// GetMaxAPITransactionAmount returns the maximum transaction amount +func GetMaxAPITransactionAmount() int64 { + return internal.Configuration.API.Send.MaxAmount +} + +// GetMinAPITransactionAmount returns the minimum transaction amount +func GetMinAPITransactionAmount() int64 { + return internal.Configuration.API.Send.MinAmount +} + +// GetAdminApprovalThreshold returns the admin approval threshold +func GetAdminApprovalThreshold() int64 { + return internal.Configuration.API.Send.AdminApprovalThreshold +} + +// GetMaxMemoLength returns the maximum memo length +func GetMaxMemoLength() int { + return internal.Configuration.API.Send.MaxMemoLength +} + +// GetAPIRateLimit returns the API rate limit +func GetAPIRateLimit() int { + return internal.Configuration.API.Send.RateLimit +} diff --git a/internal/api/server.go b/internal/api/server.go new file mode 100644 index 00000000..1b01778b --- /dev/null +++ b/internal/api/server.go @@ -0,0 +1,73 @@ +package api + +import ( + "encoding/json" + "net/http" + "time" + + "gorm.io/gorm" + + "github.com/gorilla/mux" + log "github.com/sirupsen/logrus" +) + +type Server struct { + httpServer *http.Server + router *mux.Router +} + +const ( + StatusError = "ERROR" + StatusOk = "OK" +) + +func NewServer(address string) *Server { + srv := &http.Server{ + Addr: address, + // Good practice: enforce timeouts for servers you create! + WriteTimeout: 90 * time.Second, + ReadTimeout: 90 * time.Second, + } + apiServer := &Server{ + httpServer: srv, + } + apiServer.router = mux.NewRouter() + apiServer.httpServer.Handler = apiServer.router + go apiServer.httpServer.ListenAndServe() + log.Infof("[api] Server started at %s", address) + return apiServer +} + +func (w *Server) ListenAndServe() { + go w.httpServer.ListenAndServe() +} +func (w *Server) PathPrefix(path string, handler http.Handler) { + w.router.PathPrefix(path).Handler(handler) +} +func (w *Server) AppendAuthorizedRoute(path string, authType AuthType, accessType AccessKeyType, database *gorm.DB, handler func(http.ResponseWriter, *http.Request), methods ...string) { + r := w.router.HandleFunc(path, LoggingMiddleware("API", AuthorizationMiddleware(database, authType, accessType, handler))) + if len(methods) > 0 { + r.Methods(methods...) + } +} +func (w *Server) AppendRoute(path string, handler func(http.ResponseWriter, *http.Request), methods ...string) { + r := w.router.HandleFunc(path, LoggingMiddleware("API", handler)) + if len(methods) > 0 { + r.Methods(methods...) + } +} + +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 +} diff --git a/internal/api/user_balance.go b/internal/api/user_balance.go new file mode 100644 index 00000000..b743eaf7 --- /dev/null +++ b/internal/api/user_balance.go @@ -0,0 +1,106 @@ +package api + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/LightningTipBot/LightningTipBot/internal/telegram" + log "github.com/sirupsen/logrus" +) + +// UserBalanceRequest represents the JSON request for the user balance API +type UserBalanceRequest struct { + TelegramID int64 `json:"telegram_id"` // Telegram user ID +} + +// UserBalanceResponse represents the JSON response for the user balance API +type UserBalanceResponse struct { + Success bool `json:"success"` + TelegramID int64 `json:"telegram_id"` + Balance int64 `json:"balance"` // Balance in satoshis + BalanceLKR string `json:"balance_lkr,omitempty"` // LKR conversion + FirstName string `json:"first_name,omitempty"` + LastName string `json:"last_name,omitempty"` + Username string `json:"username,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + Message string `json:"message,omitempty"` +} + +// UserBalance handles the /api/v1/userbalance endpoint for getting user balance by Telegram ID +func (s Service) UserBalance(w http.ResponseWriter, r *http.Request) { + var req UserBalanceRequest + err := json.NewDecoder(r.Body).Decode(&req) + if err != nil { + log.Errorf("[api/userbalance] Invalid JSON request: %v", err) + RespondError(w, "Invalid JSON request") + return + } + + // Validate request + if req.TelegramID <= 0 { + RespondError(w, "Invalid Telegram ID") + return + } + + // Get user by Telegram ID + user, err := telegram.GetUserByTelegramID(req.TelegramID, *s.Bot) + if err != nil { + log.Errorf("[api/userbalance] Failed to get user by Telegram ID %d: %v", req.TelegramID, err) + response := UserBalanceResponse{ + Success: false, + TelegramID: req.TelegramID, + Balance: 0, + Message: "User not found or has no wallet", + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(response) + return + } + + // Get user balance + balance, err := s.Bot.GetUserBalance(user) + if err != nil { + log.Errorf("[api/userbalance] Failed to get balance for user %d: %v", req.TelegramID, err) + response := UserBalanceResponse{ + Success: false, + TelegramID: req.TelegramID, + Balance: 0, + Message: "Failed to retrieve balance", + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(response) + return + } + + // Convert to LKR if possible + balanceLKR := getLKRValue(balance) + + // Extract user details + var firstName, lastName, username string + if user.Telegram != nil { + firstName = user.Telegram.FirstName + lastName = user.Telegram.LastName + username = user.Telegram.Username + } + + response := UserBalanceResponse{ + Success: true, + TelegramID: req.TelegramID, + Balance: balance, + BalanceLKR: balanceLKR, + FirstName: firstName, + LastName: lastName, + Username: username, + CreatedAt: user.CreatedAt.Format(time.RFC3339), + Message: "Balance retrieved successfully", + } + + log.Infof("[api/userbalance] Balance retrieved for user %d: %d sats", req.TelegramID, balance) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(response) +} diff --git a/internal/api/userpage/static/userpage.html b/internal/api/userpage/static/userpage.html new file mode 100644 index 00000000..21a5fc16 --- /dev/null +++ b/internal/api/userpage/static/userpage.html @@ -0,0 +1,79 @@ + + +{{define "userpage"}} + + + + + + + + + + + + + +{{.Username}}@ln.tips + + + + +

Pay to {{.Username}}@ln.tips

+ +
+
{{.LNURLPay}}
+
Get your own Lightning address here: ln.tips
+ + +{{end}} \ No newline at end of file diff --git a/internal/api/userpage/static/webapp.html b/internal/api/userpage/static/webapp.html new file mode 100644 index 00000000..89ea532a --- /dev/null +++ b/internal/api/userpage/static/webapp.html @@ -0,0 +1,387 @@ + + +{{define "webapp"}} + + + + + + + + + + + + + + @LightningTipBot + + + + + + + + +
+

+ +
+
+
+
+ +
+ +
+ +
+ +
+
+
+
+
+ +
+
+
+
+ + + + + + + + +{{end}} \ No newline at end of file diff --git a/internal/api/userpage/userpage.go b/internal/api/userpage/userpage.go new file mode 100644 index 00000000..39229f0a --- /dev/null +++ b/internal/api/userpage/userpage.go @@ -0,0 +1,112 @@ +package userpage + +import ( + "embed" + "errors" + "fmt" + "html/template" + "net/http" + "strings" + "time" + + "github.com/LightningTipBot/LightningTipBot/internal" + "github.com/LightningTipBot/LightningTipBot/internal/telegram" + "github.com/PuerkitoBio/goquery" + "github.com/fiatjaf/go-lnurl" + "github.com/gorilla/mux" + log "github.com/sirupsen/logrus" +) + +type Service struct { + bot *telegram.TipBot +} + +func New(b *telegram.TipBot) Service { + return Service{ + bot: b, + } +} + +const botImage = "https://avatars.githubusercontent.com/u/88730856?v=7" + +//go:embed static +var templates embed.FS +var userpage_tmpl = template.Must(template.ParseFS(templates, "static/userpage.html")) +var qr_tmpl = template.Must(template.ParseFS(templates, "static/webapp.html")) + +var Client = &http.Client{ + Timeout: 10 * time.Second, +} + +// thank you fiatjaf for this code +func (s Service) getTelegramUserPictureURL(username string) (string, error) { + // with proxy: + // client, err := s.network.GetHttpClient() + // if err != nil { + // return "", err + // } + client := http.Client{ + Timeout: 5 * time.Second, + } + resp, err := client.Get("https://t.me/" + username) + if err != nil { + return "", err + } + defer resp.Body.Close() + + doc, err := goquery.NewDocumentFromReader(resp.Body) + if err != nil { + return "", err + } + + url, ok := doc.Find(`meta[property="og:image"]`).First().Attr("content") + if !ok { + return "", errors.New("no image available for this user") + } + + return url, nil +} + +func (s Service) UserPageHandler(w http.ResponseWriter, r *http.Request) { + // https://ln.tips/@ + username := strings.ToLower(mux.Vars(r)["username"]) + callback := fmt.Sprintf("%s/.well-known/lnurlp/%s", internal.Configuration.Bot.LNURLHostName, username) + log.Infof("[UserPage] rendering page of %s", username) + lnurlEncode, err := lnurl.LNURLEncode(callback) + if err != nil { + log.Errorln("[UserPage]", err) + return + } + image, err := s.getTelegramUserPictureURL(username) + if err != nil || image == "https://telegram.org/img/t_logo.png" { + // replace the default image + image = botImage + } + + if err := userpage_tmpl.ExecuteTemplate(w, "userpage", struct { + Username string + Image string + LNURLPay string + }{username, image, lnurlEncode}); err != nil { + log.Errorf("failed to render template") + } +} + +func (s Service) UserWebAppHandler(w http.ResponseWriter, r *http.Request) { + // https://ln.tips/app/ + username := strings.ToLower(mux.Vars(r)["username"]) + callback := fmt.Sprintf("%s/.well-known/lnurlp/%s", internal.Configuration.Bot.LNURLHostName, username) + log.Infof("[UserPage] rendering webapp of %s", username) + lnurlEncode, err := lnurl.LNURLEncode(callback) + if err != nil { + log.Errorln("[UserPage]", err) + return + } + if err := qr_tmpl.ExecuteTemplate(w, "webapp", struct { + Username string + LNURLPay string + Callback string + }{username, lnurlEncode, callback}); err != nil { + log.Errorf("failed to render template") + } +} diff --git a/internal/config.go b/internal/config.go new file mode 100644 index 00000000..2f8bcddd --- /dev/null +++ b/internal/config.go @@ -0,0 +1,264 @@ +package internal + +import ( + "fmt" + "net" + "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"` + Generate GenerateConfiguration `yaml:"generate"` + Nostr NostrConfiguration `yaml:"nostr"` + API APIConfiguration `yaml:"api"` +}{} + +type NostrConfiguration struct { + PrivateKey string `yaml:"private_key"` +} + +type GenerateConfiguration struct { + OpenAiBearerToken string `yaml:"open_ai_bearer_token"` + DalleKey string `yaml:"dalle_key"` + DallePrice int64 `yaml:"dalle_price"` + Worker int `yaml:"worker"` +} + +type SocksConfiguration struct { + Host string `yaml:"host"` + Username string `yaml:"username"` + Password string `yaml:"password"` +} + +type BotConfiguration struct { + SocksProxy *SocksConfiguration `yaml:"socks_proxy,omitempty"` + TorProxy *SocksConfiguration `yaml:"tor_proxy,omitempty"` + LNURLServer string `yaml:"lnurl_server"` + LNURLServerUrl *url.URL `yaml:"-"` + LNURLHostName string `yaml:"lnurl_public_host_name"` + LNURLHostUrl *url.URL `yaml:"-"` + LNURLSendImage bool `yaml:"lnurl_image"` + AdminAPIHost string `yaml:"admin_api_host"` +} + +type TelegramConfiguration struct { + MessageDisposeDuration int64 `yaml:"message_dispose_duration"` + ApiKey string `yaml:"api_key"` + LogGroupId int64 `yaml:"log_group_id"` + ErrorThreadId int64 `yaml:"error_thread_id"` +} +type DatabaseConfiguration struct { + DbPath string `yaml:"db_path"` + ShopBuntDbPath string `yaml:"shop_buntdb_path"` + BuntDbPath string `yaml:"buntdb_path"` + TransactionsPath string `yaml:"transactions_path"` + GroupsDbPath string `yaml:"groupsdb_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:"-"` + WebhookPublicUrl string `yaml:"webhook_public_url"` + WebhookPublicUrlParsed *url.URL `yaml:"-"` +} + +type APIConfiguration struct { + Send APISendConfiguration `yaml:"send"` + Analytics APIAnalyticsConfiguration `yaml:"analytics"` +} + +type APIAnalyticsConfiguration struct { + Enabled bool `yaml:"enabled"` + APIKeys map[string]AnalyticsAPIKey `yaml:"api_keys"` + TimestampTolerance int64 `yaml:"timestamp_tolerance"` // seconds +} + +type AnalyticsAPIKey struct { + Name string `yaml:"name"` // Descriptive name (e.g. "data-team") + HMACSecret string `yaml:"hmac_secret"` // HMAC secret for this key +} + +type APISendConfiguration struct { + Enabled bool `yaml:"enabled"` + InternalNetwork string `yaml:"internal_network"` + MaxAmount int64 `yaml:"max_amount"` + MinAmount int64 `yaml:"min_amount"` + AdminApprovalThreshold int64 `yaml:"admin_approval_threshold"` + MaxMemoLength int `yaml:"max_memo_length"` + RateLimit int `yaml:"rate_limit"` + WhitelistedWallets map[string]WhitelistedWallet `yaml:"whitelisted_wallets"` + TimestampTolerance int64 `yaml:"timestamp_tolerance"` // seconds +} + +type WhitelistedWallet struct { + Username string `yaml:"username"` // Telegram username without @ + HMACSecret string `yaml:"hmac_secret"` // Unique HMAC secret for this wallet +} + +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 + + // Parse webhook public URL if provided, otherwise use webhook server URL + if Configuration.Lnbits.WebhookPublicUrl != "" { + webhookPublicUrl, err := url.Parse(Configuration.Lnbits.WebhookPublicUrl) + if err != nil { + panic(fmt.Errorf("failed to parse webhook_public_url: %v", err)) + } + Configuration.Lnbits.WebhookPublicUrlParsed = webhookPublicUrl + } else { + Configuration.Lnbits.WebhookPublicUrlParsed = 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() + setAPISendDefaults() + setAPIAnalyticsDefaults() +} + +// GetWebhookURL returns the appropriate webhook URL +// If webhook_public_url is configured, it returns that (for reverse proxy scenarios) +// Otherwise, it returns the webhook_server URL (for direct access) +func GetWebhookURL() string { + if Configuration.Lnbits.WebhookPublicUrl != "" { + return Configuration.Lnbits.WebhookPublicUrl + } + return Configuration.Lnbits.WebhookServer +} + +// GetWebhookURLParsed returns the parsed webhook URL +func GetWebhookURLParsed() *url.URL { + return Configuration.Lnbits.WebhookPublicUrlParsed +} + +// checkLnbitsConfiguration validates the lnbits configuration +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 + "/" + } + } +} + +// setAPISendDefaults sets default values for API Send configuration +func setAPISendDefaults() { + // Set defaults only if not configured + if Configuration.API.Send.InternalNetwork == "" { + Configuration.API.Send.InternalNetwork = "10.0.0.0/24" + } + + // Validate CIDR format + _, _, err := net.ParseCIDR(Configuration.API.Send.InternalNetwork) + if err != nil { + log.Errorf("Invalid internal_network CIDR format '%s': %v. Using default 10.0.0.0/24", + Configuration.API.Send.InternalNetwork, err) + Configuration.API.Send.InternalNetwork = "10.0.0.0/24" + } + + if Configuration.API.Send.MaxAmount == 0 { + Configuration.API.Send.MaxAmount = 1000000 // 1M sats + } + if Configuration.API.Send.MinAmount == 0 { + Configuration.API.Send.MinAmount = 1 + } + if Configuration.API.Send.AdminApprovalThreshold == 0 { + Configuration.API.Send.AdminApprovalThreshold = 100000 // 100k sats + } + if Configuration.API.Send.MaxMemoLength == 0 { + Configuration.API.Send.MaxMemoLength = 280 + } + if Configuration.API.Send.RateLimit == 0 { + Configuration.API.Send.RateLimit = 60 + } + + // Set default whitelisted wallets if none configured + if len(Configuration.API.Send.WhitelistedWallets) == 0 { + Configuration.API.Send.WhitelistedWallets = map[string]WhitelistedWallet{ + "CeycubeBank": { + Username: "CeycubeBank", + HMACSecret: "change-me-ceycube-secret", + }, + } + log.Warn("Using default whitelisted wallets. Please configure unique HMAC secrets for each wallet in production!") + } + + // Log API Send configuration status + if Configuration.API.Send.Enabled { + log.Infof("API Send module enabled with %d whitelisted wallets, network: %s", + len(Configuration.API.Send.WhitelistedWallets), Configuration.API.Send.InternalNetwork) + } else { + log.Infof("API Send module disabled in configuration") + } +} + +// IsAPISendEnabled returns whether the API Send module is enabled +func IsAPISendEnabled() bool { + return Configuration.API.Send.Enabled +} + +// setAPIAnalyticsDefaults sets default values for API Analytics configuration +func setAPIAnalyticsDefaults() { + if !Configuration.API.Analytics.Enabled { + log.Infof("Analytics API disabled in configuration") + return + } + + if Configuration.API.Analytics.TimestampTolerance == 0 { + Configuration.API.Analytics.TimestampTolerance = 300 // 5 minutes + } + + if len(Configuration.API.Analytics.APIKeys) == 0 { + log.Errorf("Analytics API enabled but no API keys configured. Disabling analytics API.") + Configuration.API.Analytics.Enabled = false + return + } + + // Reject placeholder/insecure secrets + for keyID, apiKey := range Configuration.API.Analytics.APIKeys { + if strings.Contains(apiKey.HMACSecret, "change-me") || len(apiKey.HMACSecret) < 32 { + log.Errorf("Analytics API key '%s' has an insecure HMAC secret (placeholder or too short). "+ + "Generate a secure secret with: openssl rand -hex 32. Disabling analytics API.", keyID) + Configuration.API.Analytics.Enabled = false + return + } + } + + log.Infof("Analytics API enabled with %d API keys", len(Configuration.API.Analytics.APIKeys)) +} + +// IsAPIAnalyticsEnabled returns whether the Analytics API is enabled +func IsAPIAnalyticsEnabled() bool { + return Configuration.API.Analytics.Enabled +} diff --git a/internal/dalle/client.go b/internal/dalle/client.go new file mode 100644 index 00000000..d22b3842 --- /dev/null +++ b/internal/dalle/client.go @@ -0,0 +1,23 @@ +package dalle + +import ( + "context" + "github.com/LightningTipBot/LightningTipBot/internal" + "io" +) + +var Enabled bool + +func init() { + if internal.Configuration.Generate.DalleKey != "" { + Enabled = true + } +} + +type Client interface { + Generate(ctx context.Context, prompt string) (*Task, error) + ListTasks(ctx context.Context, req *ListTasksRequest) (*ListTasksResponse, error) + GetTask(ctx context.Context, taskID string) (*Task, error) + Download(ctx context.Context, generationID string) (io.ReadCloser, error) + Share(ctx context.Context, generationID string) (string, error) +} diff --git a/internal/dalle/dalle.go b/internal/dalle/dalle.go new file mode 100644 index 00000000..7a968537 --- /dev/null +++ b/internal/dalle/dalle.go @@ -0,0 +1,11 @@ +package dalle + +const ( + StatusPending = "pending" + StatusRejected = "rejected" + StatusSucceeded = "succeeded" + + TaskTypeText2Im = "text2im" + + defaultBatchSize = 4 +) diff --git a/internal/dalle/httpclient.go b/internal/dalle/httpclient.go new file mode 100644 index 00000000..462bf68c --- /dev/null +++ b/internal/dalle/httpclient.go @@ -0,0 +1,242 @@ +package dalle + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +const ( + libraryVersion = "1.0.0" + defaultUserAgent = "dalle/" + libraryVersion + baseURL = "https://labs.openai.com/api/labs" + + defaultHTTPClientTimeout = 15 * time.Second +) + +type option func(*HTTPClient) error + +func WithHTTPClient(httpClient *http.Client) option { + return func(c *HTTPClient) error { + c.httpClient = httpClient + + return nil + } +} + +func WithUserAgent(userAgent string) option { + return func(c *HTTPClient) error { + c.userAgent = userAgent + + return nil + } +} + +type HTTPClient struct { + httpClient *http.Client + userAgent string + apiKey string +} + +var _ Client = (*HTTPClient)(nil) + +func NewHTTPClient(apiKey string, opts ...option) (*HTTPClient, error) { + c := &HTTPClient{ + httpClient: &http.Client{Timeout: defaultHTTPClientTimeout}, + userAgent: defaultUserAgent, + apiKey: apiKey, + } + + for _, opt := range opts { + if err := opt(c); err != nil { + return nil, err + } + } + + return c, nil +} + +type Task struct { + Object string `json:"object"` + ID string `json:"id"` + Created int64 `json:"created"` + TaskType string `json:"task_type"` + Status string `json:"status"` + PromptID string `json:"prompt_id"` + Prompt Prompt `json:"prompt"` + Generations Generations `json:"generations"` +} +type Generations struct { + Data []GenerationData `json:"data"` + Object string `json:"object"` +} +type GenerationData struct { + Created int64 `json:"created"` + Generation Generation `json:"generation"` + GenerationType string `json:"generation_type"` + ID string `json:"id"` +} +type Generation struct { + ImagePath string `json:"image_path"` +} + +type Prompt struct { + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + PromptType string `json:"prompt_type"` + Prompt struct { + Caption string `json:"caption"` + } `json:"prompt"` + ParentGenerationID string `json:"parent_generation_id"` +} + +type GenerateRequest struct { + Prompt GenerateRequestPrompt `json:"prompt"` + TaskType string `json:"task_type"` +} +type GenerateRequestPrompt struct { + BatchSize int32 `json:"batch_size"` + Caption string `json:"caption"` +} + +func (c *HTTPClient) Generate(ctx context.Context, caption string) (*Task, error) { + task := &Task{} + req := &GenerateRequest{ + Prompt: GenerateRequestPrompt{ + BatchSize: defaultBatchSize, + Caption: caption, + }, + TaskType: TaskTypeText2Im, + } + return task, c.request(ctx, "POST", "/tasks", nil, req, task) +} + +type ListTasksResponse struct { + Object string `json:"object"` + Data []Task `json:"data"` +} + +type ListTasksRequest struct { + Limit int32 `json:"limit"` +} + +func (c *HTTPClient) ListTasks(ctx context.Context, req *ListTasksRequest) (*ListTasksResponse, error) { + res := &ListTasksResponse{} + url := "/tasks" + if req != nil { + if req.Limit != 0 { + url += fmt.Sprintf("?limit=%d", req.Limit) + } + } + + return res, c.request(ctx, "GET", url, nil, nil, res) +} + +func (c *HTTPClient) GetTask(ctx context.Context, taskID string) (*Task, error) { + task := &Task{} + return task, c.request(ctx, "GET", "/tasks/"+taskID, nil, nil, task) +} + +func (c *HTTPClient) Download(ctx context.Context, generationID string) (io.ReadCloser, error) { + req, err := c.createRequest(ctx, "/generations/"+generationID+"/download", "GET", nil, nil) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("performing request: %w", err) + } + + return resp.Body, nil +} + +// Share makes the generation public and returns the public url +func (c *HTTPClient) Share(ctx context.Context, generationID string) (string, error) { + res := &GenerationData{} + + err := c.request(ctx, "POST", "/generations/"+generationID+"/share", nil, nil, res) + if err != nil { + return "", err + } + + return res.Generation.ImagePath, nil +} + +func (c *HTTPClient) createRequest(ctx context.Context, path, method string, values *url.Values, data interface{}) (*http.Request, error) { + url := baseURL + path + + if values != nil { + url += "?" + values.Encode() + } + + var body io.Reader + if data != nil { + b, err := json.Marshal(data) + if err != nil { + return nil, fmt.Errorf("parsing request data: %w", err) + } + body = bytes.NewReader(b) + } + + req, err := http.NewRequestWithContext(ctx, method, url, body) + if err != nil { + return nil, fmt.Errorf("building request: %w", err) + } + + req.Header.Add("Authorization", "Bearer "+c.apiKey) + req.Header.Add("Content-Type", "application/json") + req.Header.Set("User-Agent", c.userAgent) + + return req, nil +} + +func (c *HTTPClient) request(ctx context.Context, method, path string, values *url.Values, body interface{}, result interface{}) error { + req, err := c.createRequest(ctx, path, method, values, body) + if err != nil { + return fmt.Errorf("creating request: %w", err) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("performing request: %w", err) + } + defer resp.Body.Close() + + respBody, _ := io.ReadAll(resp.Body) + + // TODO: improve error handling... + if resp.StatusCode != http.StatusOK { + return Error{ + Message: "unexpected non 200 status code", + StatusCode: resp.StatusCode, + Details: string(respBody), + } + } + + if err = json.Unmarshal(respBody, result); err != nil { + return Error{ + Message: err.Error(), + StatusCode: resp.StatusCode, + Details: string(respBody), + } + } + + return nil +} + +type Error struct { + Message string + StatusCode int + Details string +} + +func (e Error) Error() string { + return fmt.Sprintf("dalle: %s (status: %d, details: %s)", e.Message, e.StatusCode, e.Details) +} diff --git a/internal/database/migrations.go b/internal/database/migrations.go new file mode 100644 index 00000000..e7049269 --- /dev/null +++ b/internal/database/migrations.go @@ -0,0 +1,62 @@ +package database + +import ( + "fmt" + + "github.com/LightningTipBot/LightningTipBot/internal/lnbits" + "github.com/LightningTipBot/LightningTipBot/internal/str" + log "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +func MigrateAnonIdInt32Hash(db *gorm.DB) error { + users := []lnbits.User{} + _ = db.Find(&users) + for _, u := range users { + log.Infof("[MigrateAnonIdInt32Hash] %s -> %d", u.ID, str.Int32Hash(u.ID)) + u.AnonID = fmt.Sprint(str.Int32Hash(u.ID)) + tx := db.Save(u) + if tx.Error != nil { + errmsg := fmt.Sprintf("[MigrateAnonIdInt32Hash] Error: Couldn't migrate user %s (%d)", u.Telegram.Username, u.Telegram.ID) + log.Errorln(errmsg) + return tx.Error + } + } + return nil +} + +func MigrateAnonIdSha265Hash(db *gorm.DB) error { + users := []lnbits.User{} + _ = db.Find(&users) + for _, u := range users { + pw := u.Wallet.ID + anon_id := str.AnonIdSha256(&u) + log.Infof("[MigrateAnonIdSha265Hash] %s -> %s", pw, anon_id) + u.AnonIDSha256 = anon_id + tx := db.Save(u) + if tx.Error != nil { + errmsg := fmt.Sprintf("[MigrateAnonIdSha265Hash] Error: Couldn't migrate user %s (%s)", u.Telegram.Username, pw) + log.Errorln(errmsg) + return tx.Error + } + } + return nil +} + +func MigrateUUIDSha265Hash(db *gorm.DB) error { + users := []lnbits.User{} + _ = db.Find(&users) + for _, u := range users { + pw := u.Wallet.ID + uuid := str.UUIDSha256(&u) + log.Infof("[MigrateUUIDSha265Hash] %s -> %s", pw, uuid) + u.UUID = uuid + tx := db.Save(u) + if tx.Error != nil { + errmsg := fmt.Sprintf("[MigrateUUIDSha265Hash] Error: Couldn't migrate user %s (%s)", u.Telegram.Username, pw) + log.Errorln(errmsg) + return tx.Error + } + } + return nil +} diff --git a/internal/database/users.go b/internal/database/users.go new file mode 100644 index 00000000..3bf91752 --- /dev/null +++ b/internal/database/users.go @@ -0,0 +1,42 @@ +package database + +import ( + "strconv" + "strings" + + "github.com/LightningTipBot/LightningTipBot/internal/lnbits" + "gorm.io/gorm" +) + +func FindUser(database *gorm.DB, username string) (*lnbits.User, *gorm.DB) { + // now check for the user + user := &lnbits.User{} + // check if "username" is actually the user ID + tx := database + if _, err := strconv.ParseInt(username, 10, 64); err == nil { + // asume it's anon_id + tx = database.Where("anon_id = ?", username).First(user) + } else if strings.HasPrefix(username, "0x") { + // asume it's anon_id_sha256 + tx = database.Where("anon_id_sha256 = ?", username).First(user) + } else if strings.HasPrefix(username, "1x") { + // asume it's uuid + tx = database.Where("uuid = ?", username).First(user) + } else { + // assume it's a string @username + tx = database.Where("telegram_username = ? COLLATE NOCASE", username).First(user) + } + return user, tx +} + +func FindUserSettings(user *lnbits.User, settingsTx *gorm.DB) (*lnbits.User, error) { + // tx := bot.DB.Users.Preload("Settings").First(user) + tx := settingsTx.First(user) + if tx.Error != nil { + return user, tx.Error + } + if user.Settings == nil { + user.Settings = &lnbits.Settings{ID: user.ID} + } + return user, nil +} diff --git a/internal/errors/errors.go b/internal/errors/errors.go new file mode 100644 index 00000000..b6931194 --- /dev/null +++ b/internal/errors/errors.go @@ -0,0 +1,29 @@ +package errors + +import ( + "encoding/json" +) + +func Create(code TipBotErrorType) TipBotError { + return errMap[code] +} +func New(code TipBotErrorType, err error) TipBotError { + if err != nil { + return TipBotError{Err: err, Message: err.Error(), Code: code} + } + return Create(code) +} + +type TipBotError struct { + Message string `json:"message"` + Err error + Code TipBotErrorType `json:"code"` +} + +func (e TipBotError) Error() string { + j, err := json.Marshal(&e) + if err != nil { + return e.Message + } + return string(j) +} diff --git a/internal/errors/types.go b/internal/errors/types.go new file mode 100644 index 00000000..89014cc9 --- /dev/null +++ b/internal/errors/types.go @@ -0,0 +1,75 @@ +package errors + +import "fmt" + +type TipBotErrorType int + +const ( + UnknownError TipBotErrorType = iota + NoReplyMessageError + InvalidSyntaxError + MaxReachedError + NoPhotoError + NoFileFoundError + NotActiveError + InvalidTypeError +) + +const ( + UserNoWalletError TipBotErrorType = 2000 + iota + BalanceToLowError + SelfPaymentError + NoPrivateChatError + GetBalanceError + DecodeAmountError + DecodePerUserAmountError + InvalidAmountError + InvalidAmountPerUserError +) + +const ( + NoShopError TipBotErrorType = 3000 + iota + NotShopOwnerError + ShopNoOwnerError + ItemIdMismatchError +) + +var errMap = map[TipBotErrorType]TipBotError{ + UserNoWalletError: userNoWallet, + NoReplyMessageError: noReplyMessage, + InvalidSyntaxError: invalidSyntax, + InvalidAmountPerUserError: invalidAmount, + InvalidAmountError: invalidAmountPerUser, + NoPrivateChatError: noPrivateChat, + ShopNoOwnerError: shopNoOwner, + NotShopOwnerError: notShopOwner, + MaxReachedError: maxReached, + NoShopError: noShop, + SelfPaymentError: selfPayment, + NoPhotoError: noPhoto, + ItemIdMismatchError: itemIdMismatch, + NoFileFoundError: noFileFound, + UnknownError: unknown, + NotActiveError: notActive, + InvalidTypeError: invalidType, +} + +var ( + userNoWallet = TipBotError{Err: fmt.Errorf("user has no wallet")} + noReplyMessage = TipBotError{Err: fmt.Errorf("no reply message")} + invalidSyntax = TipBotError{Err: fmt.Errorf("invalid syntax")} + invalidAmount = TipBotError{Err: fmt.Errorf("invalid amount")} + invalidAmountPerUser = TipBotError{Err: fmt.Errorf("invalid amount per user")} + noPrivateChat = TipBotError{Err: fmt.Errorf("no private chat")} + shopNoOwner = TipBotError{Err: fmt.Errorf("shop has no owner")} + notShopOwner = TipBotError{Err: fmt.Errorf("user is not shop owner")} + maxReached = TipBotError{Err: fmt.Errorf("maximum reached")} + noShop = TipBotError{Err: fmt.Errorf("user has no shop")} + selfPayment = TipBotError{Err: fmt.Errorf("can't pay yourself")} + noPhoto = TipBotError{Err: fmt.Errorf("no photo in message")} + itemIdMismatch = TipBotError{Err: fmt.Errorf("item id mismatch")} + noFileFound = TipBotError{Err: fmt.Errorf("no file found")} + unknown = TipBotError{Err: fmt.Errorf("unknown error")} + notActive = TipBotError{Err: fmt.Errorf("element not active")} + invalidType = TipBotError{Err: fmt.Errorf("invalid type")} +) diff --git a/internal/gpt/gpt.go b/internal/gpt/gpt.go new file mode 100644 index 00000000..69f47e61 --- /dev/null +++ b/internal/gpt/gpt.go @@ -0,0 +1,102 @@ +package gpt + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "github.com/LightningTipBot/LightningTipBot/internal" + "github.com/LightningTipBot/LightningTipBot/internal/telegram/intercept" + "net/http" +) + +type Response struct { + Message struct { + ID string `json:"id"` + Role string `json:"role"` + User interface{} `json:"user"` + CreateTime interface{} `json:"create_time"` + UpdateTime interface{} `json:"update_time"` + Content struct { + ContentType string `json:"content_type"` + Parts []string `json:"parts"` + } `json:"content"` + EndTurn interface{} `json:"end_turn"` + Weight float64 `json:"weight"` + Metadata struct { + } `json:"metadata"` + Recipient string `json:"recipient"` + } `json:"message"` + ConversationID string `json:"conversation_id"` + Error interface{} `json:"error"` +} +type Request struct { + Action string `json:"action"` + ConversationId string `json:"conversation_id,omitempty"` + Messages []Messages `json:"messages"` + ParentMessageID string `json:"parent_message_id"` + Model string `json:"model"` +} +type Content struct { + ContentType string `json:"content_type"` + Parts []string `json:"parts"` +} +type Messages struct { + ID string `json:"id"` + Role string `json:"role"` + Content Content `json:"content"` +} + +var dataPrefix = []byte("data: ") +var doneSequence = []byte("[DONE]") + +func GetRawCompletion(ctx intercept.Context, rr Request, cb func(s string)) (*Response, error) { + rawClient := http.Client{} + r, err := json.Marshal(rr) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx.Context, "POST", "https://chat.openai.com/backend-api/conversation", bytes.NewBuffer(r)) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", internal.Configuration.Generate.OpenAiBearerToken)) + req.Header.Set("accept", "text/event-stream") + req.Header.Set("authority", "chat.openai.com") + req.Header.Set("accept-language", "de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7") + req.Header.Set("Content-type", "application/json") + req.Header.Set("user-agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36") + + resp, err := rawClient.Do(req) + if err != nil { + return nil, err + } + reader := bufio.NewReader(resp.Body) + defer resp.Body.Close() + output := new(Response) + for { + line, err := reader.ReadBytes('\n') + if err != nil { + return nil, err + } + // make sure there isn't any extra whitespace before or after + line = bytes.TrimSpace(line) + // the completion API only returns data events + if !bytes.HasPrefix(line, dataPrefix) { + continue + } + line = bytes.TrimPrefix(line, dataPrefix) + + // the stream is completed when terminated by [DONE] + if bytes.HasPrefix(line, doneSequence) { + break + } + if err := json.Unmarshal(line, output); err != nil { + return nil, fmt.Errorf("invalid json stream data: %v", err) + } + if len(output.Message.Content.Parts) > 0 { + cb(output.Message.Content.Parts[len(output.Message.Content.Parts)-1]) + } + } + return output, nil +} diff --git a/internal/i18n/localize.go b/internal/i18n/localize.go new file mode 100644 index 00000000..9aa55401 --- /dev/null +++ b/internal/i18n/localize.go @@ -0,0 +1,40 @@ +package i18n + +import ( + "github.com/BurntSushi/toml" + "github.com/nicksnyder/go-i18n/v2/i18n" + log "github.com/sirupsen/logrus" + "golang.org/x/text/language" +) + +var Bundle *i18n.Bundle + +func init() { + Bundle = RegisterLanguages() +} + +func RegisterLanguages() *i18n.Bundle { + bundle := i18n.NewBundle(language.English) + bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal) + bundle.MustLoadMessageFile("translations/en.toml") + bundle.LoadMessageFile("translations/de.toml") + bundle.LoadMessageFile("translations/fi.toml") + bundle.LoadMessageFile("translations/it.toml") + bundle.LoadMessageFile("translations/es.toml") + bundle.LoadMessageFile("translations/nl.toml") + bundle.LoadMessageFile("translations/pl.toml") + bundle.LoadMessageFile("translations/fr.toml") + bundle.LoadMessageFile("translations/pt-br.toml") + bundle.LoadMessageFile("translations/tr.toml") + bundle.LoadMessageFile("translations/cs.toml") + bundle.LoadMessageFile("translations/id.toml") + bundle.LoadMessageFile("translations/ru.toml") + return bundle +} +func Translate(languageCode string, MessgeID string) string { + str, err := i18n.NewLocalizer(Bundle, languageCode).Localize(&i18n.LocalizeConfig{MessageID: MessgeID}) + if err != nil { + log.Warnf("Error translating message %s: %s", MessgeID, err) + } + return str +} diff --git a/internal/lnbits/lnbits.go b/internal/lnbits/lnbits.go index 164b7563..63763950 100644 --- a/internal/lnbits/lnbits.go +++ b/internal/lnbits/lnbits.go @@ -1,13 +1,34 @@ package lnbits import ( + "fmt" + "time" + "github.com/imroc/req" ) +// parseLNbitsError extracts a meaningful error from an LNbits HTTP response. +func parseLNbitsError(resp *req.Resp) Error { + statusCode := resp.Response().StatusCode + rawBody := resp.String() + + var reqErr Error + resp.ToJSON(&reqErr) + reqErr.StatusCode = statusCode + if reqErr.Detail == "" && reqErr.Message == "" { + reqErr.RawBody = rawBody + } + return reqErr +} + // NewClient returns a new lnbits api client. Pass your API key and url here. func NewClient(key, url string) *Client { return &Client{ url: url, + // info: this header holds the ADMIN key for the entire API + // it can be used to create wallets for example + // if you want to check the balance of a user, use w.Inkey + // if you want to make a payment, use w.Adminkey header: req.Header{ "Content-Type": "application/json", "Accept": "application/json", @@ -24,9 +45,7 @@ func (c *Client) GetUser(userId string) (user User, err error) { } if resp.Response().StatusCode >= 300 { - var reqErr Error - resp.ToJSON(&reqErr) - err = reqErr + err = parseLNbitsError(resp) return } @@ -47,9 +66,7 @@ func (c *Client) CreateUserWithInitialWallet(userName, walletName, adminId strin } if resp.Response().StatusCode >= 300 { - var reqErr Error - resp.ToJSON(&reqErr) - err = reqErr + err = parseLNbitsError(resp) return } err = resp.ToJSON(&wal) @@ -68,28 +85,28 @@ func (c *Client) CreateWallet(userId, walletName, adminId string) (wal Wallet, e } if resp.Response().StatusCode >= 300 { - var reqErr Error - resp.ToJSON(&reqErr) - err = reqErr + err = parseLNbitsError(resp) 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(¶ms)) +func (w Wallet) Invoice(params InvoiceParams, c *Client) (lntx Invoice, err error) { + // custom header with invoice key + invoiceHeader := req.Header{ + "Content-Type": "application/json", + "Accept": "application/json", + "X-Api-Key": w.Inkey, + } + resp, err := req.Post(c.url+"/api/v1/payments", invoiceHeader, req.BodyJSON(¶ms)) if err != nil { return } if resp.Response().StatusCode >= 300 { - var reqErr Error - resp.ToJSON(&reqErr) - err = reqErr + err = parseLNbitsError(resp) return } @@ -99,16 +116,19 @@ func (c Client) Invoice(params InvoiceParams, w Wallet) (lntx BitInvoice, err er // 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) + // custom header with invoice key + invoiceHeader := req.Header{ + "Content-Type": "application/json", + "Accept": "application/json", + "X-Api-Key": w.Inkey, + } + resp, err := req.Get(c.url+"/api/v1/wallet", invoiceHeader, nil) if err != nil { return } if resp.Response().StatusCode >= 300 { - var reqErr Error - resp.ToJSON(&reqErr) - err = reqErr + err = parseLNbitsError(resp) return } @@ -116,6 +136,56 @@ func (c Client) Info(w Wallet) (wtx Wallet, err error) { return } +// Payments returns the 60 most recent wallet payments (default behavior). +func (c Client) Payments(w Wallet) (wtx Payments, err error) { + return c.PaymentsWithOptions(w, 60, 0) +} + +// PaymentsWithOptions returns wallet payments with configurable limit and offset. +func (c Client) PaymentsWithOptions(w Wallet, limit, offset int) (wtx Payments, err error) { + // custom header with invoice key + invoiceHeader := req.Header{ + "Content-Type": "application/json", + "Accept": "application/json", + "X-Api-Key": w.Inkey, + } + url := fmt.Sprintf("%s/api/v1/payments?limit=%d&offset=%d", c.url, limit, offset) + resp, err := req.Get(url, invoiceHeader, nil) + if err != nil { + return + } + + if resp.Response().StatusCode >= 300 { + err = parseLNbitsError(resp) + return + } + + err = resp.ToJSON(&wtx) + return +} + +// Payment state of a payment +func (c Client) Payment(w Wallet, payment_hash string) (payment LNbitsPayment, err error) { + // custom header with invoice key + invoiceHeader := req.Header{ + "Content-Type": "application/json", + "Accept": "application/json", + "X-Api-Key": w.Inkey, + } + resp, err := req.Get(c.url+fmt.Sprintf("/api/v1/payments/%s", payment_hash), invoiceHeader, nil) + if err != nil { + return + } + + if resp.Response().StatusCode >= 300 { + err = parseLNbitsError(resp) + return + } + + err = resp.ToJSON(&payment) + 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) @@ -124,9 +194,7 @@ func (c Client) Wallets(w User) (wtx []Wallet, err error) { } if resp.Response().StatusCode >= 300 { - var reqErr Error - resp.ToJSON(&reqErr) - err = reqErr + err = parseLNbitsError(resp) return } @@ -135,17 +203,22 @@ func (c Client) Wallets(w User) (wtx []Wallet, err error) { } // 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(¶ms)) +func (w Wallet) Pay(params PaymentParams, c *Client) (wtx Invoice, err error) { + // custom header with admin key + adminHeader := req.Header{ + "Content-Type": "application/json", + "Accept": "application/json", + "X-Api-Key": w.Adminkey, + } + r := req.New() + r.SetTimeout(time.Hour * 24) + resp, err := r.Post(c.url+"/api/v1/payments", adminHeader, req.BodyJSON(¶ms)) if err != nil { return } if resp.Response().StatusCode >= 300 { - var reqErr Error - resp.ToJSON(&reqErr) - err = reqErr + err = parseLNbitsError(resp) return } diff --git a/internal/lnbits/types.go b/internal/lnbits/types.go index 47277ce9..348a0bd0 100644 --- a/internal/lnbits/types.go +++ b/internal/lnbits/types.go @@ -1,8 +1,17 @@ package lnbits import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "time" + + "github.com/LightningTipBot/LightningTipBot/internal/satdress" + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/ecdsa" + "github.com/imroc/req" - tb "gopkg.in/tucnak/telebot.v2" + tb "gopkg.in/lightningtipbot/telebot.v3" ) type Client struct { @@ -13,13 +22,39 @@ type Client struct { } 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"` + 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"` + CreatedAt time.Time `json:"created"` + UpdatedAt time.Time `json:"updated"` + AnonID string `json:"anon_id"` + AnonIDSha256 string `json:"anon_id_sha256"` + UUID string `json:"uuid"` + Banned bool `json:"banned"` + Settings *Settings `json:"settings" gorm:"foreignKey:id"` +} + +type Settings struct { + ID string `json:"id" gorm:"primarykey"` + Display DisplaySettings `gorm:"embedded;embeddedPrefix:display_"` + Node NodeSettings `gorm:"embedded;embeddedPrefix:node_"` + Nostr NostrSettings `gorm:"embedded;embeddedPrefix:nostr_"` +} + +type DisplaySettings struct { + DisplayCurrency string `json:"displaycurrency"` +} +type NostrSettings struct { + PubKey string `json:"pubkey"` +} +type NodeSettings struct { + NodeType string `json:"nodetype"` + LNDParams *satdress.LNDParams `gorm:"embedded;embeddedPrefix:lndparams_"` + LNbitsParams *satdress.LNBitsParams `gorm:"embedded;embeddedPrefix:lnbitsparams_"` } const ( @@ -27,6 +62,18 @@ const ( UserStateConfirmSend UserStateLNURLEnterAmount UserStateConfirmLNURLPay + UserEnterAmount + UserHasEnteredAmount + UserEnterUser + UserHasEnteredUser + UserEnterShopTitle + UserStateShopItemSendPhoto + UserStateShopItemSendTitle + UserStateShopItemSendDescription + UserStateShopItemSendPrice + UserStateShopItemSendItemFile + UserEnterShopsDescription + UserEnterDallePrompt ) type UserStateKey int @@ -37,11 +84,12 @@ func (u *User) ResetState() { } 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. + Out bool `json:"out"` // must be True if invoice is payed, False if invoice is received + Amount int64 `json:"amount"` // amount in Satoshi + 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. + UnhashedDescription string `json:"unhashed_description,omitempty"` // the unhashed invoice description. } type PaymentParams struct { @@ -63,18 +111,29 @@ type TransferParams struct { } type Error struct { - Name string `json:"name"` - Message string `json:"message"` - Code int `json:"code"` - Status int `json:"status"` + Detail string `json:"detail"` + Message string `json:"message"` + StatusCode int `json:"-"` + RawBody string `json:"-"` } func (err Error) Error() string { - return err.Message + if err.Detail != "" { + return err.Detail + } + if err.Message != "" { + return err.Message + } + if err.RawBody != "" { + return fmt.Sprintf("LNbits HTTP %d: %s", err.StatusCode, err.RawBody) + } + if err.StatusCode != 0 { + return fmt.Sprintf("LNbits HTTP %d: unknown error", err.StatusCode) + } + return "unknown LNbits error" } type Wallet struct { - *Client `gorm:"-"` ID string `json:"id" gorm:"id"` Adminkey string `json:"adminkey"` Inkey string `json:"inkey"` @@ -82,23 +141,83 @@ type Wallet struct { Name string `json:"name"` User string `json:"user"` } -type BitInvoice struct { + +type Payment struct { + CheckingID string `json:"checking_id"` + Pending bool `json:"pending"` + Amount int64 `json:"amount"` + Fee int64 `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 interface{} `json:"webhook"` + WebhookStatus interface{} `json:"webhook_status"` +} + +type LNbitsPayment struct { + Paid bool `json:"paid"` + Preimage string `json:"preimage"` + Details Payment `json:"details,omitempty"` +} + +type Payments []Payment + +type Invoice 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"` + +// from fiatjaf/lnurl-go +func (u User) LinkingKey(domain string) (*btcec.PrivateKey, *btcec.PublicKey) { + seedHash := sha256.Sum256([]byte( + fmt.Sprintf("lnurlkeyseed:%s:%s", + domain, u.ID))) + return btcec.PrivKeyFromBytes(seedHash[:]) +} + +func (u User) SignKeyAuth(domain string, k1hex string) (key string, sig string, err error) { + // lnurl-auth: create a key based on the user id and sign with it + sk, pk := u.LinkingKey(domain) + + k1, err := hex.DecodeString(k1hex) + if err != nil { + return "", "", fmt.Errorf("invalid k1 hex '%s': %w", k1hex, err) + } + + signature := ecdsa.Sign(sk, k1) + if err != nil { + return "", "", fmt.Errorf("error signing k1: %w", err) + } + + sig = hex.EncodeToString(signature.Serialize()) + key = hex.EncodeToString(pk.SerializeCompressed()) + + return key, sig, nil +} + +type SavingsPot struct { + ID string `json:"id" gorm:"primaryKey"` + UserID string `json:"user_id" gorm:"index"` + Name string `json:"name"` + Balance int64 `json:"balance" gorm:"default:0;check:balance >= 0" validate:"min=0"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` + UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` + User *User `gorm:"foreignKey:UserID;references:ID"` +} + +type StandingOrder struct { + ID string `json:"id" gorm:"primaryKey"` + UserID string `json:"user_id" gorm:"index"` + PotName string `json:"pot_name"` + DayOfMonth int `json:"day_of_month"` + Amount int64 `json:"amount"` + Active bool `json:"active" gorm:"default:true"` + LastExecutedAt *time.Time `json:"last_executed_at"` + ConsecutiveFailures int `json:"consecutive_failures" gorm:"default:0"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` + UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` } diff --git a/internal/lnbits/webhook.go b/internal/lnbits/webhook.go deleted file mode 100644 index 3bf3f2f3..00000000 --- a/internal/lnbits/webhook.go +++ /dev/null @@ -1,82 +0,0 @@ -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) -} diff --git a/internal/lnbits/webhook/webhook.go b/internal/lnbits/webhook/webhook.go new file mode 100644 index 00000000..53e81240 --- /dev/null +++ b/internal/lnbits/webhook/webhook.go @@ -0,0 +1,127 @@ +package webhook + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/LightningTipBot/LightningTipBot/internal" + "github.com/LightningTipBot/LightningTipBot/internal/lnbits" + "github.com/LightningTipBot/LightningTipBot/internal/telegram" + "github.com/LightningTipBot/LightningTipBot/internal/utils" + + log "github.com/sirupsen/logrus" + "gorm.io/gorm" + + "net/http" + + "github.com/LightningTipBot/LightningTipBot/internal/storage" + + "github.com/gorilla/mux" + tb "gopkg.in/lightningtipbot/telebot.v3" + + "github.com/LightningTipBot/LightningTipBot/internal/i18n" +) + +type Server struct { + httpServer *http.Server + bot *tb.Bot + c *lnbits.Client + database *gorm.DB + buntdb *storage.DB +} + +type Webhook struct { + CheckingID string `json:"checking_id"` + Pending bool `json:"pending"` + Amount int64 `json:"amount"` + Fee int64 `json:"fee"` + Memo string `json:"memo"` + Time int64 `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"` +} + +func NewServer(bot *telegram.TipBot) *Server { + srv := &http.Server{ + Addr: internal.Configuration.Lnbits.WebhookServerUrl.Host, + WriteTimeout: 15 * time.Second, + ReadTimeout: 15 * time.Second, + } + apiServer := &Server{ + c: bot.Client, + database: bot.DB.Users, + bot: bot.Telegram, + httpServer: srv, + buntdb: bot.Bunt, + } + apiServer.httpServer.Handler = apiServer.newRouter() + go apiServer.httpServer.ListenAndServe() + log.Infof("[Webhook] Server started at %s (public URL: %s)", internal.Configuration.Lnbits.WebhookServerUrl, internal.GetWebhookURL()) + return apiServer +} + +func (w *Server) GetUserByWalletId(walletId string) (*lnbits.User, error) { + user := &lnbits.User{} + tx := w.database.Where("wallet_id = ?", walletId).First(user) + if tx.Error != nil { + return user, tx.Error + } + return user, nil +} + +func (w *Server) newRouter() *mux.Router { + router := mux.NewRouter() + router.HandleFunc("/", w.receive).Methods(http.MethodPost) + return router +} + +func (w *Server) receive(writer http.ResponseWriter, request *http.Request) { + log.Debugln("[Webhook] Received request") + webhookEvent := Webhook{} + // need to delete the header otherwise the Decode will fail + request.Header.Del("content-length") + err := json.NewDecoder(request.Body).Decode(&webhookEvent) + if err != nil { + log.Errorf("[Webhook] Error decoding request: %s", err.Error()) + writer.WriteHeader(400) + return + } + user, err := w.GetUserByWalletId(webhookEvent.WalletID) + if err != nil { + log.Errorf("[Webhook] Error getting user: %s", err.Error()) + writer.WriteHeader(400) + return + } + log.Infoln(fmt.Sprintf("[⚡️ WebHook] User %s (%d) received invoice of %d sat.", telegram.GetUserStr(user.Telegram), user.Telegram.ID, webhookEvent.Amount/1000)) + + writer.WriteHeader(200) + + // trigger invoice events + txInvoiceEvent := &telegram.InvoiceEvent{Invoice: &telegram.Invoice{PaymentHash: webhookEvent.PaymentHash}} + err = w.buntdb.Get(txInvoiceEvent) + if err != nil { + log.Errorln(err) + } else { + // do something with the event + if c := telegram.InvoiceCallback[txInvoiceEvent.Callback]; c.Function != nil { + if err := telegram.AssertEventType(txInvoiceEvent, c.Type); err != nil { + log.Errorln(err) + return + } + go c.Function(txInvoiceEvent) + return + } + } + + // fallback: send a message to the user if there is no callback for this invoice + _, err = w.bot.Send(user.Telegram, fmt.Sprintf(i18n.Translate(user.Telegram.LanguageCode, "invoiceReceivedMessage"), utils.FormatSats(webhookEvent.Amount/1000))) + if err != nil { + log.Errorln(err) + } +} diff --git a/internal/lndhub/lndhub.go b/internal/lndhub/lndhub.go new file mode 100644 index 00000000..d9cdf949 --- /dev/null +++ b/internal/lndhub/lndhub.go @@ -0,0 +1,21 @@ +package lndhub + +import ( + "net/http" + + "github.com/LightningTipBot/LightningTipBot/internal" + "github.com/LightningTipBot/LightningTipBot/internal/api" + "github.com/LightningTipBot/LightningTipBot/internal/telegram" + "gorm.io/gorm" +) + +type LndHub struct { + database *gorm.DB +} + +func New(bot *telegram.TipBot) LndHub { + return LndHub{database: bot.DB.Users} +} +func (w LndHub) Handle(writer http.ResponseWriter, request *http.Request) { + api.Proxy(writer, request, internal.Configuration.Lnbits.Url) +} diff --git a/internal/lnurl/lnurl.go b/internal/lnurl/lnurl.go index c2e6bf9b..1d93441b 100644 --- a/internal/lnurl/lnurl.go +++ b/internal/lnurl/lnurl.go @@ -9,14 +9,74 @@ import ( "net/url" "strconv" "strings" + "time" + "github.com/eko/gocache/store" + tb "gopkg.in/lightningtipbot/telebot.v3" + + "github.com/LightningTipBot/LightningTipBot/internal" + "github.com/LightningTipBot/LightningTipBot/internal/api" + "github.com/LightningTipBot/LightningTipBot/internal/storage" + "gorm.io/gorm" + + db "github.com/LightningTipBot/LightningTipBot/internal/database" "github.com/LightningTipBot/LightningTipBot/internal/lnbits" + "github.com/LightningTipBot/LightningTipBot/internal/runtime" + "github.com/LightningTipBot/LightningTipBot/internal/telegram" + "github.com/LightningTipBot/LightningTipBot/internal/utils" "github.com/fiatjaf/go-lnurl" "github.com/gorilla/mux" + "github.com/nbd-wtf/go-nostr" log "github.com/sirupsen/logrus" ) -func (w Server) handleLnUrl(writer http.ResponseWriter, request *http.Request) { +const ( + PayRequestTag = "payRequest" + Endpoint = ".well-known/lnurlp" + MinSendable = 1000 // mSat + MaxSendable = 1_000_000_000 + CommentAllowed = 2000 +) + +type Invoice struct { + *telegram.Invoice + Comment string `json:"comment"` + User *lnbits.User `json:"user"` + CreatedAt time.Time `json:"created_at"` + Paid bool `json:"paid"` + PaidAt time.Time `json:"paid_at"` + From string `json:"from"` + Nip57Receipt nostr.Event `json:"nip57_receipt"` + Nip57ReceiptRelays []string `json:"nip57_receipt_relays"` +} +type Lnurl struct { + telegram *tb.Bot + c *lnbits.Client + database *gorm.DB + callbackHostname *url.URL + buntdb *storage.DB + WebhookServer string + cache telegram.Cache + bot *telegram.TipBot +} + +func New(bot *telegram.TipBot) Lnurl { + return Lnurl{ + c: bot.Client, + database: bot.DB.Users, + callbackHostname: internal.Configuration.Bot.LNURLHostUrl, + WebhookServer: internal.GetWebhookURL(), + buntdb: bot.Bunt, + telegram: bot.Telegram, + cache: bot.Cache, + bot: bot, + } +} +func (lnurlInvoice Invoice) Key() string { + return fmt.Sprintf("lnurl-p:%s", lnurlInvoice.PaymentHash) +} + +func (w Lnurl) Handle(writer http.ResponseWriter, request *http.Request) { var err error var response interface{} username := mux.Vars(request)["username"] @@ -25,133 +85,505 @@ func (w Server) handleLnUrl(writer http.ResponseWriter, request *http.Request) { } else { stringAmount := request.FormValue("amount") if stringAmount == "" { - NotFoundHandler(writer, fmt.Errorf("[serveLNURLpSecond] Form value 'amount' is not set")) + api.NotFoundHandler(writer, fmt.Errorf("[handleLnUrl] 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)) + + var amount int64 + if amount, err = strconv.ParseInt(stringAmount, 10, 64); err != nil { + // if the value wasn't a clean msat denomination, parse it + amount, err = telegram.GetAmount(stringAmount) + if err != nil { + api.NotFoundHandler(writer, fmt.Errorf("[handleLnUrl] Couldn't cast amount to int: %v", err)) + return + } + // GetAmount returns sat, we need msat + amount *= 1000 + } + + comment := request.FormValue("comment") + if len(comment) > CommentAllowed { + api.NotFoundHandler(writer, fmt.Errorf("[handleLnUrl] Comment is too long")) return } - response, err = w.serveLNURLpSecond(username, int64(amount)) + + // payer data + payerdata := request.FormValue("payerdata") + var payerData lnurl.PayerDataValues + if len(payerdata) > 0 { + err = json.Unmarshal([]byte(payerdata), &payerData) + if err != nil { + // Log to Telegram error channel + if w.bot.ErrorLogger != nil { + requestDetails := map[string]interface{}{ + "Username": username, + "PayerData": payerdata, + } + w.bot.ErrorLogger.LogLNURLError(err, "PayerData Parse", username, requestDetails) + } + log.Errorf("[handleLnUrl] Couldn't parse payerdata: %v", err) + // log.Errorf("[handleLnUrl] payerdata: %v", payerdata) + } + + } + + // nostr NIP-57 + // the "nostr" query param has a zap request which is a nostr event + // that specifies which nostr note has been zapped. + // here we check wheter its present, the event signature is valid + // and whether the event has the necessary tags that we need (p and relays are necessary, e is optional) + zapEventQuery := request.FormValue("nostr") + var zapEvent nostr.Event + if len(zapEventQuery) > 0 { + err = json.Unmarshal([]byte(zapEventQuery), &zapEvent) + if err != nil { + if w.bot.ErrorLogger != nil { + requestDetails := map[string]interface{}{ + "Username": username, + "ZapEvent": zapEventQuery, + } + w.bot.ErrorLogger.LogLNURLError(err, "Nostr Event Parse", username, requestDetails) + } + log.Errorf("[handleLnUrl] Couldn't parse nostr event: %v", err) + } else { + valid, err := zapEvent.CheckSignature() + if !valid || err != nil { + if w.bot.ErrorLogger != nil { + requestDetails := map[string]interface{}{ + "Username": username, + "ZapEvent": zapEvent.ID, + "PublicKey": zapEvent.PubKey, + } + w.bot.ErrorLogger.LogLNURLError(fmt.Errorf("signature validation failed: %v", err), "Nostr Zap Signature", username, requestDetails) + } + log.Errorf("[handleLnUrl] Nostr NIP-57 zap event signature invalid: %v", err) + return + } + if len(zapEvent.Tags) == 0 || zapEvent.Tags.GetFirst([]string{"p"}) == nil { + // zapEvent.Tags.GetFirst([]string{"e"}) == nil { + if w.bot.ErrorLogger != nil { + requestDetails := map[string]interface{}{ + "Username": username, + "ZapEvent": zapEvent.ID, + "Tags": fmt.Sprintf("%v", zapEvent.Tags), + } + w.bot.ErrorLogger.LogLNURLError(fmt.Errorf("missing required tags"), "Nostr Zap Validation", username, requestDetails) + } + log.Errorf("[handleLnUrl] Nostr NIP-57 zap event validation error") + return + } + + } + } + + response, err = w.serveLNURLpSecond(username, int64(amount), comment, payerData, zapEvent) } // check if error was returned from first or second handlers if err != nil { + // log the error to Telegram if ErrorLogger is available + if w.bot.ErrorLogger != nil { + requestDetails := map[string]interface{}{ + "Username": username, + "Query": request.URL.RawQuery, + "Method": request.Method, + "User-Agent": request.Header.Get("User-Agent"), + } + if request.URL.RawQuery != "" { + requestDetails["Amount"] = request.FormValue("amount") + requestDetails["Comment"] = request.FormValue("comment") + } + w.bot.ErrorLogger.LogLNURLError(err, "LNURL Request Handler", username, requestDetails) + } + // log the error - log.Errorf("[LNURL] %v", err) + log.Errorf("[LNURL] %v", err.Error()) if response != nil { // there is a valid error response - err = writeResponse(writer, response) + err = api.WriteResponse(writer, response) if err != nil { - NotFoundHandler(writer, err) + api.NotFoundHandler(writer, err) } } return } // no error from first or second handler - err = writeResponse(writer, response) + err = api.WriteResponse(writer, response) if err != nil { - NotFoundHandler(writer, err) + api.NotFoundHandler(writer, err) + } +} +func (w Lnurl) getMetaDataCached(username string) lnurl.Metadata { + key := fmt.Sprintf("lnurl_metadata_%s", username) + + // load metadata from cache + if m, err := w.cache.Get(key); err == nil { + return m.(lnurl.Metadata) + } + + // otherwise, create new metadata + metadata := w.metaData(username) + + // load the user profile picture + if internal.Configuration.Bot.LNURLSendImage { + // get the user from the database + user, tx := db.FindUser(w.database, username) + if tx.Error == nil && user.Telegram != nil { + addImageToMetaData(w.telegram, &metadata, username, user.Telegram) + } } + + // save into cache + runtime.IgnoreError(w.cache.Set(key, metadata, &store.Options{Expiration: 30 * time.Minute})) + return metadata +} + +// we have our custom LNURLPayParams response object here because we want to +// add nostr nip57 fields to it +type LNURLPayParamsCustom struct { + lnurl.LNURLResponse + Callback string `json:"callback"` + Tag string `json:"tag"` + MaxSendable int64 `json:"maxSendable"` + MinSendable int64 `json:"minSendable"` + EncodedMetadata string `json:"metadata"` + CommentAllowed int64 `json:"commentAllowed"` + PayerData *lnurl.PayerDataSpec `json:"payerData,omitempty"` + AllowNostr bool `json:"allowsNostr,omitempty"` + NostrPubKey string `json:"nostrPubkey,omitempty"` + Metadata lnurl.Metadata `json:"-"` } // 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) { +func (w Lnurl) serveLNURLpFirst(username string) (*LNURLPayParamsCustom, error) { log.Infof("[LNURL] Serving endpoint for user %s", username) - callbackURL, err := url.Parse(fmt.Sprintf("%s/%s/%s", w.callbackHostname.String(), lnurlEndpoint, username)) + callbackURL, err := url.Parse(fmt.Sprintf("%s/%s/%s", w.callbackHostname.String(), Endpoint, username)) if err != nil { return nil, err } - metadata := w.metaData(username) - jsonMeta, err := json.Marshal(metadata) - if err != nil { - return nil, err + + // produce the metadata including the image + metadata := w.getMetaDataCached(username) + + // check if the user has added a nostr key for nip57 + var allowNostr bool = false + var nostrPubkey string = "" + // if the bot has a nostr private key + if len(internal.Configuration.Nostr.PrivateKey) > 0 { + allowNostr = true + pk := internal.Configuration.Nostr.PrivateKey + pub, _ := nostr.GetPublicKey(pk) + nostrPubkey = pub } - return &lnurl.LNURLPayResponse1{ - LNURLResponse: lnurl.LNURLResponse{Status: statusOk}, - Tag: payRequestTag, + return &LNURLPayParamsCustom{ + LNURLResponse: lnurl.LNURLResponse{Status: api.StatusOk}, + Tag: PayRequestTag, Callback: callbackURL.String(), - CallbackURL: callbackURL, // probably no need to set this here - MinSendable: minSendable, + MinSendable: MinSendable, MaxSendable: MaxSendable, - EncodedMetadata: string(jsonMeta), + EncodedMetadata: metadata.Encode(), + CommentAllowed: CommentAllowed, + PayerData: &lnurl.PayerDataSpec{ + FreeName: &lnurl.PayerDataItemSpec{}, + LightningAddress: &lnurl.PayerDataItemSpec{}, + Email: &lnurl.PayerDataItemSpec{}, + }, + AllowNostr: allowNostr, + NostrPubKey: nostrPubkey, }, 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) { +func (w Lnurl) serveLNURLpSecond(username string, amount_msat int64, comment string, payerData lnurl.PayerDataValues, zapEvent nostr.Event) (*lnurl.LNURLPayValues, error) { log.Infof("[LNURL] Serving invoice for user %s", username) - if amount < minSendable || amount > MaxSendable { + if amount_msat < MinSendable || amount_msat > MaxSendable { // amount is not ok - return &lnurl.LNURLPayResponse2{ + return &lnurl.LNURLPayValues{ LNURLResponse: lnurl.LNURLResponse{ - Status: statusError, - Reason: fmt.Sprintf("Amount out of bounds (min: %d mSat, max: %d mSat).", minSendable, MaxSendable)}, + Status: api.StatusError, + Reason: fmt.Sprintf("Amount out of bounds (min: %s, max: %s).", utils.FormatSats(MinSendable/1000), utils.FormatSats(MaxSendable/1000))}, }, 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) + // check comment length + if len(comment) > CommentAllowed { + return &lnurl.LNURLPayValues{ + LNURLResponse: lnurl.LNURLResponse{ + Status: api.StatusError, + Reason: fmt.Sprintf("Comment too long (max: %d characters).", CommentAllowed)}, + }, fmt.Errorf("comment too long") } - if user.Wallet == nil || user.Initialized == false { - return nil, fmt.Errorf("[serveLNURLpSecond] invalid user data") + // get rid of LNURL spam + if amount_msat < 21_000 { + comment = "" } + user, tx := db.FindUser(w.database, username) + if tx.Error != nil { + // Log to Telegram error channel + if w.bot.ErrorLogger != nil { + requestDetails := map[string]interface{}{ + "Username": username, + "DatabaseError": tx.Error.Error(), + } + w.bot.ErrorLogger.LogLNURLError(tx.Error, "User Lookup", username, requestDetails) + } - // set wallet lnbits client - user.Wallet.Client = w.c - var resp *lnurl.LNURLPayResponse2 + return &lnurl.LNURLPayValues{ + LNURLResponse: lnurl.LNURLResponse{ + Status: api.StatusError, + Reason: fmt.Sprintf("Invalid user.")}, + }, fmt.Errorf("[GetUser] Couldn't fetch user info from database: %v", tx.Error) + } + if user.Wallet == nil { + // Log to Telegram error channel + if w.bot.ErrorLogger != nil { + requestDetails := map[string]interface{}{ + "Username": username, + "UserID": user.ID, + } + w.bot.ErrorLogger.LogLNURLError(fmt.Errorf("user has no wallet"), "Wallet Check", username, requestDetails) + } - // the same description_hash needs to be built in the second request - metadata := w.metaData(username) - descriptionHash, err := w.descriptionHash(metadata) + return &lnurl.LNURLPayValues{ + LNURLResponse: lnurl.LNURLResponse{ + Status: api.StatusError, + Reason: fmt.Sprintf("Invalid user.")}, + }, fmt.Errorf("[serveLNURLpSecond] user %s not found", username) + } + // get user settings + user2, err := db.FindUserSettings(user, w.bot.DB.Users.Preload("Settings")) if err != nil { - return nil, err + fmt.Errorf("[serveLNURLpSecond] Couldn't fetch user settings from database: %v", err) + } else { + user = user2 + } + // user is ok now create invoice + // set wallet lnbits client + + var resp *lnurl.LNURLPayValues + var descriptionHash string + + // NIP57 ZAPs + // TODO: refactor all this into nip57.go + var nip57Receipt nostr.Event + var zapEventSerializedStr string + var nip57ReceiptRelays []string + // for nip57 use the nostr event as the descriptionHash + if zapEvent.Sig != "" { + log.Infof("[LNURL] nostr zap for user %s", username) + // we calculate the descriptionHash here, create an invoice with it + // and store the invoice in the zap receipt later down the line + zapEventSerialized, err := json.Marshal(zapEvent) + zapEventSerializedStr = fmt.Sprintf("%s", zapEventSerialized) + if err != nil { + // Log to Telegram error channel + if w.bot.ErrorLogger != nil { + requestDetails := map[string]interface{}{ + "Username": username, + "ZapEventID": zapEvent.ID, + "PubKey": zapEvent.PubKey, + } + w.bot.ErrorLogger.LogLNURLError(err, "Zap Event Serialization", username, requestDetails) + } + log.Println(err) + return &lnurl.LNURLPayValues{ + LNURLResponse: lnurl.LNURLResponse{ + Status: api.StatusError, + Reason: "Couldn't serialize zap event."}, + }, err + } + // we extract the relays from the zap request + nip57ReceiptRelaysTags := zapEvent.Tags.GetFirst([]string{"relays"}) + if len(fmt.Sprintf("%s", nip57ReceiptRelaysTags)) > 0 { + nip57ReceiptRelays = strings.Split(fmt.Sprintf("%s", nip57ReceiptRelaysTags), " ") + // this tirty method returns slice [ "[relays", "wss...", "wss...", "wss...]" ] – we need to clean it up + if len(nip57ReceiptRelays) > 1 { + // remove the first entry + nip57ReceiptRelays = nip57ReceiptRelays[1:] + // clean up the last entry + len_last_entry := len(nip57ReceiptRelays[len(nip57ReceiptRelays)-1]) + nip57ReceiptRelays[len(nip57ReceiptRelays)-1] = nip57ReceiptRelays[len(nip57ReceiptRelays)-1][:len_last_entry-1] + } + // now the relay list is clean! + } + // calculate description hash from the serialized nostr event + descriptionHash = w.Nip57DescriptionHash(zapEventSerializedStr) + } else { + // calculate normal LNURL descriptionhash + // the same description_hash needs to be built in the second request + metadata := w.getMetaDataCached(username) + + var payerDataByte []byte + var err error + if payerData.Email != "" || payerData.LightningAddress != "" || payerData.FreeName != "" { + payerDataByte, err = json.Marshal(payerData) + if err != nil { + return nil, err + } + } else { + payerDataByte = []byte("") + } + + descriptionHash, err = w.DescriptionHash(metadata, string(payerDataByte)) + if err != nil { + return nil, err + } } + invoice, err := user.Wallet.Invoice( lnbits.InvoiceParams{ - Amount: amount / 1000, + Amount: amount_msat / 1000, Out: false, DescriptionHash: descriptionHash, Webhook: w.WebhookServer}, - *user.Wallet) + w.c) if err != nil { - err = fmt.Errorf("[serveLNURLpSecond] Couldn't create invoice: %v", err) - resp = &lnurl.LNURLPayResponse2{ + // Log to Telegram error channel + if w.bot.ErrorLogger != nil { + requestDetails := map[string]interface{}{ + "Username": username, + "Amount": amount_msat / 1000, + "DescriptionHash": descriptionHash, + "WalletID": user.Wallet.ID, + } + w.bot.ErrorLogger.LogLNURLError(err, "Invoice Creation", username, requestDetails) + } + + err = fmt.Errorf("[serveLNURLpSecond] Couldn't create invoice: %v", err.Error()) + resp = &lnurl.LNURLPayValues{ LNURLResponse: lnurl.LNURLResponse{ - Status: statusError, + Status: api.StatusError, Reason: "Couldn't create invoice."}, } return resp, err } - return &lnurl.LNURLPayResponse2{ - LNURLResponse: lnurl.LNURLResponse{Status: statusOk}, + invoiceStruct := &telegram.Invoice{ + PaymentRequest: invoice.PaymentRequest, + PaymentHash: invoice.PaymentHash, + Amount: amount_msat / 1000, + } + + // nip57 - we need to store the newly created invoice in the zap receipt + if zapEvent.Sig != "" { + pk := internal.Configuration.Nostr.PrivateKey + pub, _ := nostr.GetPublicKey(pk) + nip57Receipt = nostr.Event{ + PubKey: pub, + CreatedAt: time.Now(), + Kind: 9735, + Tags: nostr.Tags{ + *zapEvent.Tags.GetFirst([]string{"p"}), + []string{"bolt11", invoice.PaymentRequest}, + []string{"description", zapEventSerializedStr}, + }, + } + if zapEvent.Tags.GetFirst([]string{"e"}) != nil { + nip57Receipt.Tags = nip57Receipt.Tags.AppendUnique(*zapEvent.Tags.GetFirst([]string{"e"})) + } + nip57Receipt.Sign(pk) + } + + // save lnurl invoice struct for later use (will hold the comment or other metadata for a notification when paid) + // also holds Nip57 Zap receipt to send to nostr when invoice is paid + runtime.IgnoreError(w.buntdb.Set( + Invoice{ + Invoice: invoiceStruct, + User: user, + Comment: comment, + CreatedAt: time.Now(), + From: extractSenderFromPayerdata(payerData), + Nip57Receipt: nip57Receipt, + Nip57ReceiptRelays: nip57ReceiptRelays, + })) + // save the invoice Event that will be loaded when the invoice is paid and trigger the comment display callback + runtime.IgnoreError(w.buntdb.Set( + telegram.InvoiceEvent{ + Invoice: invoiceStruct, + User: user, + Callback: telegram.InvoiceCallbackLNURLPayReceive, + UserCurrency: user.Settings.Display.DisplayCurrency, + })) + + return &lnurl.LNURLPayValues{ + LNURLResponse: lnurl.LNURLResponse{Status: api.StatusOk}, PR: invoice.PaymentRequest, - Routes: make([][]lnurl.RouteInfo, 0), + Routes: make([]struct{}, 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 +// DescriptionHash is the SHA256 hash of the metadata +func (w Lnurl) DescriptionHash(metadata lnurl.Metadata, payerData string) (string, error) { + var hashString string + var hash [32]byte + if len(payerData) == 0 { + hash = sha256.Sum256([]byte(metadata.Encode())) + hashString = hex.EncodeToString(hash[:]) + } else { + hash = sha256.Sum256([]byte(metadata.Encode() + payerData)) + hashString = hex.EncodeToString(hash[:]) } - 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 { +func (w Lnurl) metaData(username string) lnurl.Metadata { + // this is a bit stupid but if the address is a UUID starting with 1x... + // we actually want to find the users username so it looks nicer in the + // metadata description + if strings.HasPrefix(username, "1x") { + user, _ := db.FindUser(w.database, username) + if user.Telegram.Username != "" { + username = user.Telegram.Username + } + } + 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())}} + Description: fmt.Sprintf("Pay to %s@%s", username, w.callbackHostname.Hostname()), + LightningAddress: fmt.Sprintf("%s@%s", username, w.callbackHostname.Hostname()), + } +} + +// addImageMetaData add images an image to the LNURL metadata +func addImageToMetaData(tb *tb.Bot, metadata *lnurl.Metadata, username string, user *tb.User) { + metadata.Image.Ext = "jpeg" + + // if the username is anonymous, add the bot's picture + if isAnonUsername(username) { + metadata.Image.Bytes = telegram.BotProfilePicture + return + } + + // if the user has a profile picture, add it + picture, err := telegram.DownloadProfilePicture(tb, user) + if err != nil { + log.Debugf("[LNURL] Couldn't download user %s's profile picture: %v", username, err) + // in case the user has no image, use bot's picture + metadata.Image.Bytes = telegram.BotProfilePicture + return + } + metadata.Image.Bytes = picture +} + +func isAnonUsername(username string) bool { + if _, err := strconv.ParseInt(username, 10, 64); err == nil { + return true + } else { + return strings.HasPrefix(username, "0x") + } +} + +func extractSenderFromPayerdata(payer lnurl.PayerDataValues) string { + if payer.LightningAddress != "" { + return payer.LightningAddress + } + if payer.Email != "" { + return payer.Email + } + if payer.FreeName != "" { + return payer.FreeName + } + return "" } diff --git a/internal/lnurl/nip57.go b/internal/lnurl/nip57.go new file mode 100644 index 00000000..ef8d9895 --- /dev/null +++ b/internal/lnurl/nip57.go @@ -0,0 +1,28 @@ +package lnurl + +import ( + "crypto/sha256" + "encoding/hex" + "time" +) + +type Tag []string + +type Tags []Tag + +type NostrEvent struct { + ID string `json:"id"` + PubKey string `json:"pubkey"` + CreatedAt time.Time `json:"created_at"` + Kind int `json:"kind"` + Tags Tags `json:"tags"` + Content string `json:"content"` + Sig string `json:"sig"` +} + +// DescriptionHash is the SHA256 hash of the metadata +func (w Lnurl) Nip57DescriptionHash(zapEventSerialized string) string { + hash := sha256.Sum256([]byte(zapEventSerialized)) + hashString := hex.EncodeToString(hash[:]) + return hashString +} diff --git a/internal/lnurl/server.go b/internal/lnurl/server.go deleted file mode 100644 index 76f78069..00000000 --- a/internal/lnurl/server.go +++ /dev/null @@ -1,76 +0,0 @@ -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 -} diff --git a/internal/network/http.go b/internal/network/http.go new file mode 100644 index 00000000..df2476d4 --- /dev/null +++ b/internal/network/http.go @@ -0,0 +1,81 @@ +package network + +import ( + "context" + "fmt" + "net" + "net/http" + "net/url" + "regexp" + "time" + + "github.com/LightningTipBot/LightningTipBot/internal" + log "github.com/sirupsen/logrus" + "golang.org/x/net/proxy" +) + +type ClientType string + +const ( + ClientTypeClearNet = "clearnet" + ClientTypeTor = "tor" +) + +// checks if strings contains http(s)://*.onion +var isOnion = regexp.MustCompile("^https?\\:\\/\\/[\\w\\-\\.]+\\.onion") + +// GetClientForScheme returns correct client for url scheme. +// if tld is .onion, function will also return an onion client. +func GetClientForScheme(url *url.URL) (*http.Client, error) { + if isOnion.FindString(url.String()) != "" { + return GetClient(ClientTypeTor) + } + switch url.Scheme { + case "onion": + return GetClient(ClientTypeTor) + default: + return GetClient(ClientTypeClearNet) + } +} + +func GetClient(clientType ClientType) (*http.Client, error) { + client := http.Client{ + Timeout: 10 * time.Second, + } + var cfg *internal.SocksConfiguration + switch clientType { + case ClientTypeClearNet: + cfg = internal.Configuration.Bot.SocksProxy + case ClientTypeTor: + cfg = internal.Configuration.Bot.TorProxy + default: + return nil, fmt.Errorf("[GetClient] invalid clientType") + } + if cfg == nil { + return &client, nil + } + if cfg.Host == "" { + return &client, nil + } + proxyURL, _ := url.Parse(cfg.Host) + specialTransport := &http.Transport{} + specialTransport.Proxy = http.ProxyURL(proxyURL) + var auth *proxy.Auth + if cfg.Username != "" && cfg.Password != "" { + auth = &proxy.Auth{User: cfg.Username, Password: cfg.Password} + } + d, err := proxy.SOCKS5("tcp", cfg.Host, auth, &net.Dialer{ + Timeout: 20 * time.Second, + Deadline: time.Now().Add(time.Second * 10), + KeepAlive: -1, + }) + if err != nil { + log.Errorln(err) + return &client, nil + } + specialTransport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { + return d.Dial(network, addr) + } + client.Transport = specialTransport + return &client, nil +} diff --git a/internal/nostr/nip05.go b/internal/nostr/nip05.go new file mode 100644 index 00000000..365ec2a3 --- /dev/null +++ b/internal/nostr/nip05.go @@ -0,0 +1,56 @@ +package nostr + +import ( + "fmt" + "net/http" + + "github.com/LightningTipBot/LightningTipBot/internal/api" + db "github.com/LightningTipBot/LightningTipBot/internal/database" + "github.com/LightningTipBot/LightningTipBot/internal/telegram" + "github.com/prometheus/common/log" + "gorm.io/gorm" +) + +type Nostr struct { + database *gorm.DB + bot *telegram.TipBot +} + +func New(bot *telegram.TipBot) Nostr { + return Nostr{ + database: bot.DB.Users, + bot: bot, + } +} + +func (n Nostr) Handle(writer http.ResponseWriter, request *http.Request) { + username := request.FormValue("name") + if username == "" { + api.NotFoundHandler(writer, fmt.Errorf("[NostrNip05] Form value 'name' is not set")) + return + } + user, tx := db.FindUser(n.database, username) + if tx.Error == nil && user.Telegram != nil { + user, err := db.FindUserSettings(user, n.bot.DB.Users.Preload("Settings")) + if err != nil { + log.Errorf("[NostrNip05] user settings not found") + api.NotFoundHandler(writer, fmt.Errorf("user settings error")) + return + } + if user.Settings.Nostr.PubKey != "" { + data := []byte(fmt.Sprintf(`{ + "names":{ + "%s":"%s" + } +} +`, username, user.Settings.Nostr.PubKey)) + _, err = writer.Write(data) + if err != nil { + log.Errorf("[NostrNip05] Failed responding to user %s", username) + } + } + } else { + log.Errorf("[NostrNip05] user not found") + api.NotFoundHandler(writer, fmt.Errorf("user not found")) + } +} diff --git a/internal/price/price.go b/internal/price/price.go new file mode 100644 index 00000000..04fe5986 --- /dev/null +++ b/internal/price/price.go @@ -0,0 +1,132 @@ +package price + +import ( + "fmt" + "io/ioutil" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + log "github.com/sirupsen/logrus" + "github.com/tidwall/gjson" +) + +type PriceWatcher struct { + client *http.Client + UpdateInterval time.Duration + Currencies map[string]string + Exchanges map[string]func(string) (float64, error) +} + +var ( + Price map[string]float64 + P *PriceWatcher +) + +func NewPriceWatcher() *PriceWatcher { + pricewatcher := &PriceWatcher{ + client: &http.Client{ + Timeout: time.Second * time.Duration(5), + }, + // attention: $ must come after other $-denominated currencies like R$ + Currencies: map[string]string{ + "EUR": "€", + "GBP": "£", + "JPY": "¥", + "BRL": "R$", + "MXN": "MX$", + "USD": "$", + "RUB": "₽", + "TRY": "₺", + "INR": "₹", + }, + Exchanges: make(map[string]func(string) (float64, error), 0), + UpdateInterval: time.Second * time.Duration(30), + } + pricewatcher.Exchanges["coinbase"] = pricewatcher.GetCoinbasePrice + pricewatcher.Exchanges["bitfinex"] = pricewatcher.GetBitfinexPrice + Price = make(map[string]float64, 0) + log.Infof("[PriceWatcher] Watcher started") + P = pricewatcher + return pricewatcher +} + +func (p *PriceWatcher) Start() { + go p.Watch() +} + +func (p *PriceWatcher) Watch() error { + for { + for currency, _ := range p.Currencies { + avg_price := 0.0 + n_responses := 0 + for _, getPrice := range p.Exchanges { + fprice, err := getPrice(currency) + if err != nil { + // log.Debug(err) + // if one exchanges is down, use the next + continue + } + n_responses++ + avg_price += fprice + // log.Debugf("[PriceWatcher] %s %s price: %f", exchange, currency, fprice) + time.Sleep(time.Second * time.Duration(2)) + } + Price[currency] = avg_price / float64(n_responses) + // log.Debugf("[PriceWatcher] Average %s price: %f", currency, Price[currency]) + } + time.Sleep(p.UpdateInterval) + } +} + +func (p *PriceWatcher) GetCoinbasePrice(currency string) (float64, error) { + coinbaseEndpoint, err := url.Parse(fmt.Sprintf("https://api.coinbase.com/v2/prices/spot?currency=%s", currency)) + response, err := p.client.Get(coinbaseEndpoint.String()) + if err != nil { + return 0, err + } + bodyBytes, err := ioutil.ReadAll(response.Body) + if err != nil { + log.Debug(err) + return 0, err + } + price := gjson.Get(string(bodyBytes), "data.amount") + if len(price.String()) > 0 { + fprice, err := strconv.ParseFloat(strings.TrimSpace(price.String()), 64) + if err != nil { + log.Debug(err) + return 0, err + } + return fprice, nil + } else { + return 0, fmt.Errorf("no price") + } +} + +func (p *PriceWatcher) GetBitfinexPrice(currency string) (float64, error) { + var bitfinexCurrencyToPair = map[string]string{"USD": "btcusd", "EUR": "btceur", "GBP": "btcusd", "JPY": "btcjpy"} + pair := bitfinexCurrencyToPair[currency] + bitfinexEndpoint, err := url.Parse(fmt.Sprintf("https://api.bitfinex.com/v1/pubticker/%s", pair)) + response, err := p.client.Get(bitfinexEndpoint.String()) + if err != nil { + return 0, err + } + bodyBytes, err := ioutil.ReadAll(response.Body) + if err != nil { + log.Debug(err) + return 0, err + } + price := gjson.Get(string(bodyBytes), "last_price") + if len(price.String()) > 0 { + fprice, err := strconv.ParseFloat(strings.TrimSpace(price.String()), 64) + if err != nil { + log.Debug(err) + return 0, err + } + return fprice, nil + } else { + return 0, fmt.Errorf("no price") + } +} diff --git a/internal/rate/limiter.go b/internal/rate/limiter.go new file mode 100644 index 00000000..f62a91df --- /dev/null +++ b/internal/rate/limiter.go @@ -0,0 +1,95 @@ +package rate + +import ( + "context" + "strconv" + "sync" + + log "github.com/sirupsen/logrus" + + "golang.org/x/time/rate" + tb "gopkg.in/lightningtipbot/telebot.v3" +) + +// Limiter +type Limiter struct { + keys map[string]*rate.Limiter + mu *sync.RWMutex + r rate.Limit + b int +} + +var idLimiter *Limiter +var globalLimiter *rate.Limiter + +// NewLimiter creates both chat and global rate limiters. +func Start() { + idLimiter = newIdRateLimiter(rate.Limit(0.29), 19) + globalLimiter = rate.NewLimiter(rate.Limit(30), 30) +} + +// NewRateLimiter . +func newIdRateLimiter(r rate.Limit, b int) *Limiter { + i := &Limiter{ + keys: make(map[string]*rate.Limiter), + mu: &sync.RWMutex{}, + r: r, + b: b, + } + + return i +} + +func CheckLimit(to interface{}) { + globalLimiter.Wait(context.Background()) + var id string + switch to.(type) { + case string: + id = to.(string) + case *tb.Chat: + id = strconv.FormatInt(to.(*tb.Chat).ID, 10) + case *tb.User: + id = strconv.FormatInt(to.(*tb.User).ID, 10) + case tb.Recipient: + id = to.(tb.Recipient).Recipient() + case *tb.Message: + if to.(*tb.Message).Chat != nil { + id = strconv.FormatInt(to.(*tb.Message).Chat.ID, 10) + } + } + if len(id) > 0 { + log.Tracef("[Check Limit] limiter for %+v", id) + idLimiter.GetLimiter(id).Wait(context.Background()) + return + } + log.Tracef("[Check Limit] skipping id limiter for %+v", to) +} + +// Add creates a new rate limiter and adds it to the keys map, +// using the key +func (i *Limiter) Add(key string) *rate.Limiter { + i.mu.Lock() + defer i.mu.Unlock() + + limiter := rate.NewLimiter(i.r, i.b) + + i.keys[key] = limiter + + return limiter +} + +// GetLimiter returns the rate limiter for the provided key if it exists. +// Otherwise, calls Add to add key address to the map +func (i *Limiter) GetLimiter(key string) *rate.Limiter { + i.mu.Lock() + limiter, exists := i.keys[key] + + if !exists { + i.mu.Unlock() + return i.Add(key) + } + + i.mu.Unlock() + + return limiter +} diff --git a/internal/runtime/function.go b/internal/runtime/function.go new file mode 100644 index 00000000..8140e141 --- /dev/null +++ b/internal/runtime/function.go @@ -0,0 +1,131 @@ +package runtime + +import ( + cmap "github.com/orcaman/concurrent-map" + "time" +) + +var functionMap cmap.ConcurrentMap + +func init() { + functionMap = cmap.New() +} + +var DefaultTickerDuration = time.Second * 10 + +// ResettableFunction will reset the user state as soon as tick is delivered. +type ResettableFunction struct { + Ticker *time.Ticker + Timer *time.Timer + ResetChan chan struct{} // channel used to reset the ticker + StopChan chan struct{} // channel used to reset the ticker + duration time.Duration + Started bool + name string +} + +type ResettableFunctionTickerOption func(*ResettableFunction) + +func WithTicker(t *time.Ticker) ResettableFunctionTickerOption { + return func(a *ResettableFunction) { + a.Ticker = t + } +} +func WithDuration(d time.Duration) ResettableFunctionTickerOption { + return func(a *ResettableFunction) { + a.duration = d + } +} +func WithTimer(t *time.Timer) ResettableFunctionTickerOption { + return func(a *ResettableFunction) { + a.Timer = t + } +} +func RemoveTicker(name string) { + functionMap.Remove(name) +} + +func Get(name string) (*ResettableFunction, bool) { + if t, ok := functionMap.Get(name); ok { + return t.(*ResettableFunction), ok + } + return nil, false +} +func GetFunction(name string, option ...ResettableFunctionTickerOption) *ResettableFunction { + if t, ok := functionMap.Get(name); ok { + return t.(*ResettableFunction) + } else { + t := NewResettableFunction(name, option...) + functionMap.Set(name, t) + return t + } +} + +func NewResettableFunction(name string, option ...ResettableFunctionTickerOption) *ResettableFunction { + t := &ResettableFunction{ + ResetChan: make(chan struct{}, 1), + StopChan: make(chan struct{}, 1), + name: name, + } + if t.duration == 0 { + t.duration = DefaultTickerDuration + } + + for _, opt := range option { + opt(t) + } + + return t +} + +// Do will listen for timers and invoke functionCallback on tick. +func (t *ResettableFunction) Do(functionCallback func()) { + t.Started = true + // persist function + functionMap.Set(t.name, t) + go func() { + for { + if t.Timer != nil { + // timer is set. checking event + select { + case <-t.Timer.C: + functionCallback() + return + default: + } + } + if t.Ticker != nil { + // ticker is set. checking event + select { + case <-t.Ticker.C: + // ticker delivered signal. do function functionCallback + functionCallback() + return + default: + } + } + // check stop and reset channels + select { + case <-t.StopChan: + if t.Timer != nil { + t.Timer.Stop() + } + if t.Ticker != nil { + t.Ticker.Stop() + } + return + case <-t.ResetChan: + // reset signal received. creating new ticker. + if t.Ticker != nil { + t.Ticker.Reset(t.duration) + } + if t.Timer != nil { + t.Timer.Reset(t.duration) + } + default: + break + } + time.Sleep(time.Millisecond * 500) + } + }() +} diff --git a/internal/runtime/mutex/mutex.go b/internal/runtime/mutex/mutex.go new file mode 100644 index 00000000..fd2a12db --- /dev/null +++ b/internal/runtime/mutex/mutex.go @@ -0,0 +1,141 @@ +package mutex + +import ( + "context" + "fmt" + "net/http" + "sync" + + "github.com/gorilla/mux" + + cmap "github.com/orcaman/concurrent-map" + log "github.com/sirupsen/logrus" +) + +var mutexMap cmap.ConcurrentMap +var mutexMapSync sync.Mutex + +func init() { + mutexMap = cmap.New() + mutexMapSync = sync.Mutex{} +} +func IsEmpty() bool { + return mutexMap.Count() == 0 +} + +func ServeHTTP(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(fmt.Sprintf("Current number of locks: %d\nLocks: %+v\nUse /mutex/unlock/{id} endpoint to mutex", len(mutexMap.Keys()), mutexMap.Keys()))) +} + +func UnlockHTTP(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + if m, ok := mutexMap.Get(vars["id"]); ok { + m.(*sync.Mutex).Unlock() + w.Write([]byte(fmt.Sprintf("Unlocked mutex %s.\nCurrent number of locks: %d\nLocks: %+v", + vars["id"], len(mutexMap.Keys()), mutexMap.Keys()))) + return + } + w.Write([]byte(fmt.Sprintf("Mutex %s not found!", vars["id"]))) +} + +// checkSoftLock checks in mutexMap how often an existing mutex was already SoftLocked. +// The counter is there to avoid multiple recursive locking of an object in the mutexMap. +// This happens if multiple handlers call each other and try to lock/unlock multiple times +// the same mutex. +func checkSoftLock(s string) int { + if v, ok := mutexMap.Get(fmt.Sprintf("nLocks:%s", s)); ok { + return v.(int) + } + return 0 +} + +// LockWithContext locks a mutex only if it hasn't been locked before in a context. +// LockWithContext should be used to lock objects like faucets etc. +// The context carries a uid that is unique the each request (message, button press, etc.). +// If the uid has a lock already *for a certain object*, it increments the +// nLocks in the mutexMap. If not, it locks the object. This is supposed to lock only if nLock == 0. +func LockWithContext(ctx context.Context, s string) { + uid := ctx.Value("uid").(string) + if len(uid) == 0 { + log.Error("[Mutex] LockWithContext: uid is empty!") + } + if len(s) == 0 { + log.Error("[Mutex] LockWithContext: s is empty!") + } + // sync mutex to sync checkSoftLock with the increment of nLocks + // same user can't lock the same object multiple times + Lock(fmt.Sprintf("mutex-sync:%s:%s", s, uid)) + var nLocks = checkSoftLock(uid) + if nLocks == 0 { + Lock(s) + } else { + log.Tracef("[Mutex] Skip lock (nLocks: %d)", nLocks) + } + nLocks++ + mutexMap.Set(fmt.Sprintf("nLocks:%s", uid), nLocks) + Unlock(fmt.Sprintf("mutex-sync:%s:%s", s, uid)) +} + +// UnlockWithContext unlock a mutex only if it has been locked once within a context. +// If it has been locked more than once +// it only decrements nLocks and skips the unlock of the mutex. This is supposed to unlock only for +// nLocks == 1 +func UnlockWithContext(ctx context.Context, s string) { + uid := ctx.Value("uid").(string) + if len(uid) == 0 { + log.Error("[Mutex] UnlockWithContext: uid is empty!") + return + } + if len(s) == 0 { + log.Error("[Mutex] UnlockWithContext: s is empty!") + return + } + Lock(fmt.Sprintf("mutex-sync:%s:%s", s, uid)) + var nLocks = checkSoftLock(uid) + nLocks-- + mutexMap.Set(fmt.Sprintf("nLocks:%s", uid), nLocks) + if nLocks == 0 { + Unlock(s) + mutexMap.Remove(fmt.Sprintf("nLocks:%s", uid)) + } else { + log.Tracef("[Mutex] Skip unlock (nLocks: %d)", nLocks) + } + Unlock(fmt.Sprintf("mutex-sync:%s:%s", s, uid)) + //mutexMap.Remove(fmt.Sprintf("mutex-sync:%s:%s", s, uid)) +} + +// Lock locks a mutex in the mutexMap. If the mutex is already in the map, it locks the current call. +// After it another call unlocks the mutex (and deletes it from the mutexMap) the mutex written again into the mutexMap. +// If the mutex was not in the mutexMap before, a new mutext is created and locked and written into the mutexMap. +func Lock(s string) { + log.Tracef("[Mutex] Attempt Lock %s", s) + if m, ok := mutexMap.Get(s); ok { + m.(*sync.Mutex).Lock() + // write into mutex map + mutexMapSync.Lock() + mutexMap.Set(s, m) + mutexMapSync.Unlock() + } else { + m := &sync.Mutex{} + m.Lock() + // write into mutex map + mutexMapSync.Lock() + mutexMap.Set(s, m) + mutexMapSync.Unlock() + } + log.Tracef("[Mutex] Locked %s", s) +} + +// Unlock unlocks a mutex in the mutexMap. +func Unlock(s string) { + mutexMapSync.Lock() + if m, ok := mutexMap.Get(s); ok { + mutexMap.Remove(s) + m.(*sync.Mutex).Unlock() + log.Tracef("[Mutex] Unlocked %s", s) + } else { + // this should never happen. Mutex should have been in the mutexMap. + log.Errorf("[Mutex] ⚠️⚠️⚠️ Unlock %s not in mutexMap. Skip.", s) + } + mutexMapSync.Unlock() +} diff --git a/internal/runtime/once/once.go b/internal/runtime/once/once.go new file mode 100644 index 00000000..23533745 --- /dev/null +++ b/internal/runtime/once/once.go @@ -0,0 +1,49 @@ +package once + +import ( + "fmt" + + cmap "github.com/orcaman/concurrent-map" + log "github.com/sirupsen/logrus" +) + +var onceMap cmap.ConcurrentMap + +func init() { + onceMap = cmap.New() +} + +func New(objectKey string) { + onceMap.Set(objectKey, cmap.New()) +} + +// Once creates a map of keys k1 with a map of keys k2. +// The idea is that an object with ID k1 can create a list of users k2 +// that have already interacted with the object. If the user k2 is in the list, +// the object is not allowed to accessed again. +func Once(k1, k2 string) error { + i, ok := onceMap.Get(k1) + if ok { + return setOrReturn(i.(cmap.ConcurrentMap), k2) + } + userMap := cmap.New() + onceMap.Set(k1, userMap) + log.Tracef("[Once] Added key %s to onceMap (len=%d)", k1, len(onceMap.Keys())) + return setOrReturn(userMap, k2) +} + +// setOrReturn sets the key k2 in the map i if it is not already set. +func setOrReturn(objectMap cmap.ConcurrentMap, k2 string) error { + if _, ok := objectMap.Get(k2); ok { + return fmt.Errorf("[Once] %s already consumed object", k2) + } + objectMap.Set(k2, true) + return nil +} + +// Remove removes the key k1 from the map. Should be called after Once was called and +// the object k1 finished. +func Remove(k1 string) { + onceMap.Remove(k1) + log.Tracef("[Once] Removed key %s from onceMap (len=%d)", k1, len(onceMap.Keys())) +} diff --git a/internal/runtime/retry.go b/internal/runtime/retry.go new file mode 100644 index 00000000..3eca38db --- /dev/null +++ b/internal/runtime/retry.go @@ -0,0 +1,64 @@ +package runtime + +import ( + "context" + "time" +) + +// todo -- use function.go instead! +// var retryMap cmap.ConcurrentMap + +// func init() { +// retryMap = cmap.New() +// } + +// ResettableFunction will reset the user state as soon as tick is delivered. +type FunctionRetry struct { + Ticker *time.Ticker + duration time.Duration + ctx context.Context + name string +} + +type FunctionRetryOption func(*FunctionRetry) + +func WithRetryDuration(d time.Duration) FunctionRetryOption { + return func(a *FunctionRetry) { + a.duration = d + } +} +func NewRetryTicker(ctx context.Context, name string, option ...FunctionRetryOption) *FunctionRetry { + t := &FunctionRetry{ + name: name, + ctx: ctx, + } + for _, opt := range option { + opt(t) + } + if t.duration == 0 { + t.duration = DefaultTickerDuration + } + t.Ticker = time.NewTicker(t.duration) + return t +} + +func (t *FunctionRetry) Do(f func(), cancel_f func(), deadline_f func()) { + functionMap.Set(t.name, t) + go func() { + for { + select { + case <-t.Ticker.C: + // ticker delivered signal. do function f + f() + case <-t.ctx.Done(): + if t.ctx.Err() == context.DeadlineExceeded { + deadline_f() + } + if t.ctx.Err() == context.Canceled { + cancel_f() + } + return + } + } + }() +} diff --git a/internal/satdress/satdress.go b/internal/satdress/satdress.go new file mode 100644 index 00000000..daf7e3d8 --- /dev/null +++ b/internal/satdress/satdress.go @@ -0,0 +1,330 @@ +package satdress + +import ( + "bytes" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "github.com/LightningTipBot/LightningTipBot/internal/network" + log "github.com/sirupsen/logrus" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" + "io/ioutil" + "net/http" + "net/url" + "strings" + "time" +) + +// Much of this is from github.com/fiatjaf/makeinvoice +// but with added "checkInvoice" and http proxy support + +type LNDParams struct { + Cert []byte `json:"cert" gorm:"-"` + CertString string `json:"certstring"` + Host string `json:"host"` + Macaroon string `json:"macaroon"` +} + +func (l LNDParams) getCert() []byte { return l.Cert } +func (l LNDParams) isLocal() bool { return strings.HasPrefix(l.Host, "https://127.0.0.1") } +func (l LNDParams) isTor() bool { return strings.Index(l.Host, ".onion") != -1 } + +type LNBitsParams struct { + Cert string `json:"certstring"` + Host string `json:"host"` + Key string `json:"key"` +} + +func (l LNBitsParams) getCert() []byte { return []byte(l.Cert) } +func (l LNBitsParams) isTor() bool { return strings.Index(l.Host, ".onion") != -1 } +func (l LNBitsParams) isLocal() bool { return strings.HasPrefix(l.Host, "https://127.0.0.1") } + +type BackendParams interface { + getCert() []byte + isTor() bool + isLocal() bool +} + +type Params struct { + Backend BackendParams + Msatoshi int64 + Description string + DescriptionHash []byte + + Label string // only used for c-lightning +} + +type CheckInvoiceParams struct { + Backend BackendParams + PR string + Hash []byte + Status string +} + +func SetupHttpClient(useProxy bool, cert []byte) (*http.Client, error) { + var client *http.Client + if !useProxy { + client = &http.Client{ + Timeout: 10 * time.Second, + } + } else { + var err error + client, err = network.GetClient(network.ClientTypeTor) + if err != nil { + return nil, err + } + + } + // use a cert or skip TLS verification? + if len(cert) > 0 { + caCertPool := x509.NewCertPool() + ok := caCertPool.AppendCertsFromPEM(cert) + if !ok { + return client, fmt.Errorf("invalid root certificate") + } + client.Transport.(*http.Transport).TLSClientConfig = &tls.Config{RootCAs: caCertPool} + } else { + client.Transport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: true} + } + return client, nil +} + +func MakeInvoice(params Params) (CheckInvoiceParams, error) { + // defer func(prevTransport http.RoundTripper) { + // Client.Transport = prevTransport + // }(Client.Transport) + + if params.Backend == nil { + return CheckInvoiceParams{}, errors.New("no backend specified") + } + + var err error + Client, err := SetupHttpClient(!params.Backend.isLocal(), params.Backend.getCert()) + if err != nil { + log.Errorf(err.Error()) + return CheckInvoiceParams{}, err + } + + // description hash? + var hexh, b64h string + if params.DescriptionHash != nil { + hexh = hex.EncodeToString(params.DescriptionHash) + b64h = base64.StdEncoding.EncodeToString(params.DescriptionHash) + } + + switch backend := params.Backend.(type) { + case LNDParams: + log.Debugf("[MakeInvoice] LND invoice at %s", backend.Host) + body, _ := sjson.Set("{}", "value_msat", params.Msatoshi) + + if params.DescriptionHash == nil { + body, _ = sjson.Set(body, "memo", params.Description) + } else { + body, _ = sjson.Set(body, "description_hash", b64h) + } + + req, err := http.NewRequest("POST", + backend.Host+"/v1/invoices", + bytes.NewBufferString(body), + ) + if err != nil { + return CheckInvoiceParams{}, err + } + + // macaroon must be hex, so if it is on base64 we adjust that + if b, err := base64.StdEncoding.DecodeString(backend.Macaroon); err == nil { + backend.Macaroon = hex.EncodeToString(b) + } + + req.Header.Set("Grpc-Metadata-macaroon", backend.Macaroon) + resp, err := Client.Do(req) + if err != nil { + return CheckInvoiceParams{}, err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + body, _ := ioutil.ReadAll(resp.Body) + text := string(body) + if len(text) > 300 { + text = text[:300] + } + return CheckInvoiceParams{}, fmt.Errorf("call to lnd failed (%d): %s", resp.StatusCode, text) + } + + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + return CheckInvoiceParams{}, err + } + + // bot.Cache.Set(shopView.ID, shopView, &store.Options{Expiration: 24 * time.Hour}) + checkInvoiceParams := CheckInvoiceParams{ + Backend: params.Backend, + PR: gjson.ParseBytes(b).Get("payment_request").String(), + Hash: []byte(gjson.ParseBytes(b).Get("r_hash").String()), + Status: "OPEN", + } + if len(checkInvoiceParams.PR) == 0 { + return CheckInvoiceParams{}, errors.New("could not create invoice") + } + return checkInvoiceParams, nil + + case LNBitsParams: + log.Debugf("[MakeInvoice] LNBits invoice at %s", backend.Host) + body, _ := sjson.Set("{}", "amount", params.Msatoshi/1000) + body, _ = sjson.Set(body, "out", false) + + if params.DescriptionHash == nil { + if params.Description == "" { + body, _ = sjson.Set(body, "memo", "invoice") + } else { + body, _ = sjson.Set(body, "memo", params.Description) + } + } else { + body, _ = sjson.Set(body, "description_hash", hexh) + } + + req, err := http.NewRequest("POST", + backend.Host+"/api/v1/payments", + bytes.NewBufferString(body), + ) + if err != nil { + return CheckInvoiceParams{}, err + } + + req.Header.Set("X-Api-Key", backend.Key) + req.Header.Set("Content-Type", "application/json") + resp, err := Client.Do(req) + if err != nil { + return CheckInvoiceParams{}, err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + body, _ := ioutil.ReadAll(resp.Body) + text := string(body) + if len(text) > 300 { + text = text[:300] + } + return CheckInvoiceParams{}, fmt.Errorf("call to lnbits failed (%d): %s", resp.StatusCode, text) + } + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + return CheckInvoiceParams{}, err + } + checkInvoiceParams := CheckInvoiceParams{ + Backend: params.Backend, + PR: gjson.ParseBytes(b).Get("payment_request").String(), + Hash: []byte(gjson.ParseBytes(b).Get("payment_hash").String()), + Status: "OPEN", + } + if len(checkInvoiceParams.PR) == 0 { + return CheckInvoiceParams{}, errors.New("could not create invoice") + } + return checkInvoiceParams, nil + default: + return CheckInvoiceParams{}, errors.New("wrong backend type") + } +} + +func CheckInvoice(params CheckInvoiceParams) (CheckInvoiceParams, error) { + // defer func(prevTransport http.RoundTripper) { + // Client.Transport = prevTransport + // }(Client.Transport) + + if params.Backend == nil { + return CheckInvoiceParams{}, errors.New("no backend specified") + } + + var err error + Client, err := SetupHttpClient(!params.Backend.isLocal(), params.Backend.getCert()) + if err != nil { + log.Errorf(err.Error()) + return CheckInvoiceParams{}, err + } + + switch backend := params.Backend.(type) { + case LNDParams: + log.Debugf("[CheckInvoice] LND invoice %s at %s", base64.StdEncoding.EncodeToString(params.Hash), backend.Host) + p, err := base64.StdEncoding.DecodeString(string(params.Hash)) + if err != nil { + return CheckInvoiceParams{}, fmt.Errorf("invalid hash") + } + hexHash := hex.EncodeToString(p) + requestUrl, err := url.Parse(fmt.Sprintf("%s/v1/invoice/%s?r_hash=%s", backend.Host, hexHash, base64.StdEncoding.EncodeToString(params.Hash))) + if err != nil { + return CheckInvoiceParams{}, err + } + requestUrl.Scheme = "https" + req, err := http.NewRequest("GET", + requestUrl.String(), nil) + if err != nil { + return CheckInvoiceParams{}, err + } + // macaroon must be hex, so if it is on base64 we adjust that + if b, err := base64.StdEncoding.DecodeString(backend.Macaroon); err == nil { + backend.Macaroon = hex.EncodeToString(b) + } + + req.Header.Set("Grpc-Metadata-macaroon", backend.Macaroon) + resp, err := Client.Do(req) + if err != nil { + return CheckInvoiceParams{}, err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + body, _ := ioutil.ReadAll(resp.Body) + text := string(body) + if len(text) > 300 { + text = text[:300] + } + return CheckInvoiceParams{}, fmt.Errorf("call to lnd failed (%d): %s", resp.StatusCode, text) + } + + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + return CheckInvoiceParams{}, err + } + params.Status = gjson.ParseBytes(b).Get("state").String() + return params, nil + + case LNBitsParams: + log.Debugf("[CheckInvoice] LNBits invoice %s at %s", base64.StdEncoding.EncodeToString(params.Hash), backend.Host) + log.Debug("Getting ", backend.Host+"/api/v1/payments/"+string(params.Hash)) + req, err := http.NewRequest("GET", backend.Host+"/api/v1/payments/"+string(params.Hash), nil) + if err != nil { + return CheckInvoiceParams{}, err + } + + req.Header.Set("X-Api-Key", backend.Key) + req.Header.Set("Content-Type", "application/json") + resp, err := Client.Do(req) + if err != nil { + return CheckInvoiceParams{}, err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + body, _ := ioutil.ReadAll(resp.Body) + text := string(body) + if len(text) > 300 { + text = text[:300] + } + return CheckInvoiceParams{}, fmt.Errorf("call to lnbits failed (%d): %s", resp.StatusCode, text) + } + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + return CheckInvoiceParams{}, err + } + status := strings.ToLower(gjson.ParseBytes(b).Get("paid").String()) + if status == "true" { + params.Status = "SETTLED" + } else { + params.Status = "OPEN" + } + return params, nil + default: + return CheckInvoiceParams{}, errors.New("missing backend params") + } +} diff --git a/internal/storage/base.go b/internal/storage/base.go new file mode 100644 index 00000000..dd3bcda3 --- /dev/null +++ b/internal/storage/base.go @@ -0,0 +1,93 @@ +package storage + +import ( + "github.com/LightningTipBot/LightningTipBot/internal/runtime" + "time" + + "github.com/eko/gocache/store" + gocache "github.com/patrickmn/go-cache" + + log "github.com/sirupsen/logrus" +) + +var transactionCache = store.NewGoCache(gocache.New(5*time.Minute, 10*time.Minute), nil) + +type Base struct { + ID string `json:"id"` + Active bool `json:"active"` + Canceled bool `json:"canceled"` + CreatedAt time.Time `json:"created"` + UpdatedAt time.Time `json:"updated"` +} + +type Option func(b *Base) + +func ID(id string) Option { + return func(btx *Base) { + btx.ID = id + } +} + +func New(opts ...Option) *Base { + btx := &Base{ + Active: true, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + for _, opt := range opts { + opt(btx) + } + return btx +} + +func (tx Base) Key() string { + return tx.ID +} + +func (tx *Base) Inactivate(s Storable, db *DB) error { + tx.Active = false + err := tx.Set(s, db) + if err != nil { + log.Tracef("[Bunt Inactivate] %s Error: %s", tx.ID, err.Error()) + return err + } + log.Tracef("[Bunt Inactivate] %s", tx.ID) + return nil +} + +func (tx *Base) Get(s Storable, db *DB) (Storable, error) { + cacheTx, err := transactionCache.Get(s.Key()) + if err != nil { + err := db.Get(s) + if err != nil { + return s, err + } + log.Tracef("[Bunt] get object %s", s.Key()) + return s, transactionCache.Set(s.Key(), s, &store.Options{Expiration: 5 * time.Minute}) + } + log.Tracef("[Bunt Cache] get object %s", s.Key()) + return cacheTx.(Storable), err + +} + +func (tx *Base) Set(s Storable, db *DB) error { + tx.UpdatedAt = time.Now() + err := db.Set(s) + if err != nil { + log.Errorf("[Bunt] could not set object: %v", err.Error()) + return err + } + log.Tracef("[Bunt] set object %s", s.Key()) + err = transactionCache.Set(s.Key(), s, &store.Options{Expiration: 5 * time.Minute}) + if err != nil { + log.Errorf("[Bunt Cache] could not set object: %v", err.Error()) + } + log.Tracef("[Bunt Cache] set object: %s", s.Key()) + return err +} + +func (tx *Base) Delete(s Storable, db *DB) error { + tx.UpdatedAt = time.Now() + runtime.IgnoreError(transactionCache.Delete(s.Key())) + return db.Delete(s.Key(), s) +} diff --git a/internal/storage/bunt.go b/internal/storage/bunt.go index c2f9f580..479a94d1 100644 --- a/internal/storage/bunt.go +++ b/internal/storage/bunt.go @@ -2,7 +2,8 @@ package storage import ( "encoding/json" - "github.com/LightningTipBot/LightningTipBot/internal/runtime" + "fmt" + log "github.com/sirupsen/logrus" "github.com/tidwall/buntdb" ) @@ -16,24 +17,11 @@ 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} } @@ -69,6 +57,7 @@ func (db *DB) Get(object Storable) error { } err = json.Unmarshal([]byte(val), object) if err != nil { + fmt.Println(err) return err } return nil @@ -91,24 +80,31 @@ func (db *DB) Set(object Storable) error { } // 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 - } + _, err := tx.Get(object.Key()) + if err != nil { + return err } + if _, err := tx.Delete(object.Key()); err != nil { + return err + } + // OLD: from gohumble: + // todo -- not ascend users index + // 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 }) - } diff --git a/internal/str/strings.go b/internal/str/strings.go new file mode 100644 index 00000000..0cdee213 --- /dev/null +++ b/internal/str/strings.go @@ -0,0 +1,57 @@ +package str + +import ( + "crypto/sha256" + "fmt" + "hash/fnv" + "strings" + + "github.com/LightningTipBot/LightningTipBot/internal/lnbits" +) + +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 Int32Hash(s string) uint32 { + h := fnv.New32a() + h.Write([]byte(s)) + return h.Sum32() +} + +func Int64Hash(s string) uint64 { + h := fnv.New64a() + h.Write([]byte(s)) + return h.Sum64() +} + +func AnonIdSha256(u *lnbits.User) string { + h := sha256.Sum256([]byte(u.Wallet.ID)) + hash := fmt.Sprintf("%x", h) + anon_id := fmt.Sprintf("0x%s", hash[:16]) // starts with 0x because that can't be a valid telegram username + return anon_id +} + +func UUIDSha256(u *lnbits.User) string { + h := sha256.Sum256([]byte(u.Wallet.ID)) + hash := fmt.Sprintf("%x", h) + anon_id := fmt.Sprintf("1x%s", hash[len(hash)-16:]) // starts with 1x because that can't be a valid telegram username + return anon_id +} diff --git a/internal/telegram/amounts.go b/internal/telegram/amounts.go new file mode 100644 index 00000000..8af386e0 --- /dev/null +++ b/internal/telegram/amounts.go @@ -0,0 +1,251 @@ +package telegram + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/LightningTipBot/LightningTipBot/internal/telegram/intercept" + + "github.com/LightningTipBot/LightningTipBot/internal/errors" + + "github.com/LightningTipBot/LightningTipBot/internal/runtime/mutex" + "github.com/LightningTipBot/LightningTipBot/internal/storage" + + "github.com/LightningTipBot/LightningTipBot/internal/lnbits" + "github.com/LightningTipBot/LightningTipBot/internal/price" + "github.com/LightningTipBot/LightningTipBot/internal/runtime" + "github.com/LightningTipBot/LightningTipBot/internal/utils" + log "github.com/sirupsen/logrus" + tb "gopkg.in/lightningtipbot/telebot.v3" +) + +// amountsMap maps string to int64 amounts +var amountsMap = map[string]int64{ + "🍌": 777, + "🥜": 69, +} + +func getArgumentFromCommand(input string, which int) (output string, err error) { + if len(strings.Split(input, " ")) < which+1 { + return "", fmt.Errorf("message doesn't contain enough arguments") + } + output = strings.Split(input, " ")[which] + return output, nil +} + +func decodeAmountFromCommand(input string) (amount int64, err error) { + if len(strings.Split(input, " ")) < 2 { + errmsg := "message doesn't contain any amount" + // log.Errorln(errmsg) + return 0, fmt.Errorf(errmsg) + } + amount, err = GetAmount(strings.Split(input, " ")[1]) + return amount, err +} + +// GetAmount parses an amount from a string like 1.2k or 3.50€ +// and returns the value in satoshis +func GetAmount(input string) (amount int64, err error) { + // replace occurances of comma with dot + input = strings.Replace(input, ",", ".", -1) + + // replace strings in amountsMap with their integer values + for k, v := range amountsMap { + input = strings.Replace(input, k, strconv.FormatInt(v, 10), -1) + } + + // convert something like 1.2k into 1200 + if strings.HasSuffix(strings.ToLower(input), "k") { + fmount, err := strconv.ParseFloat(strings.TrimSpace(input[:len(input)-1]), 64) + if err != nil { + return 0, err + } + amount = int64(fmount * 1000) + return amount, err + } + + // convert fiat currencies to satoshis + for currency, symbol := range price.P.Currencies { + if strings.HasPrefix(input, symbol) || strings.HasSuffix(input, symbol) || // for 1$ and $1 + strings.HasPrefix(strings.ToLower(input), strings.ToLower(currency)) || // for USD1 + strings.HasSuffix(strings.ToLower(input), strings.ToLower(currency)) { // for 1USD + numeric_string := "" + numeric_string = strings.Replace(input, symbol, "", 1) // for symbol like $ + numeric_string = strings.Replace(strings.ToLower(numeric_string), strings.ToLower(currency), "", 1) // for 1USD + fmount, err := strconv.ParseFloat(numeric_string, 64) + if err != nil { + log.Errorln(err) + return 0, err + } + if !(price.Price[currency] > 0) { + return 0, fmt.Errorf("price is zero") + } + amount = int64(fmount / price.Price[currency] * float64(100_000_000)) + return amount, nil + } + } + + // use plain integer as satoshis + amount, err = strconv.ParseInt(input, 10, 64) + if err != nil { + return 0, err + } + if amount <= 0 { + return 0, fmt.Errorf("amount must be greater than 0") + } + return amount, err +} + +func SatoshisToFiat(amount int64, currency string) (fiat float64, err error) { + if !(price.Price[currency] > 0) { + return 0, fmt.Errorf("price is zero") + } + fiat = float64(amount) / 100_000_000 * price.Price[currency] + return fiat, nil +} + +type EnterAmountStateData struct { + ID string `json:"ID"` // holds the ID of the tx object in bunt db + Type string `json:"Type"` // holds type of the tx in bunt db (needed for type checking) + Amount int64 `json:"Amount"` // holds the amount entered by the user mSat + AmountMin int64 `json:"AmountMin"` // holds the minimum amount that needs to be entered mSat + AmountMax int64 `json:"AmountMax"` // holds the maximum amount that needs to be entered mSat + OiringalCommand string `json:"OiringalCommand"` // hold the originally entered command for evtl later use +} + +func (bot *TipBot) askForAmount(ctx context.Context, id string, eventType string, amountMin int64, amountMax int64, originalCommand string) (enterAmountStateData *EnterAmountStateData, err error) { + user := LoadUser(ctx) + if user.Wallet == nil { + return // errors.New("user has no wallet"), 0 + } + enterAmountStateData = &EnterAmountStateData{ + ID: id, + Type: eventType, + AmountMin: amountMin, + AmountMax: amountMax, + OiringalCommand: originalCommand, + } + // set LNURLPayParams in the state of the user + stateDataJson, err := json.Marshal(enterAmountStateData) + if err != nil { + log.Errorln(err) + return + } + SetUserState(user, bot, lnbits.UserEnterAmount, string(stateDataJson)) + askAmountText := Translate(ctx, "enterAmountMessage") + if amountMin > 0 && amountMax >= amountMin { + askAmountText = fmt.Sprintf(Translate(ctx, "enterAmountRangeMessage"), utils.FormatSats(enterAmountStateData.AmountMin/1000), utils.FormatSats(enterAmountStateData.AmountMax/1000)) + } + // Let the user enter an amount and return + bot.trySendMessage(user.Telegram, askAmountText, tb.ForceReply) + return +} + +// enterAmountHandler is invoked in anyTextHandler when the user needs to enter an amount +// the amount is then stored as an entry in the user's stateKey in the user database +// any other handler that relies on this, needs to load the resulting amount from the database +func (bot *TipBot) enterAmountHandler(ctx intercept.Context) (intercept.Context, error) { + user := LoadUser(ctx) + if user.Wallet == nil { + return ctx, errors.Create(errors.UserNoWalletError) + } + + if !(user.StateKey == lnbits.UserEnterAmount) { + ResetUserState(user, bot) + return ctx, fmt.Errorf("invalid statekey") + } + + var EnterAmountStateData EnterAmountStateData + err := json.Unmarshal([]byte(user.StateData), &EnterAmountStateData) + if err != nil { + log.Errorf("[enterAmountHandler] %s", err.Error()) + ResetUserState(user, bot) + return ctx, err + } + + amount, err := GetAmount(ctx.Message().Text) + if err != nil { + log.Warnf("[enterAmountHandler] %s", err.Error()) + bot.trySendMessage(ctx.Message().Sender, Translate(ctx, "lnurlInvalidAmountMessage")) + ResetUserState(user, bot) + return ctx, err + } + // amount not in allowed range from LNURL + if EnterAmountStateData.AmountMin > 0 && EnterAmountStateData.AmountMax >= EnterAmountStateData.AmountMin && // this line checks whether min_max is set at all + (amount > int64(EnterAmountStateData.AmountMax/1000) || amount < int64(EnterAmountStateData.AmountMin/1000)) { // this line then checks whether the amount is in the range + err = fmt.Errorf("amount not in range") + log.Warnf("[enterAmountHandler] %s", err.Error()) + bot.trySendMessage(ctx.Sender(), fmt.Sprintf(Translate(ctx, "lnurlInvalidAmountRangeMessage"), utils.FormatSats(EnterAmountStateData.AmountMin/1000), utils.FormatSats(EnterAmountStateData.AmountMax/1000))) + ResetUserState(user, bot) + return ctx, errors.Create(errors.InvalidSyntaxError) + } + + // find out which type the object in bunt has waiting for an amount + // we stored this in the EnterAmountStateData before + switch EnterAmountStateData.Type { + case "LnurlPayState": + tx := &LnurlPayState{Base: storage.New(storage.ID(EnterAmountStateData.ID))} + mutex.LockWithContext(ctx, tx.ID) + defer mutex.UnlockWithContext(ctx, tx.ID) + sn, err := tx.Get(tx, bot.Bunt) + if err != nil { + return ctx, err + } + LnurlPayState := sn.(*LnurlPayState) + LnurlPayState.Amount = amount * 1000 // mSat + // add result to persistent struct + runtime.IgnoreError(LnurlPayState.Set(LnurlPayState, bot.Bunt)) + + EnterAmountStateData.Amount = int64(amount) * 1000 // mSat + StateDataJson, err := json.Marshal(EnterAmountStateData) + if err != nil { + log.Errorln(err) + return ctx, err + } + SetUserState(user, bot, lnbits.UserHasEnteredAmount, string(StateDataJson)) + return bot.lnurlPayHandlerSend(ctx) + case "LnurlWithdrawState": + tx := &LnurlWithdrawState{Base: storage.New(storage.ID(EnterAmountStateData.ID))} + mutex.LockWithContext(ctx, tx.ID) + defer mutex.UnlockWithContext(ctx, tx.ID) + sn, err := tx.Get(tx, bot.Bunt) + if err != nil { + return ctx, err + } + LnurlWithdrawState := sn.(*LnurlWithdrawState) + LnurlWithdrawState.Amount = amount * 1000 // mSat + // add result to persistent struct + runtime.IgnoreError(LnurlWithdrawState.Set(LnurlWithdrawState, bot.Bunt)) + + EnterAmountStateData.Amount = int64(amount) * 1000 // mSat + StateDataJson, err := json.Marshal(EnterAmountStateData) + if err != nil { + log.Errorln(err) + return ctx, err + } + SetUserState(user, bot, lnbits.UserHasEnteredAmount, string(StateDataJson)) + return bot.lnurlWithdrawHandlerWithdraw(ctx) + case "CreateInvoiceState": + ctx.Message().Text = fmt.Sprintf("/invoice %d", amount) + SetUserState(user, bot, lnbits.UserHasEnteredAmount, "") + return bot.invoiceHandler(ctx) + case "CreateDonationState": + ctx.Message().Text = fmt.Sprintf("/donate %d", amount) + SetUserState(user, bot, lnbits.UserHasEnteredAmount, "") + return bot.donationHandler(ctx) + case "CreateSendState": + splits := strings.SplitAfterN(EnterAmountStateData.OiringalCommand, " ", 2) + if len(splits) > 1 { + ctx.Message().Text = fmt.Sprintf("/send %d %s", amount, splits[1]) + SetUserState(user, bot, lnbits.UserHasEnteredAmount, "") + return bot.sendHandler(ctx) + } + return ctx, errors.Create(errors.InvalidSyntaxError) + default: + ResetUserState(user, bot) + return ctx, errors.Create(errors.InvalidSyntaxError) + } +} diff --git a/internal/telegram/api_approval.go b/internal/telegram/api_approval.go new file mode 100644 index 00000000..90a14e02 --- /dev/null +++ b/internal/telegram/api_approval.go @@ -0,0 +1,247 @@ +package telegram + +import ( + "fmt" + "regexp" + "strconv" + + "github.com/LightningTipBot/LightningTipBot/internal/errors" + "github.com/LightningTipBot/LightningTipBot/internal/i18n" + "github.com/LightningTipBot/LightningTipBot/internal/lnbits" + "github.com/LightningTipBot/LightningTipBot/internal/runtime/mutex" + "github.com/LightningTipBot/LightningTipBot/internal/storage" + "github.com/LightningTipBot/LightningTipBot/internal/str" + "github.com/LightningTipBot/LightningTipBot/internal/telegram/intercept" + "github.com/LightningTipBot/LightningTipBot/internal/thirdparty" + "github.com/LightningTipBot/LightningTipBot/internal/utils" + log "github.com/sirupsen/logrus" + tb "gopkg.in/lightningtipbot/telebot.v3" +) + +var ( + apiApprovalConfirmationMenu = &tb.ReplyMarkup{ResizeKeyboard: true} + btnCancelAPITx = apiApprovalConfirmationMenu.Data("🚫 Cancel", "cancel_api_tx") + btnApproveAPITx = apiApprovalConfirmationMenu.Data("✅ Approve & Send", "approve_api_tx") +) + +// isTelegramID checks if the identifier is a Telegram ID (numeric string) +func isTelegramID(identifier string) bool { + // Check if it's all digits and has reasonable length for Telegram ID + match, _ := regexp.MatchString(`^[0-9]{5,15}$`, identifier) + return match +} + +// APIApprovalData holds data for API transaction approval (similar to SendData) +type APIApprovalData struct { + *storage.Base + TransactionID string `json:"transaction_id"` + FromUser *lnbits.User `json:"from_user"` + ToUsername string `json:"to_username"` + Amount int64 `json:"amount"` + Memo string `json:"memo"` + Message string `json:"message"` + LanguageCode string `json:"language_code"` + ClientIP string `json:"client_ip"` + OriginalRequest interface{} `json:"original_request" gorm:"-"` +} + +// approveAPITransactionHandler handles the approval of API transactions +func (bot *TipBot) approveAPITransactionHandler(ctx intercept.Context) (intercept.Context, error) { + tx := &APIApprovalData{Base: storage.New(storage.ID(ctx.Data()))} + mutex.LockWithContext(ctx, tx.ID) + defer mutex.UnlockWithContext(ctx, tx.ID) + + sn, err := tx.Get(tx, bot.Bunt) + if err != nil { + log.Errorf("[approveAPITransactionHandler] %s", err.Error()) + return ctx, err + } + approvalData := sn.(*APIApprovalData) + + // Only the correct user can press + if approvalData.FromUser.Telegram.ID != ctx.Callback().Sender.ID { + return ctx, errors.Create(errors.UnknownError) + } + if !approvalData.Active { + log.Errorf("[approveAPITransactionHandler] approval not active anymore") + return ctx, errors.Create(errors.NotActiveError) + } + defer approvalData.Set(approvalData, bot.Bunt) + + from := LoadUser(ctx) + ResetUserState(from, bot) + + // Get recipient user + toUser, err := GetUserByTelegramUsername(approvalData.ToUsername, *bot) + if err != nil { + log.Errorf("[approveAPITransactionHandler] Could not find recipient user %s: %v", approvalData.ToUsername, err) + bot.tryEditMessage(ctx.Callback().Message, "❌ Approval failed: recipient not found", &tb.ReplyMarkup{}) + return ctx, err + } + + // Check sender's balance again + balance, err := bot.GetUserBalance(from) + if err != nil { + log.Errorf("[approveAPITransactionHandler] Could not check sender balance: %v", err) + bot.tryEditMessage(ctx.Callback().Message, "❌ Approval failed: could not check balance", &tb.ReplyMarkup{}) + return ctx, err + } + + if balance < approvalData.Amount { + log.Warnf("[approveAPITransactionHandler] Insufficient balance: %d < %d", balance, approvalData.Amount) + bot.tryEditMessage(ctx.Callback().Message, fmt.Sprintf("❌ Insufficient balance: %s available, %s required", utils.FormatSats(balance), utils.FormatSats(approvalData.Amount)), &tb.ReplyMarkup{}) + return ctx, errors.Create(errors.UnknownError) + } + + // Create transaction memo + fromUserStr := GetUserStr(from.Telegram) + toUserStr := GetUserStr(toUser.Telegram) + transactionMemo := fmt.Sprintf("💸 API Send from %s to %s (Approved).", fromUserStr, toUserStr) + if approvalData.Memo != "" { + transactionMemo += fmt.Sprintf(" Memo: %s", approvalData.Memo) + } + + // Create and execute transaction + t := NewTransaction(bot, from, toUser, approvalData.Amount, TransactionType("api_send_approved")) + t.Memo = transactionMemo + + success, err := t.Send() + if !success || err != nil { + log.Errorf("[approveAPITransactionHandler] Transaction failed from %s to %s: %v", fromUserStr, toUserStr, err) + if bot.ErrorLogger != nil { + bot.ErrorLogger.LogTransactionError(err, "api_send_approved", approvalData.Amount, from.Telegram, toUser.Telegram) + } + bot.tryEditMessage(ctx.Callback().Message, "❌ Payment execution failed", &tb.ReplyMarkup{}) + return ctx, errors.Create(errors.UnknownError) + } + + approvalData.Inactivate(approvalData, bot.Bunt) + + log.Infof("[💸 api_send_approved] Send from %s to %s (%s).", fromUserStr, toUserStr, thirdparty.FormatSatsWithLKR(approvalData.Amount)) + + // Notify recipient (same format as API send) + fromUserStrMd := GetUserStrMd(from.Telegram) + notificationMsg := fmt.Sprintf("💰 You received %s from %s via Automated API", thirdparty.FormatSatsWithLKR(approvalData.Amount), fromUserStrMd) + if approvalData.Memo != "" { + notificationMsg += fmt.Sprintf("\n✉️ Memo: %s", str.MarkdownEscape(approvalData.Memo)) + } + bot.trySendMessage(toUser.Telegram, notificationMsg) + + // Update approval message to show success + if ctx.Callback().Message.Private() { + bot.tryDeleteMessage(ctx.Callback().Message) + successMsg := fmt.Sprintf("✅ Payment approved and sent successfully!\n\n💸 Amount: %s\n👤 To: @%s", thirdparty.FormatSatsWithLKR(approvalData.Amount), approvalData.ToUsername) + if approvalData.Memo != "" { + successMsg += fmt.Sprintf("\n✉️ Memo: %s", str.MarkdownEscape(approvalData.Memo)) + } + bot.trySendMessage(ctx.Callback().Sender, successMsg) + } else { + toUserStrMd := GetUserStrMd(toUser.Telegram) + bot.tryEditMessage(ctx.Callback().Message, fmt.Sprintf("✅ API payment approved and sent!\n\n💸 %s → %s", thirdparty.FormatSatsWithLKR(approvalData.Amount), toUserStrMd), &tb.ReplyMarkup{}) + } + + return ctx, nil +} + +// cancelAPITransactionHandler handles the cancellation of API transactions +func (bot *TipBot) cancelAPITransactionHandler(ctx intercept.Context) (intercept.Context, error) { + c := ctx.Callback() + user := LoadUser(ctx) + ResetUserState(user, bot) + + tx := &APIApprovalData{Base: storage.New(storage.ID(c.Data))} + mutex.LockWithContext(ctx, tx.ID) + defer mutex.UnlockWithContext(ctx, tx.ID) + + sn, err := tx.Get(tx, bot.Bunt) + if err != nil { + log.Errorf("[cancelAPITransactionHandler] %s", err.Error()) + return ctx, err + } + + approvalData := sn.(*APIApprovalData) + // Only the correct user can press + if approvalData.FromUser.Telegram.ID != c.Sender.ID { + return ctx, errors.Create(errors.UnknownError) + } + + // Delete and send cancellation message (same as cancel send) + bot.tryDeleteMessage(c) + bot.trySendMessage(c.Message.Chat, i18n.Translate(approvalData.LanguageCode, "sendCancelledMessage")) + approvalData.Inactivate(approvalData, bot.Bunt) + + log.Infof("[cancelAPITransactionHandler] API transaction %s cancelled by @%s", approvalData.TransactionID, c.Sender.Username) + return ctx, nil +} + +// CreateAPIApprovalRequest creates an approval request for API transaction (similar to send confirmation) +func CreateAPIApprovalRequest(bot *TipBot, fromUser *lnbits.User, toUsername string, amount int64, memo string, transactionID string, clientIP string) error { + // Check if toUsername is actually a user ID and get the actual username + actualUsername := toUsername + if isTelegramID(toUsername) { + // It's a Telegram ID, get the user and use their username + telegramID, err := strconv.ParseInt(toUsername, 10, 64) + if err == nil { + toUser, err := GetUserByTelegramID(telegramID, *bot) + if err == nil && toUser.Telegram.Username != "" { + actualUsername = toUser.Telegram.Username + } + } + } + + // Create confirmation text (same format as /send command) + toUserStrMention := fmt.Sprintf("@%s", actualUsername) + confirmText := fmt.Sprintf("Do you want to pay to %s?\n\n💸 Amount: %s", toUserStrMention, thirdparty.FormatSatsWithLKR(amount)) + if memo != "" { + confirmText += fmt.Sprintf("\n✉️ %s", str.MarkdownEscape(memo)) + } + + // Add approval context + confirmText += "\n\n🔔 *Admin Approval Required*\n" + confirmText += fmt.Sprintf("This transaction requires approval because the amount (%s) exceeds the threshold.", thirdparty.FormatSatsWithLKR(amount)) + + // Create unique ID for this approval request (same pattern as send command) + id := fmt.Sprintf("api-%d-%d-%s", fromUser.Telegram.ID, amount, RandStringRunes(5)) + + // Create approval data object (similar to SendData) + approvalData := &APIApprovalData{ + Base: storage.New(storage.ID(id)), + TransactionID: transactionID, + FromUser: fromUser, + ToUsername: actualUsername, // Use the actual username instead of the original toUsername + Amount: amount, + Memo: memo, + Message: confirmText, + LanguageCode: fromUser.Telegram.LanguageCode, + ClientIP: clientIP, + } + + // Save approval data to database + err := approvalData.Set(approvalData, bot.Bunt) + if err != nil { + log.Errorf("[CreateAPIApprovalRequest] Failed to save approval data: %v", err) + return err + } + + // Create buttons (same pattern as send confirmation) + approveButton := apiApprovalConfirmationMenu.Data("✅ Approve & Send", "approve_api_tx") + cancelButton := apiApprovalConfirmationMenu.Data("🚫 Cancel", "cancel_api_tx") + approveButton.Data = id + cancelButton.Data = id + + apiApprovalConfirmationMenu.Inline( + apiApprovalConfirmationMenu.Row( + approveButton, + cancelButton), + ) + + // Send approval request to the sender (from user) + _, err = bot.Telegram.Send(fromUser.Telegram, confirmText, apiApprovalConfirmationMenu, tb.ModeMarkdown) + if err != nil { + log.Errorf("[CreateAPIApprovalRequest] Failed to send approval request: %v", err) + return err + } + + log.Infof("[CreateAPIApprovalRequest] Sent approval request to @%s for transaction %s", fromUser.Telegram.Username, transactionID) + return nil +} diff --git a/internal/telegram/balance.go b/internal/telegram/balance.go new file mode 100644 index 00000000..9751a4b4 --- /dev/null +++ b/internal/telegram/balance.go @@ -0,0 +1,88 @@ +package telegram + +import ( + "fmt" + + "github.com/LightningTipBot/LightningTipBot/internal/errors" + "github.com/LightningTipBot/LightningTipBot/internal/telegram/intercept" + "github.com/LightningTipBot/LightningTipBot/internal/thirdparty" + "github.com/LightningTipBot/LightningTipBot/internal/utils" + + log "github.com/sirupsen/logrus" + + tb "gopkg.in/lightningtipbot/telebot.v3" +) + +func (bot *TipBot) balanceHandler(ctx intercept.Context) (intercept.Context, error) { + m := ctx.Message() + // check and print all commands + if len(m.Text) > 0 { + bot.anyTextHandler(ctx) + } + + // reply only in private message + if m.Chat.Type != tb.ChatPrivate { + // delete message + bot.tryDeleteMessage(m) + } + // first check whether the user is initialized + user := LoadUser(ctx) + if user.Wallet == nil { + return ctx, errors.Create(errors.UserNoWalletError) + } + + if !user.Initialized { + return bot.startHandler(ctx) + } + + usrStr := GetUserStr(ctx.Sender()) + // Get available balance (wallet balance - pot balance) + availableBalance, err := bot.GetUserAvailableBalance(user) + if err != nil { + log.Errorf("[/balance] Error fetching %s's available balance: %s", usrStr, err) + availableBalance = 0 + } + + log.Infof("[/balance] %s's available balance: %s\n", usrStr, utils.FormatSats(availableBalance)) + + LKRPerSat, USDPerSat, err := thirdparty.GetSatPrice() + if err != nil { + log.Infof("[/balance] error fetching price from coingecko\n") + } + + potBalance, err := bot.GetUserTotalPotBalance(user) + if err != nil { + log.Errorf("[/balance] Error fetching %s's pot balance: %s", usrStr, err) + potBalance = 0 + } + + availableUSDValue := USDPerSat * float64(availableBalance) + availableLKRValue := LKRPerSat * float64(availableBalance) + + potUSDValue := USDPerSat * float64(potBalance) + potLKRValue := LKRPerSat * float64(potBalance) + + totalBalance := availableBalance + potBalance + totalUSDValue := USDPerSat * float64(totalBalance) + totalLKRValue := LKRPerSat * float64(totalBalance) + + message := fmt.Sprintf(Translate(ctx, "balanceMessage"), + utils.FormatSats(availableBalance), + utils.FormatFloatWithCommas(availableUSDValue), + utils.FormatFloatWithCommas(availableLKRValue)) + + if potBalance > 0 { + potInfo := fmt.Sprintf(Translate(ctx, "potBalanceInfo"), + utils.FormatSats(potBalance), + utils.FormatFloatWithCommas(potUSDValue), + utils.FormatFloatWithCommas(potLKRValue)) + totalInfo := fmt.Sprintf(Translate(ctx, "totalBalanceInfo"), + utils.FormatSats(totalBalance), + utils.FormatFloatWithCommas(totalUSDValue), + utils.FormatFloatWithCommas(totalLKRValue)) + message += "\n" + potInfo + "\n" + totalInfo + } + + bot.trySendMessage(ctx.Sender(), message) + return ctx, nil +} diff --git a/internal/telegram/bot.go b/internal/telegram/bot.go new file mode 100644 index 00000000..e9c3eabe --- /dev/null +++ b/internal/telegram/bot.go @@ -0,0 +1,159 @@ +package telegram + +import ( + "context" + "fmt" + "os" + "os/signal" + "sync" + "syscall" + "time" + + "github.com/LightningTipBot/LightningTipBot/internal/runtime/mutex" + + limiter "github.com/LightningTipBot/LightningTipBot/internal/rate" + + "github.com/eko/gocache/store" + + "github.com/LightningTipBot/LightningTipBot/internal" + "github.com/LightningTipBot/LightningTipBot/internal/lnbits" + "github.com/LightningTipBot/LightningTipBot/internal/storage" + gocache "github.com/patrickmn/go-cache" + log "github.com/sirupsen/logrus" + tb "gopkg.in/lightningtipbot/telebot.v3" +) + +type TipBot struct { + DB *Databases + Bunt *storage.DB + ShopBunt *storage.DB + Telegram *tb.Bot + Client *lnbits.Client + limiter map[string]limiter.Limiter + ErrorLogger *ErrorLogger + Cache +} +type Cache struct { + *store.GoCacheStore +} + +var ( + botWalletInitialisation = sync.Once{} + telegramHandlerRegistration = sync.Once{} +) + +// NewBot migrates data and creates a new bot +func NewBot() TipBot { + gocacheClient := gocache.New(5*time.Minute, 10*time.Minute) + gocacheStore := store.NewGoCache(gocacheClient, nil) + // create sqlite databases + dbs := AutoMigration() + limiter.Start() + + bot := TipBot{ + DB: dbs, + Client: lnbits.NewClient(internal.Configuration.Lnbits.AdminKey, internal.Configuration.Lnbits.Url), + Bunt: createBunt(internal.Configuration.Database.BuntDbPath), + ShopBunt: createBunt(internal.Configuration.Database.ShopBuntDbPath), + Telegram: newTelegramBot(), + Cache: Cache{GoCacheStore: gocacheStore}, + } + + // Initialize error logger after bot is created + bot.ErrorLogger = NewErrorLogger(&bot) + + return bot +} + +// newTelegramBot will create a new Telegram bot. +func newTelegramBot() *tb.Bot { + tgb, err := tb.NewBot(tb.Settings{ + Token: internal.Configuration.Telegram.ApiKey, + Poller: &tb.LongPoller{Timeout: 60 * time.Second}, + ParseMode: tb.ModeMarkdown, + Verbose: false, + }) + 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 +} + +// GracefulShutdown will gracefully shutdown the bot +// It will wait for all mutex locks to unlock before shutdown. +func (bot *TipBot) GracefulShutdown() { + t := time.NewTicker(time.Second * 10) + log.Infof("[shutdown] Graceful shutdown (timeout=10s).") + for { + select { + case <-t.C: + // timer expired + log.Infof("[shutdown] Graceful shutdown timeout reached. Forcing shutdown.") + return + default: + // check if all mutex locks are unlocked + if mutex.IsEmpty() { + log.Infof("[shutdown] Graceful shutdown successful.") + return + } + } + time.Sleep(time.Second) + log.Tracef("[shutdown] Trying graceful shutdown...") + } +} + +// Start will initialize the Telegram bot and lnbits. +func (bot *TipBot) Start() { + 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()) + } + + // register telegram handlers + bot.registerTelegramHandlers() + + // start standing order scheduler with a cancellable context so it stops cleanly on shutdown + schedulerCtx, cancelScheduler := context.WithCancel(context.Background()) + defer cancelScheduler() + NewStandingOrderScheduler(bot).Start(schedulerCtx) + + // download bot avatar once + bot.downloadMyProfilePicture() + + // edit worker collects messages to edit and + // periodically edits them + bot.startEditWorker() + + // register callbacks for invoices + initInvoiceEventCallbacks(bot) + + // register callbacks for user state changes + initializeStateCallbackMessage(bot) + + // start the telegram bot + go bot.Telegram.Start() + + go bot.restartPersistedTickets() + // gracefully shutdown + exit := make(chan os.Signal, 1) // we need to reserve to buffer size 1, so the notifier are not blocked + // we need to catch SIGTERM and SIGINT + signal.Notify(exit, os.Interrupt, syscall.SIGTERM) + <-exit + // gracefully shutdown + bot.GracefulShutdown() +} diff --git a/internal/telegram/buttons.go b/internal/telegram/buttons.go new file mode 100644 index 00000000..07beb24b --- /dev/null +++ b/internal/telegram/buttons.go @@ -0,0 +1,175 @@ +package telegram + +import ( + "context" + "fmt" + "strings" + + "github.com/LightningTipBot/LightningTipBot/internal" + "github.com/LightningTipBot/LightningTipBot/internal/lnbits" + "github.com/LightningTipBot/LightningTipBot/internal/thirdparty" + log "github.com/sirupsen/logrus" + tb "gopkg.in/lightningtipbot/telebot.v3" +) + +// we can't use space in the label of buttons, because string splitting will mess everything up. +const ( + // MainMenuCommandWebApp = "⤵️ Recv" + // MainMenuCommandBalance = "Balance" + // MainMenuCommandInvoice = "⚡️ Invoice" + // MainMenuCommandHelp = "📖 Help" + // MainMenuCommandSend = "⤴️ Send" + // SendMenuCommandEnter = "👤 Enter" + MainMenuCommandWebApp = "🤝 Community" + MainMenuCommandBalance = "Balance" + MainMenuCommandInvoice = "⚡️ Invoice" + MainMenuCommandHelp = "📖 Help" + MainMenuCommandSend = "⤴️" + MainMenuCommandConvert = "LKR→Sat" + SendMenuCommandEnter = "👤 Enter" +) + +var ( + mainMenu = &tb.ReplyMarkup{ResizeKeyboard: true} + btnHelpMainMenu = mainMenu.Text(MainMenuCommandHelp) + btnWebAppMainMenu = mainMenu.Text(MainMenuCommandWebApp) + btnSendMainMenu = mainMenu.Text(MainMenuCommandSend) + btnBalanceMainMenu = mainMenu.Text(MainMenuCommandBalance) + btnInvoiceMainMenu = mainMenu.Text(MainMenuCommandInvoice) + btnConvertMainMenu = mainMenu.Text(MainMenuCommandConvert) + + sendToMenu = &tb.ReplyMarkup{ResizeKeyboard: true} + sendToButtons = []tb.Btn{} + btnSendMenuEnter = mainMenu.Text(SendMenuCommandEnter) +) + +func init() { + btnBalanceMainMenu = mainMenu.Text(MainMenuCommandBalance) + mainMenu.Reply( + mainMenu.Row(btnBalanceMainMenu), + // mainMenu.Row(btnInvoiceMainMenu, btnWebAppMainMenu, btnSendMainMenu, btnHelpMainMenu), // TODO: fix btnSendMainMenu + mainMenu.Row(btnInvoiceMainMenu, btnConvertMainMenu, btnHelpMainMenu), + ) +} + +// buttonWrapper wrap buttons slice in rows of length i +func buttonWrapper(buttons []tb.Btn, markup *tb.ReplyMarkup, length int) []tb.Row { + buttonLength := len(buttons) + rows := make([]tb.Row, 0) + + if buttonLength > length { + for i := 0; i < buttonLength; i = i + length { + buttonRow := make([]tb.Btn, length) + if i+length < buttonLength { + buttonRow = buttons[i : i+length] + } else { + buttonRow = buttons[i:] + } + rows = append(rows, markup.Row(buttonRow...)) + } + return rows + } + rows = append(rows, markup.Row(buttons...)) + return rows +} + +// appendWebAppLinkToButton adds a WebApp object to a Button with the user's webapp page +func (bot *TipBot) appendWebAppLinkToButton(btn *tb.Btn, user *lnbits.User) { + var url string + if len(user.Telegram.Username) > 0 { + url = fmt.Sprintf("%s/app/@%s", internal.Configuration.Bot.LNURLHostName, user.Telegram.Username) + } else { + url = fmt.Sprintf("%s/app/@%s", internal.Configuration.Bot.LNURLHostName, user.AnonIDSha256) + } + if strings.HasPrefix(url, "https://") { + // prevent adding a link if not https is used, otherwise + // Telegram returns an error and does not show the keyboard + url = "https://t.me/c/1869805823" + btn.URL = "https://t.me/c/1869805823" + } +} + +// mainMenuBalanceButtonUpdate updates the balance button in the mainMenu +func (bot *TipBot) mainMenuBalanceButtonUpdate(to int64) { + var user *lnbits.User + var err error + if user, err = getCachedUser(&tb.User{ID: to}, *bot); err != nil { + user, err = GetLnbitsUser(&tb.User{ID: to}, *bot) + if err != nil { + return + } + updateCachedUser(user, *bot) + } + if user.Wallet != nil { + amount, err := bot.GetUserBalanceCached(user) + if err == nil { + + log.Tracef("[appendMainMenu] user %s balance %d sat(s)", GetUserStr(user.Telegram), amount) + MainMenuCommandBalance := fmt.Sprintf("%s %s", MainMenuCommandBalance, thirdparty.FormatSatsWithLKR(amount)) + + btnBalanceMainMenu = mainMenu.Text(MainMenuCommandBalance) + } + + bot.appendWebAppLinkToButton(&btnWebAppMainMenu, user) + mainMenu.Reply( + mainMenu.Row(btnBalanceMainMenu), + // mainMenu.Row(btnInvoiceMainMenu, btnWebAppMainMenu, btnSendMainMenu, btnHelpMainMenu), // TODO: fix btnSendMainMenu + mainMenu.Row(btnInvoiceMainMenu, btnConvertMainMenu, btnHelpMainMenu), + ) + } +} + +// makeContactsButtons will create a slice of buttons for the send menu +// it will show the 5 most recently interacted contacts and one button to use a custom contact +func (bot *TipBot) makeContactsButtons(ctx context.Context) []tb.Btn { + var records []Transaction + + sendToButtons = []tb.Btn{} + user := LoadUser(ctx) + // get 5 most recent transactions by from_id with distint to_user + // where to_user starts with an @ and is not the user itself + bot.DB.Transactions.Where("from_id = ? AND to_user LIKE ? AND to_user <> ?", user.Telegram.ID, "@%", GetUserStr(user.Telegram)).Distinct("to_user").Order("id desc").Limit(5).Find(&records) + log.Debugf("[makeContactsButtons] found %d records", len(records)) + + // get all contacts and add them to the buttons + for i, r := range records { + log.Tracef("[makeContactsButtons] toNames[%d] = %s (id=%d)", i, r.ToUser, r.ID) + sendToButtons = append(sendToButtons, tb.Btn{Text: r.ToUser}) + } + + // add the "enter a username" button to the end + sendToButtons = append(sendToButtons, tb.Btn{Text: SendMenuCommandEnter}) + sendToMenu.Reply(buttonWrapper(sendToButtons, sendToMenu, 3)...) + return sendToButtons +} + +// appendMainMenu will check if to (recipient) ID is from private or group chat. +// appendMainMenu is called in telegram.go every time a user receives a PM from the bot. +// this function will only add a keyboard if this is a private chat and no force reply. +func (bot *TipBot) appendMainMenu(to int64, recipient interface{}, options []interface{}) []interface{} { + + // update the balance button + if to > 0 { + bot.mainMenuBalanceButtonUpdate(to) + } + + appendKeyboard := true + for _, option := range options { + if option == tb.ForceReply { + appendKeyboard = false + } + switch option.(type) { + case *tb.ReplyMarkup: + appendKeyboard = false + //option.(*tb.ReplyMarkup).ReplyKeyboard = mainMenu.ReplyKeyboard + //if option.(*tb.ReplyMarkup).InlineKeyboard == nil { + // options = append(options[:i], options[i+1:]...) + //} + } + } + // to > 0 is private chats + if to > 0 && appendKeyboard { + options = append(options, mainMenu) + } + return options +} diff --git a/internal/telegram/convertsats.go b/internal/telegram/convertsats.go new file mode 100644 index 00000000..107e0826 --- /dev/null +++ b/internal/telegram/convertsats.go @@ -0,0 +1,43 @@ +package telegram + +import ( + "fmt" + "strconv" + "strings" + + "github.com/LightningTipBot/LightningTipBot/internal/telegram/intercept" + "github.com/LightningTipBot/LightningTipBot/internal/thirdparty" + "github.com/LightningTipBot/LightningTipBot/internal/utils" + log "github.com/sirupsen/logrus" +) + +// satToFiatHandler converts satoshis to USD and LKR values +func (bot *TipBot) satToFiatHandler(ctx intercept.Context) (intercept.Context, error) { + m := ctx.Message() + bot.anyTextHandler(ctx) + + args := strings.Split(m.Text, " ") + if len(args) < 2 { + bot.trySendMessage(m.Sender, Translate(ctx, "convertEnterAmountMessage")) + return ctx, nil + } + amountStr := strings.ReplaceAll(args[1], ",", "") + sats, err := strconv.ParseInt(amountStr, 10, 64) + if err != nil || sats <= 0 { + bot.trySendMessage(m.Sender, Translate(ctx, "convertInvalidAmountMessage")) + return ctx, nil + } + + lkrPerSat, usdPerSat, err := thirdparty.GetSatPrice() + if err != nil || lkrPerSat == 0 || usdPerSat == 0 { + log.Errorf("[satToFiat] error fetching price: %v", err) + bot.trySendMessage(m.Sender, Translate(ctx, "convertPriceErrorMessage")) + return ctx, err + } + + usd := usdPerSat * float64(sats) + lkr := lkrPerSat * float64(sats) + + bot.trySendMessage(m.Sender, fmt.Sprintf(Translate(ctx, "convertSatsResultMessage"), utils.FormatSats(sats), utils.FormatFloatWithCommas(usd), utils.FormatFloatWithCommas(lkr))) + return ctx, nil +} diff --git a/internal/telegram/database.go b/internal/telegram/database.go new file mode 100644 index 00000000..6dd7791b --- /dev/null +++ b/internal/telegram/database.go @@ -0,0 +1,329 @@ +package telegram + +import ( + "bufio" + "crypto/sha1" + "encoding/base64" + "fmt" + "os" + "reflect" + "runtime/debug" + "strconv" + "time" + + "github.com/LightningTipBot/LightningTipBot/internal/database" + "github.com/LightningTipBot/LightningTipBot/internal/str" + + "github.com/eko/gocache/store" + + "github.com/LightningTipBot/LightningTipBot/internal" + + "github.com/LightningTipBot/LightningTipBot/internal/storage" + "github.com/tidwall/buntdb" + + log "github.com/sirupsen/logrus" + + "github.com/LightningTipBot/LightningTipBot/internal/lnbits" + tb "gopkg.in/lightningtipbot/telebot.v3" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +type Databases struct { + Users *gorm.DB + Transactions *gorm.DB + Groups *gorm.DB +} + +const ( + JoinTicketIndex = "join-ticket:*" + MessageOrderedByReplyToFrom = "message.reply_to_message.from.id" + TipTooltipKeyPattern = "tip-tool-tip:*" +) + +func createBunt(file string) *storage.DB { + t1 := time.Now() + // create bunt database + bunt := storage.NewBunt(file) + log.Infof("[blunt] loaded file in %s", time.Since(t1)) + // create bunt database index for ascending (searching) TipTooltips + err := bunt.CreateIndex(MessageOrderedByReplyToFrom, TipTooltipKeyPattern, buntdb.IndexJSON(MessageOrderedByReplyToFrom)) + log.Infof("[blunt] index 1 created in %s", time.Since(t1)) + + if err != nil { + panic(err) + } + err = bunt.CreateIndex("join-ticket", JoinTicketIndex, buntdb.IndexString) + log.Infof("[blunt] index 2 created in %s", time.Since(t1)) + if err != nil { + panic(err) + } + log.Infof("[blunt] total time: %s", time.Since(t1)) + return bunt +} + +func ColumnMigrationTasks(db *gorm.DB) error { + var err error + // anon_id migration (2021-11-01) + if !db.Migrator().HasColumn(&lnbits.User{}, "anon_id") { + // first we need to auto migrate the user. This will create anon_id column + err = db.AutoMigrate(&lnbits.User{}) + if err != nil { + panic(err) + } + log.Info("Running anon_id database migrations ...") + // run the migration on anon_id + err = database.MigrateAnonIdInt32Hash(db) + } + + // anon_id_sha256 migration (2022-01-01) + if !db.Migrator().HasColumn(&lnbits.User{}, "anon_id_sha256") { + // first we need to auto migrate the user. This will create anon_id column + err = db.AutoMigrate(&lnbits.User{}) + if err != nil { + panic(err) + } + log.Info("Running anon_id_sha256 database migrations ...") + // run the migration on anon_id + err = database.MigrateAnonIdSha265Hash(db) + } + + // uuid migration (2022-02-11) + if !db.Migrator().HasColumn(&lnbits.User{}, "uuid") { + // first we need to auto migrate the user. This will create uuid column + err = db.AutoMigrate(&lnbits.User{}) + if err != nil { + panic(err) + } + log.Info("Running UUID database migrations ...") + // run the migration on uuid + err = database.MigrateUUIDSha265Hash(db) + } + + // todo -- add more database field migrations here in the future + return err +} + +func AutoMigration() *Databases { + orm, err := gorm.Open(sqlite.Open(internal.Configuration.Database.DbPath), &gorm.Config{DisableForeignKeyConstraintWhenMigrating: true, FullSaveAssociations: true}) + if err != nil { + panic("Initialize orm failed.") + } + err = ColumnMigrationTasks(orm) + if err != nil { + panic(err) + } + err = orm.AutoMigrate(&lnbits.User{}) + if err != nil { + panic(err) + } + err = orm.AutoMigrate(&lnbits.SavingsPot{}) + if err != nil { + panic(err) + } + err = orm.AutoMigrate(&lnbits.StandingOrder{}) + if err != nil { + panic(err) + } + + txLogger, err := gorm.Open(sqlite.Open(internal.Configuration.Database.TransactionsPath), &gorm.Config{DisableForeignKeyConstraintWhenMigrating: true, FullSaveAssociations: true}) + if err != nil { + panic("Initialize orm failed.") + } + err = txLogger.AutoMigrate(&Transaction{}) + if err != nil { + panic(err) + } + + groupsDb, err := gorm.Open(sqlite.Open(internal.Configuration.Database.GroupsDbPath), &gorm.Config{DisableForeignKeyConstraintWhenMigrating: true, FullSaveAssociations: true}) + if err != nil { + panic("Initialize orm failed.") + } + err = groupsDb.AutoMigrate(&Group{}) + if err != nil { + panic(err) + } + + return &Databases{ + Users: orm, + Transactions: txLogger, + Groups: groupsDb, + } +} + +func GetUserByTelegramUsername(toUserStrWithoutAt string, bot TipBot) (*lnbits.User, error) { + toUserDb := &lnbits.User{} + // return error if username is too long + if len(toUserStrWithoutAt) > 100 { + return nil, fmt.Errorf("[GetUserByTelegramUsername] Telegram username is too long: %s..", toUserStrWithoutAt[:100]) + } + tx := bot.DB.Users.Where("telegram_username = ? COLLATE NOCASE", toUserStrWithoutAt).First(toUserDb) + if tx.Error != nil || toUserDb.Wallet == nil { + err := tx.Error + if toUserDb.Wallet == nil { + err = fmt.Errorf("%s | user @%s has no wallet", tx.Error, toUserStrWithoutAt) + } + return nil, err + } + return toUserDb, nil +} + +// GetUserByTelegramID retrieves a user by their Telegram ID +func GetUserByTelegramID(telegramID int64, bot TipBot) (*lnbits.User, error) { + toUserDb := &lnbits.User{} + tx := bot.DB.Users.Where("telegram_id = ?", telegramID).First(toUserDb) + if tx.Error != nil || toUserDb.Wallet == nil { + err := tx.Error + if toUserDb.Wallet == nil { + err = fmt.Errorf("%s | user with ID %d has no wallet", tx.Error, telegramID) + } + return nil, err + } + return toUserDb, nil +} + +func getCachedUser(u *tb.User, bot TipBot) (*lnbits.User, error) { + user := &lnbits.User{Name: strconv.FormatInt(u.ID, 10)} + if us, err := bot.Cache.Get(user.Name); err == nil { + return us.(*lnbits.User), nil + } + user.Telegram = u + return user, gorm.ErrRecordNotFound +} + +// GetLnbitsUser will not update the user in Database. +// this is required, because fetching lnbits.User from a incomplete tb.User +// will update the incomplete (partial) user in storage. +// this function will accept users like this: +// &tb.User{ID: toId, Username: username} +// without updating the user in storage. +func GetLnbitsUser(u *tb.User, bot TipBot) (*lnbits.User, error) { + user := &lnbits.User{Name: strconv.FormatInt(u.ID, 10)} + tx := bot.DB.Users.First(user) + if tx.Error != nil { + errmsg := fmt.Sprintf("[GetUser] Couldn't fetch %s from Database: %s", GetUserStr(u), tx.Error.Error()) + log.Warnln(errmsg) + if bot.ErrorLogger != nil { + bot.ErrorLogger.LogDatabaseError(tx.Error, "GetLnbitsUser", u) + } + user.Telegram = u + return user, tx.Error + } + // todo -- unblock this ! + return user, nil +} + +func GetLnbitsUserWithSettings(u *tb.User, bot TipBot) (*lnbits.User, error) { + user := &lnbits.User{Name: strconv.FormatInt(u.ID, 10)} + tx := bot.DB.Users.Preload("Settings").First(user) + if tx.Error != nil { + errmsg := fmt.Sprintf("[GetLnbitsUserWithSettings] Couldn't fetch %s from Database: %s", GetUserStr(u), tx.Error.Error()) + log.Warnln(errmsg) + if bot.ErrorLogger != nil { + bot.ErrorLogger.LogDatabaseError(tx.Error, "GetLnbitsUserWithSettings", u) + } + user.Telegram = u + return user, tx.Error + } + if user.Settings == nil { + user.Settings = &lnbits.Settings{ID: user.ID} + } + return user, nil +} + +// GetUser from Telegram user. Update the user if user information changed. +func GetUser(u *tb.User, bot TipBot) (*lnbits.User, error) { + var user *lnbits.User + var err error + if user, err = getCachedUser(u, bot); err != nil { + user, err = GetLnbitsUser(u, bot) + if err != nil { + return user, err + } + updateCachedUser(user, bot) + } + if telegramUserChanged(u, user.Telegram) { + // update possibly changed user details in Database + user.Telegram = u + err = UpdateUserRecord(user, bot) + if err != nil { + log.Warnln(fmt.Sprintf("[UpdateUserRecord] %s", err.Error())) + } + } + return user, err +} + +func updateCachedUser(apiUser *lnbits.User, bot TipBot) { + bot.Cache.Set(apiUser.Name, apiUser, &store.Options{Expiration: 1 * time.Minute}) +} + +func telegramUserChanged(apiUser, stateUser *tb.User) bool { + if reflect.DeepEqual(apiUser, stateUser) { + return false + } + return true +} +func debugStack() { + stack := debug.Stack() + go func() { + hasher := sha1.New() + hasher.Write(stack) + sha := base64.URLEncoding.EncodeToString(hasher.Sum(nil)) + fo, err := os.Create(fmt.Sprintf("trace_%s.txt", sha)) + log.Infof("[debugStack] ⚠️ Writing stack trace to %s", fmt.Sprintf("trace_%s.txt", sha)) + if err != nil { + panic(err) + } + defer func() { + if err := fo.Close(); err != nil { + panic(err) + } + }() + w := bufio.NewWriter(fo) + if _, err := w.Write(stack); err != nil { + panic(err) + } + + if err = w.Flush(); err != nil { + panic(err) + } + }() +} +func UpdateUserRecord(user *lnbits.User, bot TipBot) error { + user.UpdatedAt = time.Now() + + // There is a weird bug that makes the AnonID vanish. This is a workaround. + // TODO -- Remove this after empty anon id bug is identified + if user.AnonIDSha256 == "" { + debugStack() + user.AnonIDSha256 = str.AnonIdSha256(user) + log.Errorf("[UpdateUserRecord] AnonIDSha256 empty! Setting to: %s", user.AnonIDSha256) + } + // TODO -- Remove this after empty anon id bug is identified + if user.AnonID == "" { + debugStack() + user.AnonID = fmt.Sprint(str.Int32Hash(user.ID)) + log.Errorf("[UpdateUserRecord] AnonID empty! Setting to: %s", user.AnonID) + } + // TODO -- Remove this after empty anon id bug is identified + if user.UUID == "" { + debugStack() + user.UUID = str.UUIDSha256(user) + log.Errorf("[UpdateUserRecord] UUID empty! Setting to: %s", user.UUID) + } + + tx := bot.DB.Users.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) + if bot.ErrorLogger != nil { + bot.ErrorLogger.LogDatabaseError(tx.Error, "UpdateUserRecord", user.Telegram) + } + return tx.Error + } + log.Tracef("[UpdateUserRecord] Records of user %s updated.", GetUserStr(user.Telegram)) + if bot.Cache.GoCacheStore != nil { + updateCachedUser(user, bot) + } + return nil +} diff --git a/internal/telegram/donate.go b/internal/telegram/donate.go new file mode 100644 index 00000000..e372a434 --- /dev/null +++ b/internal/telegram/donate.go @@ -0,0 +1,189 @@ +package telegram + +import ( + "context" + "encoding/json" + "fmt" + "github.com/fiatjaf/go-lnurl" + "io" + "io/ioutil" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/LightningTipBot/LightningTipBot/internal/telegram/intercept" + + "github.com/LightningTipBot/LightningTipBot/internal/errors" + + "github.com/LightningTipBot/LightningTipBot/internal/str" + + "github.com/LightningTipBot/LightningTipBot/internal/lnbits" + log "github.com/sirupsen/logrus" + tb "gopkg.in/lightningtipbot/telebot.v3" +) + +// 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 ( + donationEndpoint string +) + +func helpDonateUsage(ctx context.Context, errormsg string) string { + if len(errormsg) > 0 { + return fmt.Sprintf(Translate(ctx, "donateHelpText"), fmt.Sprintf("%s", errormsg)) + } else { + return fmt.Sprintf(Translate(ctx, "donateHelpText"), "") + } +} + +func (bot TipBot) donationHandler(ctx intercept.Context) (intercept.Context, error) { + // check and print all commands + m := ctx.Message() + bot.anyTextHandler(ctx) + user := LoadUser(ctx) + if user.Wallet == nil { + return ctx, errors.Create(errors.UserNoWalletError) + } + // if no amount is in the command, ask for it + amount, err := decodeAmountFromCommand(m.Text) + if (err != nil || amount < 1) && m.Chat.Type == tb.ChatPrivate { + // // no amount was entered, set user state and ask for amount + _, err = bot.askForAmount(ctx, "", "CreateDonationState", 0, 0, m.Text) + return ctx, err + } + amount = amount * 1000 + // command is valid + msg := bot.trySendMessageEditable(m.Chat, Translate(ctx, "donationProgressMessage")) + // get invoice + r, err := http.NewRequest(http.MethodGet, donationEndpoint, nil) + if err != nil { + log.Errorln(err) + bot.tryEditMessage(msg, Translate(ctx, "donationErrorMessage")) + return ctx, err + } + // Create query parameters + params := url.Values{} + params.Set("amount", strconv.FormatInt(amount, 10)) + params.Set("comment", fmt.Sprintf("from %s bot %s", GetUserStr(user.Telegram), GetUserStr(bot.Telegram.Me))) + // Set the query parameters in the URL + r.URL.RawQuery = params.Encode() + + resp, err := http.DefaultClient.Do(r) + if err != nil { + log.Errorln(err) + bot.tryEditMessage(msg, Translate(ctx, "donationErrorMessage")) + return ctx, err + } + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + log.Errorln(err) + bot.tryEditMessage(msg, Translate(ctx, "donationErrorMessage")) + return ctx, err + } + pv := lnurl.LNURLPayValues{} + err = json.Unmarshal(body, &pv) + if err != nil { + log.Errorln(err) + bot.tryEditMessage(msg, Translate(ctx, "donationErrorMessage")) + return ctx, err + } + if pv.Status == "ERROR" || len(pv.PR) < 1 { + log.Errorln(err) + bot.tryEditMessage(msg, Translate(ctx, "donationErrorMessage")) + return ctx, err + } + + // send donation invoice + // user := LoadUser(ctx) + // bot.trySendMessage(user.Telegram, string(body)) + _, err = user.Wallet.Pay(lnbits.PaymentParams{Out: true, Bolt11: string(pv.PR)}, bot.Client) + if err != nil { + userStr := GetUserStr(user.Telegram) + errmsg := fmt.Sprintf("[/donate] Donation failed for user %s: %s", userStr, err) + log.Errorln(errmsg) + bot.tryEditMessage(msg, Translate(ctx, "donationErrorMessage")) + return ctx, err + } + // hotfix because the edit doesn't work! + // todo: fix edit + // bot.tryEditMessage(msg, Translate(ctx, "donationSuccess")) + bot.tryDeleteMessage(msg) + bot.trySendMessage(m.Chat, Translate(ctx, "donationSuccess")) + return ctx, nil +} + +func init() { + var sb strings.Builder + _, err := io.Copy(&sb, rot13Reader{strings.NewReader("uggcf://ya.gvcf/.jryy-xabja/yaheyc/YvtugavatGvcObg")}) + if err != nil { + panic(err) + } + donationEndpoint = 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(ctx intercept.Context) error { + m := ctx.Message() + 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, str.MarkdownEscape(donationInterceptMessage)) + m.Text = fmt.Sprintf("/donate %d", amount) + bot.donationHandler(ctx) + // returning nil here will abort the parent ctx (/pay or /tip) + return nil +} diff --git a/internal/telegram/edit.go b/internal/telegram/edit.go new file mode 100644 index 00000000..1c8ac0e3 --- /dev/null +++ b/internal/telegram/edit.go @@ -0,0 +1,84 @@ +package telegram + +import ( + "strings" + "time" + + cmap "github.com/orcaman/concurrent-map" + log "github.com/sirupsen/logrus" + tb "gopkg.in/lightningtipbot/telebot.v3" +) + +var editStack cmap.ConcurrentMap + +type edit struct { + to tb.Editable + key string + what interface{} + options []interface{} + lastEdit time.Time + edited bool +} + +func init() { + editStack = cmap.New() +} + +const resultTrueError = "telebot: result is True" +const editSameStringError = "specified new message content and reply markup are exactly the same as a current content and reply markup of the message" +const retryAfterError = "retry after" + +// startEditWorker will loop through the editStack and run tryEditMessage on not edited messages. +// if editFromStack is older than 5 seconds, editFromStack will be removed. +func (bot TipBot) startEditWorker() { + go func() { + for { + for _, k := range editStack.Keys() { + if e, ok := editStack.Get(k); ok { + editFromStack := e.(edit) + if !editFromStack.edited { + _, err := bot.tryEditMessage(editFromStack.to, editFromStack.what, editFromStack.options...) + if err != nil && strings.Contains(err.Error(), retryAfterError) { + // ignore any other error than retry after + log.Errorf("[startEditWorker] Edit error: %s. len(editStack)=%d", err.Error(), len(editStack.Keys())) + + } else { + if err != nil { + log.Errorf("[startEditWorker] Ignoring edit error: %s. len(editStack)=%d", err.Error(), len(editStack.Keys())) + } + log.Tracef("[startEditWorker] message from stack edited %+v. len(editStack)=%d", editFromStack, len(editStack.Keys())) + editFromStack.lastEdit = time.Now() + editFromStack.edited = true + editStack.Set(k, editFromStack) + } + } else { + if editFromStack.lastEdit.Before(time.Now().Add(-(time.Duration(5) * time.Second))) { + log.Tracef("[startEditWorker] removing message edit from stack %+v. len(editStack)=%d", editFromStack, len(editStack.Keys())) + editStack.Remove(k) + } + } + } + } + time.Sleep(time.Millisecond * 1000) + } + }() + +} + +// tryEditStack will add the editable to the edit stack, if what (message) changed. +func (bot TipBot) tryEditStack(to tb.Editable, key string, what interface{}, options ...interface{}) { + sig, chat := to.MessageSig() + log.Tracef("[tryEditStack] sig=%s, chat=%d, key=%s, what=%+v, options=%+v", sig, chat, key, what, options) + // var sig = fmt.Sprintf("%s-%d", msgSig, chat) + if e, ok := editStack.Get(key); ok { + editFromStack := e.(edit) + if editFromStack.what == what.(string) { + log.Tracef("[tryEditStack] Message already in edit stack. Skipping") + return + } + } + e := edit{options: options, key: key, what: what, to: to} + + editStack.Set(key, e) + log.Tracef("[tryEditStack] Added message %s to edit stack. len(editStack)=%d", key, len(editStack.Keys())) +} diff --git a/internal/telegram/error_logger.go b/internal/telegram/error_logger.go new file mode 100644 index 00000000..b2616b74 --- /dev/null +++ b/internal/telegram/error_logger.go @@ -0,0 +1,475 @@ +package telegram + +import ( + "fmt" + "runtime" + "strings" + "time" + + "github.com/LightningTipBot/LightningTipBot/internal" + "github.com/LightningTipBot/LightningTipBot/internal/utils" + log "github.com/sirupsen/logrus" + tb "gopkg.in/lightningtipbot/telebot.v3" +) + +// ErrorLogger handles logging errors to Telegram group +type ErrorLogger struct { + bot *TipBot + logGroupId int64 + threadId int64 + enabled bool +} + +// NewErrorLogger creates a new error logger instance +func NewErrorLogger(bot *TipBot) *ErrorLogger { + logger := &ErrorLogger{ + bot: bot, + logGroupId: internal.Configuration.Telegram.LogGroupId, + threadId: internal.Configuration.Telegram.ErrorThreadId, + enabled: internal.Configuration.Telegram.LogGroupId != 0, + } + + if logger.enabled { + log.Infof("[ErrorLogger] Error logging enabled for group: %d", logger.logGroupId) + } else { + log.Warnf("[ErrorLogger] Error logging disabled - no log_group_id configured") + } + + return logger +} + +// GetLogGroupId returns the log group ID for external packages +func GetLogGroupId(el *ErrorLogger) int64 { + if el == nil { + return 0 + } + return el.logGroupId +} + +// LogError logs an error to the configured Telegram group +func (el *ErrorLogger) LogError(err error, context string, userInfo ...interface{}) { + if !el.enabled || err == nil { + return + } + + // Filter out empty/ghost errors and irrelevant messages + errorMsg := err.Error() + if errorMsg == "" || errorMsg == `{"message":"","Err":{},"code":0}` { + return // Skip empty/meaningless errors + } + if strings.Contains(errorMsg, "[requirePrivateChatInterceptor]") { + return // Skip logging this specific interceptor error + } + + // Format error message as HTML (like payment error) + htmlEscape := utils.EscapeHTML + timestamp := time.Now().Format("2006-01-02 15:04:05 UTC") + + // User and chat details + userStr := "Unknown" + userId := "" + chatStr := "" + chatId := "" + + for _, info := range userInfo { + switch v := info.(type) { + case *tb.User: + if v.Username != "" { + userStr = "@" + htmlEscape(v.Username) + "" + } else { + userStr = "" + htmlEscape(v.FirstName+" "+v.LastName) + "" + } + userId = fmt.Sprintf("%d", v.ID) + case *tb.Chat: + chatStr = htmlEscape(v.Title) + chatId = fmt.Sprintf("%d", v.ID) + } + } + + msg := "" + msg += "🚫 Bot Error\n\n" + msg += " Context: " + htmlEscape(context) + "\n" + if userStr != "" && userId != "" { + msg += "👤 User: " + userStr + " (ID: " + userId + ")\n" + } + if chatStr != "" && chatId != "" { + msg += "💬 Chat: " + chatStr + " (ID: " + chatId + ")\n" + } + msg += "
\n" + msg += "❗ Error: " + htmlEscape(err.Error()) + "\n" + // Add stack trace location if available + if pc, file, line, ok := runtime.Caller(1); ok { + if strings.Contains(file, "error_logger.go") { + if pc2, file2, line2, ok2 := runtime.Caller(2); ok2 { + pc = pc2 + file = file2 + line = line2 + } + } + funcName := runtime.FuncForPC(pc).Name() + msg += fmt.Sprintf("📍 Location: %s:%d in %s\n", htmlEscape(file), line, htmlEscape(funcName)) + } + msg += "🕒 Time: " + htmlEscape(timestamp) + "\n" + msg += "
" + + go el.sendToTelegramHTML(msg) +} + +// LogPanic logs a panic with stack trace to the Telegram group +func (el *ErrorLogger) LogPanic(panicData interface{}, context string) { + if !el.enabled { + return + } + + // Get stack trace + buf := make([]byte, 4096) + n := runtime.Stack(buf, false) + stackTrace := string(buf[:n]) + + errorMsg := fmt.Sprintf("🚨 *PANIC DETECTED*\n\n"+ + "*Context:* `%s`\n"+ + "*Panic:* `%v`\n\n"+ + "*Stack Trace:*\n```\n%s\n```\n\n"+ + "*Time:* `%s`", + el.escapeMarkdownV2(context), + panicData, + el.truncateStackTrace(stackTrace), + el.escapeMarkdownV2(time.Now().Format("2006-01-02 15:04:05 UTC"))) + + go el.sendToTelegram(errorMsg) +} + +// LogCriticalError logs critical errors that require immediate attention +func (el *ErrorLogger) LogCriticalError(err error, context string, userInfo ...interface{}) { + if !el.enabled || err == nil { + return + } + + errorMsg := "🔥 *CRITICAL ERROR* 🔥\n\n" + el.formatErrorMessage(err, context, userInfo...) + + // Send to Telegram group immediately (not in goroutine for critical errors) + el.sendToTelegram(errorMsg) +} + +// formatErrorMessage creates a formatted error message +func (el *ErrorLogger) formatErrorMessage(err error, context string, userInfo ...interface{}) string { + timestamp := time.Now().Format("2006-01-02 15:04:05 UTC") + + msg := fmt.Sprintf( + "*Time:* `%s`\n"+ + "*Context:* `%s`\n"+ + "*Error Details:*\n"+ + "> %s\n", + el.escapeMarkdownV2(timestamp), + el.escapeMarkdownV2(context), + el.escapeMarkdownV2(err.Error())) + + // Add user information if provided + if len(userInfo) > 0 { + var userDetails []string + for _, info := range userInfo { + switch v := info.(type) { + case *tb.User: + userDetails = append(userDetails, fmt.Sprintf("*User:* %s \\(ID: %d\\)", el.getUserStrV2(v), v.ID)) + case *tb.Chat: + userDetails = append(userDetails, fmt.Sprintf("*Chat:* %s \\(ID: %d\\)", el.escapeMarkdownV2(v.Title), v.ID)) + case string: + userDetails = append(userDetails, v) + default: + userDetails = append(userDetails, fmt.Sprintf("%v", v)) + } + } + if len(userDetails) > 0 { + msg += fmt.Sprintf("\n*Additional Details:*\n%s\n", strings.Join(userDetails, "\n")) + } + } + + // Add stack trace for debugging (limited to 3 most recent calls) + if pc, file, line, ok := runtime.Caller(2); ok { + // if the caller is within this file, step one level further up the stack + if strings.Contains(file, "error_logger.go") { + if pc2, file2, line2, ok2 := runtime.Caller(3); ok2 { + pc = pc2 + file = file2 + line = line2 + } + } + funcName := runtime.FuncForPC(pc).Name() + msg += fmt.Sprintf("\n*Location:* `%s:%d` in `%s`", el.escapeMarkdownV2(file), line, el.escapeMarkdownV2(funcName)) + } + + return msg +} + +// sendToTelegram sends the formatted message to the Telegram group +func (el *ErrorLogger) sendToTelegram(message string) { + if el.bot == nil || el.bot.Telegram == nil { + log.Warnf("[ErrorLogger] Cannot send error log - Telegram bot not initialized") + return + } + + // Create recipient + recipient := &tb.Chat{ID: el.logGroupId} + + // Prepare send options + sendOptions := &tb.SendOptions{ + ParseMode: tb.ModeMarkdownV2, + DisableWebPagePreview: true, + } + + // Add thread ID if specified (for Telegram topics/threads) + if el.threadId > 0 { + sendOptions.ReplyTo = &tb.Message{ID: int(el.threadId)} + } + + // Send message + _, err := el.bot.Telegram.Send(recipient, message, sendOptions) + if err != nil { + log.Errorf("[ErrorLogger] Failed to send error log to Telegram: %v", err) + // Try sending without markdown if parsing fails + if strings.Contains(err.Error(), "parse") { + plainMessage := el.stripMarkdown(message) + plainOptions := &tb.SendOptions{ + DisableWebPagePreview: true, + } + if el.threadId > 0 { + plainOptions.ReplyTo = &tb.Message{ID: int(el.threadId)} + } + _, fallbackErr := el.bot.Telegram.Send(recipient, plainMessage, plainOptions) + if fallbackErr != nil { + log.Errorf("[ErrorLogger] Failed to send plain error log: %v", fallbackErr) + } + } + } +} + +// escapeMarkdown escapes special markdown characters +func (el *ErrorLogger) escapeMarkdown(text string) string { + replacer := strings.NewReplacer( + "_", "\\_", + "*", "\\*", + "`", "\\`", + "[", "\\[", + "]", "\\]", + "(", "\\(", + ")", "\\)", + "~", "\\~", + ">", "\\>", + "#", "\\#", + "+", "\\+", + "-", "\\-", + "=", "\\=", + "|", "\\|", + "{", "\\{", + "}", "\\}", + ".", "\\.", + "!", "\\!", + ) + return replacer.Replace(text) +} + +// escapeMarkdownV2 escapes special MarkdownV2 characters +func (el *ErrorLogger) escapeMarkdownV2(text string) string { + replacer := strings.NewReplacer( + "_", "\\_", + "*", "\\*", + "[", "\\[", + "]", "\\]", + "(", "\\(", + ")", "\\)", + "~", "\\~", + "`", "\\`", + ">", "\\>", + "#", "\\#", + "+", "\\+", + "-", "\\-", + "=", "\\=", + "|", "\\|", + "{", "\\{", + "}", "\\}", + ".", "\\.", + "!", "\\!", + ) + return replacer.Replace(text) +} + +// stripMarkdown removes markdown formatting +func (el *ErrorLogger) stripMarkdown(text string) string { + replacer := strings.NewReplacer( + "**", "", + "*", "", + "`", "", + "_", "", + "~", "", + "```", "", + ) + return replacer.Replace(text) +} + +// truncateStackTrace limits stack trace length for Telegram +func (el *ErrorLogger) truncateStackTrace(stackTrace string) string { + const maxLength = 2000 // Telegram message limit consideration + if len(stackTrace) <= maxLength { + return stackTrace + } + return stackTrace[:maxLength] + "\n... (truncated)" +} + +// getUserStr returns a string representation of a Telegram user +func (el *ErrorLogger) getUserStr(user *tb.User) string { + if user == nil { + return "Unknown" + } + if user.Username != "" { + return "@" + user.Username + } + return fmt.Sprintf("%s %s", user.FirstName, user.LastName) +} + +// getUserStrV2 returns a MarkdownV2 escaped string representation of a Telegram user +func (el *ErrorLogger) getUserStrV2(user *tb.User) string { + if user == nil { + return "Unknown" + } + if user.Username != "" { + return "@" + el.escapeMarkdownV2(user.Username) + } + return fmt.Sprintf("%s %s", el.escapeMarkdownV2(user.FirstName), el.escapeMarkdownV2(user.LastName)) +} + +// LogPaymentError logs payment-related errors with detailed information +func (el *ErrorLogger) LogPaymentError(err error, amount int64, memo, invoice string, user *tb.User) { + if !el.enabled || err == nil { + return + } + + if len(invoice) > 80 { + invoice = invoice[:80] + "..." + } + if memo == "" { + memo = "None" + } + + timestamp := time.Now().Format("2006-01-02 15:04:05 UTC") + + // HTML escaping utility + htmlEscape := utils.EscapeHTML + + userStr := "Unknown" + if user != nil { + if user.Username != "" { + userStr = "@" + htmlEscape(user.Username) + "" + } else { + userStr = "" + htmlEscape(user.FirstName+" "+user.LastName) + "" + } + } + + amountStr := "" + htmlEscape(utils.FormatSats(amount)) + "" + memoStr := htmlEscape(memo) + invoiceStr := htmlEscape(invoice) + + // Compose HTML message + msg := fmt.Sprintf( + "🚫 Payment Error for %s (ID: %d)\n\n"+ + "💰 Amount: %s\n"+ + "📄 Invoice: %s\n"+ + "📝 Memo: %s\n"+ + "
", + userStr, user.ID, amountStr, invoiceStr, memoStr, + ) + + // Expandable details (Telegram does not support true expandable blocks, but blockquote visually separates) + detail := fmt.Sprintf("❗ Error: %s\n", htmlEscape(err.Error())) + + if _, file, line, ok := runtime.Caller(1); ok { + if strings.Contains(file, "error_logger.go") { + if _, file2, line2, ok2 := runtime.Caller(2); ok2 { + file = file2 + line = line2 + } + } + if idx := strings.Index(file, "/internal/"); idx > -1 { + file = file[idx:] + } + detail += fmt.Sprintf("📍 Logged at:\n%s:%d\n(from github.com/LightningTipBot/LightningTipBot)\n", htmlEscape(file), line) + } + detail += fmt.Sprintf("🕒 Time: %s", htmlEscape(timestamp)) + + msg += detail + "
" + + go el.sendToTelegramHTML(msg) +} + +// sendToTelegramHTML sends the formatted HTML message to the Telegram group +func (el *ErrorLogger) sendToTelegramHTML(message string) { + if el.bot == nil || el.bot.Telegram == nil { + log.Warnf("[ErrorLogger] Cannot send error log - Telegram bot not initialized") + return + } + + recipient := &tb.Chat{ID: el.logGroupId} + sendOptions := &tb.SendOptions{ + ParseMode: "HTML", + DisableWebPagePreview: true, + } + if el.threadId > 0 { + sendOptions.ReplyTo = &tb.Message{ID: int(el.threadId)} + } + _, err := el.bot.Telegram.Send(recipient, message, sendOptions) + if err != nil { + log.Errorf("[ErrorLogger] Failed to send HTML error log to Telegram: %v", err) + } +} + +// LogTransactionError logs transaction-related errors with sender/receiver info +func (el *ErrorLogger) LogTransactionError(err error, transactionType string, amount int64, fromUser, toUser *tb.User) { + context := fmt.Sprintf("Transaction Error - Type: %s, Amount: %s", transactionType, utils.FormatSats(amount)) + + var userDetails []string + if fromUser != nil { + userDetails = append(userDetails, fmt.Sprintf("> *From:* %s \\(ID: %d\\)", el.getUserStrV2(fromUser), fromUser.ID)) + } + if toUser != nil { + userDetails = append(userDetails, fmt.Sprintf("> *To:* %s \\(ID: %d\\)", el.getUserStrV2(toUser), toUser.ID)) + } + + transactionDetails := fmt.Sprintf("*Transaction Details:*\n%s\n> *Amount:* `%s`\n> *Transaction Error:* `%s`", + strings.Join(userDetails, "\n"), utils.FormatSats(amount), el.escapeMarkdownV2(err.Error())) + + var logUsers []interface{} + if fromUser != nil { + logUsers = append(logUsers, fromUser) + } + if toUser != nil { + logUsers = append(logUsers, toUser) + } + logUsers = append(logUsers, transactionDetails) + + el.LogError(err, context, logUsers...) +} + +// LogDatabaseError logs database-related errors +func (el *ErrorLogger) LogDatabaseError(err error, operation string, user *tb.User) { + context := fmt.Sprintf("Database Error - Operation: %s", operation) + el.LogError(err, context, user) +} + +// LogAPIError logs API-related errors +func (el *ErrorLogger) LogAPIError(err error, endpoint string, user *tb.User) { + context := fmt.Sprintf("API Error - Endpoint: %s", endpoint) + el.LogError(err, context, user) +} + +// LogLNURLError logs LNURL-related errors with request details +func (el *ErrorLogger) LogLNURLError(err error, operation string, username string, requestDetails map[string]interface{}) { + context := fmt.Sprintf("LNURL Error - Operation: %s, User: %s", operation, username) + + var details []string + for key, value := range requestDetails { + details = append(details, fmt.Sprintf("> *%s:* `%v`", el.escapeMarkdownV2(key), el.escapeMarkdownV2(fmt.Sprintf("%v", value)))) + } + + requestInfo := fmt.Sprintf("*Request Details:*\n%s", strings.Join(details, "\n")) + + el.LogError(err, context, requestInfo) +} diff --git a/internal/telegram/faucet.go b/internal/telegram/faucet.go new file mode 100644 index 00000000..6231e917 --- /dev/null +++ b/internal/telegram/faucet.go @@ -0,0 +1,423 @@ +package telegram + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" + + "github.com/LightningTipBot/LightningTipBot/internal/telegram/intercept" + + "github.com/LightningTipBot/LightningTipBot/internal/runtime/mutex" + "github.com/LightningTipBot/LightningTipBot/internal/runtime/once" + "github.com/LightningTipBot/LightningTipBot/internal/storage" + + "github.com/eko/gocache/store" + + "github.com/LightningTipBot/LightningTipBot/internal/errors" + "github.com/LightningTipBot/LightningTipBot/internal/i18n" + "github.com/LightningTipBot/LightningTipBot/internal/thirdparty" + "github.com/LightningTipBot/LightningTipBot/internal/utils" + + "github.com/LightningTipBot/LightningTipBot/internal/lnbits" + + log "github.com/sirupsen/logrus" + tb "gopkg.in/lightningtipbot/telebot.v3" +) + +var ( + inlineFaucetMenu = &tb.ReplyMarkup{ResizeKeyboard: true} + btnCancelInlineFaucet = inlineFaucetMenu.Data("🚫 Cancel", "cancel_faucet_inline") + btnAcceptInlineFaucet = inlineFaucetMenu.Data("✅ Collect", "confirm_faucet_inline") +) + +type InlineFaucet struct { + *storage.Base + Message string `json:"inline_faucet_message"` + Amount int64 `json:"inline_faucet_amount"` + RemainingAmount int64 `json:"inline_faucet_remainingamount"` + PerUserAmount int64 `json:"inline_faucet_peruseramount"` + From *lnbits.User `json:"inline_faucet_from"` + To []*lnbits.User `json:"inline_faucet_to"` + Memo string `json:"inline_faucet_memo"` + NTotal int `json:"inline_faucet_ntotal"` + NTaken int `json:"inline_faucet_ntaken"` + UserNeedsWallet bool `json:"inline_faucet_userneedswallet"` + LanguageCode string `json:"languagecode"` +} + +func (bot TipBot) mapFaucetLanguage(ctx context.Context, command string) context.Context { + if len(strings.Split(command, " ")) > 1 { + c := strings.Split(command, " ")[0][1:] // cut the / + ctx = bot.commandTranslationMap(ctx, c) + } + return ctx +} + +func (bot TipBot) createFaucet(ctx context.Context, text string, sender *tb.User) (*InlineFaucet, error) { + amount, err := decodeAmountFromCommand(text) + if err != nil { + return nil, errors.New(errors.DecodeAmountError, err) + } + peruserStr, err := getArgumentFromCommand(text, 2) + if err != nil { + return nil, errors.New(errors.DecodePerUserAmountError, err) + } + perUserAmount, err := GetAmount(peruserStr) + if err != nil { + return nil, errors.New(errors.InvalidAmountError, err) + } + if perUserAmount < 5 || amount%perUserAmount != 0 { + return nil, errors.New(errors.InvalidAmountPerUserError, fmt.Errorf("invalid amount per user")) + } + nTotal := int(amount / perUserAmount) + fromUser := LoadUser(ctx) + fromUserStr := GetUserStr(sender) + balance, err := bot.GetUserBalanceCached(fromUser) + if err != nil { + return nil, errors.New(errors.GetBalanceError, err) + } + // check if fromUser has balance + if balance < amount { + return nil, errors.New(errors.BalanceToLowError, fmt.Errorf("[faucet] Balance of user %s too low", fromUserStr)) + } + // // check for memo in command + memo := GetMemoFromCommand(text, 3) + + inlineMessage := fmt.Sprintf(Translate(ctx, "inlineFaucetMessage"), thirdparty.FormatSatsWithLKR(perUserAmount), GetUserStrMd(sender), thirdparty.FormatSatsWithLKR(amount), thirdparty.FormatSatsWithLKR(amount), 0, nTotal, MakeProgressbar(amount, amount)) + if len(memo) > 0 { + inlineMessage = inlineMessage + fmt.Sprintf(Translate(ctx, "inlineFaucetAppendMemo"), memo) + } + id := fmt.Sprintf("faucet:%s:%d", RandStringRunes(10), amount) + + return &InlineFaucet{ + Base: storage.New(storage.ID(id)), + Message: inlineMessage, + Amount: amount, + From: fromUser, + Memo: memo, + PerUserAmount: perUserAmount, + NTotal: nTotal, + NTaken: 0, + RemainingAmount: amount, + UserNeedsWallet: false, + LanguageCode: ctx.Value("publicLanguageCode").(string), + }, nil + +} +func (bot TipBot) makeFaucet(ctx context.Context, m *tb.Message, query bool) (*InlineFaucet, error) { + faucet, err := bot.createFaucet(ctx, m.Text, m.Sender) + if err != nil { + switch err.(errors.TipBotError).Code { + case errors.DecodeAmountError: + bot.trySendMessage(m.Sender, fmt.Sprintf(Translate(ctx, "inlineFaucetHelpText"), Translate(ctx, "inlineFaucetInvalidAmountMessage"))) + bot.tryDeleteMessage(m) + return nil, err + case errors.DecodePerUserAmountError: + bot.trySendMessage(m.Sender, fmt.Sprintf(Translate(ctx, "inlineFaucetHelpText"), "")) + bot.tryDeleteMessage(m) + return nil, err + case errors.InvalidAmountError: + bot.trySendMessage(m.Sender, fmt.Sprintf(Translate(ctx, "inlineFaucetHelpText"), Translate(ctx, "inlineFaucetInvalidAmountMessage"))) + bot.tryDeleteMessage(m) + return nil, err + case errors.InvalidAmountPerUserError: + bot.trySendMessage(m.Sender, fmt.Sprintf(Translate(ctx, "inlineFaucetHelpText"), Translate(ctx, "inlineFaucetInvalidPeruserAmountMessage"))) + bot.tryDeleteMessage(m) + return nil, err + case errors.GetBalanceError: + // log.Errorln(err.Error()) + bot.tryDeleteMessage(m) + return nil, err + case errors.BalanceToLowError: + // log.Errorf(err.Error()) + bot.trySendMessage(m.Sender, Translate(ctx, "inlineSendBalanceLowMessage")) + bot.tryDeleteMessage(m) + return nil, err + } + } + return faucet, err +} + +func (bot TipBot) makeQueryFaucet(ctx intercept.Context) (*InlineFaucet, error) { + faucet, err := bot.createFaucet(ctx, ctx.Query().Text, ctx.Query().Sender) + if err != nil { + switch err.(errors.TipBotError).Code { + case errors.DecodeAmountError: + bot.inlineQueryReplyWithError(ctx, TranslateUser(ctx, "inlineQueryFaucetTitle"), fmt.Sprintf(TranslateUser(ctx, "inlineQueryFaucetDescription"), bot.Telegram.Me.Username)) + return nil, err + case errors.DecodePerUserAmountError: + bot.inlineQueryReplyWithError(ctx, TranslateUser(ctx, "inlineQueryFaucetTitle"), fmt.Sprintf(TranslateUser(ctx, "inlineQueryFaucetDescription"), bot.Telegram.Me.Username)) + return nil, err + case errors.InvalidAmountError: + bot.inlineQueryReplyWithError(ctx, TranslateUser(ctx, "inlineSendInvalidAmountMessage"), fmt.Sprintf(TranslateUser(ctx, "inlineQueryFaucetDescription"), bot.Telegram.Me.Username)) + return nil, err + case errors.InvalidAmountPerUserError: + bot.inlineQueryReplyWithError(ctx, TranslateUser(ctx, "inlineFaucetInvalidPeruserAmountMessage"), fmt.Sprintf(TranslateUser(ctx, "inlineQueryFaucetDescription"), bot.Telegram.Me.Username)) + return nil, err + case errors.GetBalanceError: + bot.inlineQueryReplyWithError(ctx, TranslateUser(ctx, "inlineQueryFaucetTitle"), fmt.Sprintf(TranslateUser(ctx, "inlineQueryFaucetDescription"), bot.Telegram.Me.Username)) + return nil, err + case errors.BalanceToLowError: + log.Errorf(err.Error()) + bot.inlineQueryReplyWithError(ctx, TranslateUser(ctx, "inlineSendBalanceLowMessage"), fmt.Sprintf(TranslateUser(ctx, "inlineQueryFaucetDescription"), bot.Telegram.Me.Username)) + return nil, err + } + } + return faucet, err +} + +func (bot TipBot) makeFaucetKeyboard(ctx context.Context, id string) *tb.ReplyMarkup { + inlineFaucetMenu := &tb.ReplyMarkup{ResizeKeyboard: true} + acceptInlineFaucetButton := inlineFaucetMenu.Data(Translate(ctx, "collectButtonMessage"), "confirm_faucet_inline", id) + cancelInlineFaucetButton := inlineFaucetMenu.Data(Translate(ctx, "cancelButtonMessage"), "cancel_faucet_inline", id) + inlineFaucetMenu.Inline( + inlineFaucetMenu.Row( + acceptInlineFaucetButton, + cancelInlineFaucetButton), + ) + return inlineFaucetMenu +} + +func (bot TipBot) faucetHandler(ctx intercept.Context) (intercept.Context, error) { + bot.anyTextHandler(ctx) + if ctx.Message().Private() { + bot.trySendMessage(ctx.Message().Sender, fmt.Sprintf(Translate(ctx, "inlineFaucetHelpText"), Translate(ctx, "inlineFaucetHelpFaucetInGroup"))) + return ctx, errors.Create(errors.NoPrivateChatError) + } + ctx.Context = bot.mapFaucetLanguage(ctx, ctx.Text()) + inlineFaucet, err := bot.makeFaucet(ctx, ctx.Message(), false) + if err != nil { + log.Warnf("[faucet] %s", err.Error()) + return ctx, err + } + fromUserStr := GetUserStr(ctx.Message().Sender) + mFaucet := bot.trySendMessage(ctx.Message().Chat, inlineFaucet.Message, bot.makeFaucetKeyboard(ctx, inlineFaucet.ID)) + log.Infof("[faucet] %s created faucet %s: %d sat(s) (%d per user)", fromUserStr, inlineFaucet.ID, inlineFaucet.Amount, inlineFaucet.PerUserAmount) + + // log faucet link if possible + if mFaucet != nil && mFaucet.Chat != nil { + log.Infof("[faucet] Link: https://t.me/c/%s/%d", strconv.FormatInt(mFaucet.Chat.ID, 10)[4:], mFaucet.ID) + } + return ctx, inlineFaucet.Set(inlineFaucet, bot.Bunt) +} + +func (bot TipBot) handleInlineFaucetQuery(ctx intercept.Context) (intercept.Context, error) { + inlineFaucet, err := bot.makeQueryFaucet(ctx) + if err != nil { + log.Errorf("[handleInlineFaucetQuery] %s", err.Error()) + return ctx, err + } + urls := []string{ + queryImage, + } + results := make(tb.Results, len(urls)) // []tb.Result + for i, url := range urls { + result := &tb.ArticleResult{ + // URL: url, + Text: inlineFaucet.Message, + Title: fmt.Sprintf(TranslateUser(ctx, "inlineResultFaucetTitle"), thirdparty.FormatSatsWithLKR(inlineFaucet.Amount)), + Description: TranslateUser(ctx, "inlineResultFaucetDescription"), + // required for photos + ThumbURL: url, + } + result.ReplyMarkup = &tb.ReplyMarkup{InlineKeyboard: bot.makeFaucetKeyboard(ctx, inlineFaucet.ID).InlineKeyboard} + results[i] = result + // needed to set a unique string ID for each result + results[i].SetResultID(inlineFaucet.ID) + + bot.Cache.Set(inlineFaucet.ID, inlineFaucet, &store.Options{Expiration: 5 * time.Minute}) + log.Infof("[faucet] %s:%d created inline faucet %s: %d sat(s) (%d per user)", GetUserStr(inlineFaucet.From.Telegram), inlineFaucet.From.Telegram.ID, inlineFaucet.ID, inlineFaucet.Amount, inlineFaucet.PerUserAmount) + } + + err = bot.Telegram.Answer(ctx.Query(), &tb.QueryResponse{ + Results: results, + CacheTime: 1, + }) + if err != nil { + log.Errorln(err.Error()) + return ctx, err + } + return ctx, nil +} + +func (bot *TipBot) acceptInlineFaucetHandler(ctx intercept.Context) (intercept.Context, error) { + c := ctx.Callback() + to := LoadUser(ctx) + tx := &InlineFaucet{Base: storage.New(storage.ID(c.Data))} + mutex.LockWithContext(ctx, tx.ID) + defer mutex.UnlockWithContext(ctx, tx.ID) + fn, err := tx.Get(tx, bot.Bunt) + if err != nil { + log.Errorf("[acceptInlineFaucetHandler] c.Data: %s, Error: %s", c.Data, err.Error()) + return ctx, err + } + log.Tracef("[acceptInlineFaucetHandler] Callback c.Data: %s tx.ID: %s", c.Data, tx.ID) + + inlineFaucet := fn.(*InlineFaucet) + from := inlineFaucet.From + // failsafe for queued users + if !inlineFaucet.Active { + log.Tracef(fmt.Sprintf("[faucet] faucet %s inactive. Remaining: %d sat(s)", inlineFaucet.ID, inlineFaucet.RemainingAmount)) + bot.finishFaucet(ctx, c, inlineFaucet) + return ctx, errors.Create(errors.NotActiveError) + } + // log faucet link if possible + if c.Message != nil && c.Message.Chat != nil { + log.Infof("[faucet] Link: https://t.me/c/%s/%d", strconv.FormatInt(c.Message.Chat.ID, 10)[4:], c.Message.ID) + } + + if from.Telegram.ID == to.Telegram.ID { + log.Debugf("[faucet] %s is the owner faucet %s", GetUserStr(to.Telegram), inlineFaucet.ID) + ctx.Context = context.WithValue(ctx, "callback_response", Translate(ctx, "sendYourselfMessage")) + return ctx, errors.Create(errors.SelfPaymentError) + } + // check if to user has already taken from the faucet + for _, a := range inlineFaucet.To { + if a.Telegram.ID == to.Telegram.ID { + // to user is already in To slice, has taken from facuet + log.Debugf("[faucet] %s:%d already took from faucet %s", GetUserStr(to.Telegram), to.Telegram.ID, inlineFaucet.ID) + ctx.Context = context.WithValue(ctx, "callback_response", Translate(ctx, "inlineFaucetAlreadyTookMessage")) + return ctx, errors.Create(errors.UnknownError) + } + } + + defer inlineFaucet.Set(inlineFaucet, bot.Bunt) + + if inlineFaucet.RemainingAmount >= inlineFaucet.PerUserAmount { + toUserStrMd := GetUserStrMd(to.Telegram) + fromUserStrMd := GetUserStrMd(from.Telegram) + toUserStr := GetUserStr(to.Telegram) + fromUserStr := GetUserStr(from.Telegram) + // check if user exists and create a wallet if not + _, exists := bot.UserExists(to.Telegram) + if !exists { + to, err = bot.CreateWalletForTelegramUser(to.Telegram) + if err != nil { + errmsg := fmt.Errorf("[faucet] Error: Could not create wallet for %s", toUserStr) + log.Errorln(errmsg) + return ctx, err + } + } + + if !to.Initialized { + inlineFaucet.UserNeedsWallet = true + } + + // todo: user new get username function to get userStrings + transactionMemo := fmt.Sprintf("🚰 Faucet from %s to %s.", fromUserStr, toUserStr) + t := NewTransaction(bot, from, to, inlineFaucet.PerUserAmount, TransactionType("faucet")) + t.Memo = transactionMemo + + success, err := t.Send() + if !success { + // bot.trySendMessage(from.Telegram, Translate(ctx, "sendErrorMessage")) + errMsg := fmt.Sprintf("[faucet] Transaction failed: %s", err.Error()) + log.Warnln(errMsg) + ctx.Context = context.WithValue(ctx, "callback_response", Translate(ctx, "errorTryLaterMessage")) + // if faucet fails, cancel it: + // c.Sender.ID = inlineFaucet.From.Telegram.ID // overwrite the sender of the callback to be the faucet owner + // log.Debugf("[faucet] Canceling faucet %s...", inlineFaucet.ID) + // bot.cancelInlineFaucet(ctx, c, true) // cancel without ID check + bot.finishFaucet(ctx, c, inlineFaucet) + return ctx, errors.New(errors.UnknownError, err) + } + + log.Infof("[💸 faucet] Faucet %s from %s to %s:%d (%d sat).", inlineFaucet.ID, fromUserStr, toUserStr, to.Telegram.ID, inlineFaucet.PerUserAmount) + inlineFaucet.NTaken += 1 + inlineFaucet.To = append(inlineFaucet.To, to) + inlineFaucet.RemainingAmount = inlineFaucet.RemainingAmount - inlineFaucet.PerUserAmount + go func() { + to_message := fmt.Sprintf(i18n.Translate(to.Telegram.LanguageCode, "inlineFaucetReceivedMessage"), fromUserStrMd, thirdparty.FormatSatsWithLKR(inlineFaucet.PerUserAmount)) + ctx.Context = context.WithValue(ctx, "callback_response", to_message) + bot.trySendMessage(to.Telegram, to_message) + bot.trySendMessage(from.Telegram, fmt.Sprintf(i18n.Translate(from.Telegram.LanguageCode, "inlineFaucetSentMessage"), thirdparty.FormatSatsWithLKR(inlineFaucet.PerUserAmount), toUserStrMd)) + }() + + // build faucet message + inlineFaucet.Message = fmt.Sprintf(i18n.Translate(inlineFaucet.LanguageCode, "inlineFaucetMessage"), thirdparty.FormatSatsWithLKR(inlineFaucet.PerUserAmount), GetUserStrMd(inlineFaucet.From.Telegram), thirdparty.FormatSatsWithLKR(inlineFaucet.RemainingAmount), thirdparty.FormatSatsWithLKR(inlineFaucet.Amount), inlineFaucet.NTaken, inlineFaucet.NTotal, MakeProgressbar(inlineFaucet.RemainingAmount, inlineFaucet.Amount)) + memo := inlineFaucet.Memo + if len(memo) > 0 { + inlineFaucet.Message = inlineFaucet.Message + fmt.Sprintf(i18n.Translate(inlineFaucet.LanguageCode, "inlineFaucetAppendMemo"), memo) + } + if inlineFaucet.UserNeedsWallet { + inlineFaucet.Message += "\n\n" + fmt.Sprintf(i18n.Translate(inlineFaucet.LanguageCode, "inlineFaucetCreateWalletMessage"), GetUserStr(bot.Telegram.Me)) + } + // update message + log.Infoln(inlineFaucet.Message) + + // update the message if the faucet still has some sats left after this tx + if inlineFaucet.RemainingAmount >= inlineFaucet.PerUserAmount { + bot.tryEditStack(c, inlineFaucet.ID, inlineFaucet.Message, bot.makeFaucetKeyboard(ctx, inlineFaucet.ID)) + } + } + if inlineFaucet.RemainingAmount < inlineFaucet.PerUserAmount { + log.Debugf(fmt.Sprintf("[faucet] faucet %s empty. Remaining: %d sat", inlineFaucet.ID, inlineFaucet.RemainingAmount)) + // faucet is depleted + bot.finishFaucet(ctx, c, inlineFaucet) + } + return ctx, nil +} + +func (bot *TipBot) cancelInlineFaucet(ctx context.Context, c *tb.Callback, ignoreID bool) (context.Context, error) { + tx := &InlineFaucet{Base: storage.New(storage.ID(c.Data))} + mutex.LockWithContext(ctx, tx.ID) + defer mutex.UnlockWithContext(ctx, tx.ID) + fn, err := tx.Get(tx, bot.Bunt) + if err != nil { + log.Debugf("[cancelInlineFaucetHandler] %s", err.Error()) + return ctx, err + } + + inlineFaucet := fn.(*InlineFaucet) + if ignoreID || c.Sender.ID == inlineFaucet.From.Telegram.ID { + faucet_cancelled_message := i18n.Translate(inlineFaucet.LanguageCode, "inlineFaucetCancelledMessage") + bot.tryEditStack(c, inlineFaucet.ID, faucet_cancelled_message, &tb.ReplyMarkup{}) + ctx = context.WithValue(ctx, "callback_response", faucet_cancelled_message) + // set the inlineFaucet inactive + inlineFaucet.Active = false + inlineFaucet.Canceled = true + err = inlineFaucet.Set(inlineFaucet, bot.Bunt) + if err != nil { + return ctx, err + } + log.Debugf("[faucet] Faucet %s canceled.", inlineFaucet.ID) + once.Remove(inlineFaucet.ID) + } + return ctx, nil +} + +func (bot *TipBot) finishFaucet(ctx context.Context, c *tb.Callback, inlineFaucet *InlineFaucet) { + inlineFaucet.Message = fmt.Sprintf(i18n.Translate(inlineFaucet.LanguageCode, "inlineFaucetEndedMessage"), thirdparty.FormatSatsWithLKR(inlineFaucet.Amount), inlineFaucet.NTaken) + if inlineFaucet.UserNeedsWallet { + inlineFaucet.Message += "\n\n" + fmt.Sprintf(i18n.Translate(inlineFaucet.LanguageCode, "inlineFaucetCreateWalletMessage"), GetUserStrMd(bot.Telegram.Me)) + } + bot.tryEditStack(c, inlineFaucet.ID, inlineFaucet.Message, &tb.ReplyMarkup{}) + + log.Debugf("[faucet] Faucet finished %s", inlineFaucet.ID) + once.Remove(inlineFaucet.ID) + // send update to faucet creator + if inlineFaucet.Active && inlineFaucet.From.Telegram.ID != 0 { + bot.trySendMessage(inlineFaucet.From.Telegram, listFaucetTakers(inlineFaucet)) + } + inlineFaucet.Active = false +} + +func listFaucetTakers(inlineFaucet *InlineFaucet) string { + var to_str string + to_str = fmt.Sprintf("🚰 *Faucet summary*\n\nMemo: %s\nCapacity: %s\nTakers: %d\nRemaining: %s\n\n*Takers:*\n\n", inlineFaucet.Memo, utils.FormatSats(inlineFaucet.Amount), inlineFaucet.NTaken, utils.FormatSats(inlineFaucet.RemainingAmount)) + to_str += "```\n" + for _, to := range inlineFaucet.To { + to_str += fmt.Sprintf("%s\n", GetUserStr(to.Telegram)) + } + to_str += "```" + return to_str +} + +func (bot *TipBot) cancelInlineFaucetHandler(ctx intercept.Context) (intercept.Context, error) { + var err error + ctx.Context, err = bot.cancelInlineFaucet(ctx, ctx.Callback(), false) + return ctx, err + +} diff --git a/internal/telegram/files.go b/internal/telegram/files.go new file mode 100644 index 00000000..bcbadd30 --- /dev/null +++ b/internal/telegram/files.go @@ -0,0 +1,35 @@ +package telegram + +import ( + "github.com/LightningTipBot/LightningTipBot/internal/errors" + "github.com/LightningTipBot/LightningTipBot/internal/runtime" + "github.com/LightningTipBot/LightningTipBot/internal/telegram/intercept" + tb "gopkg.in/lightningtipbot/telebot.v3" + "time" +) + +func (bot *TipBot) fileHandler(ctx intercept.Context) (intercept.Context, error) { + m := ctx.Message() + if m.Chat.Type != tb.ChatPrivate { + return ctx, errors.Create(errors.NoPrivateChatError) + } + user := LoadUser(ctx) + if c := stateCallbackMessage[user.StateKey]; c != nil { + // found ctx for this state + // now looking for user state reset ticker + ticker := runtime.GetFunction(user.ID, runtime.WithTicker(time.NewTicker(runtime.DefaultTickerDuration))) + if !ticker.Started { + ticker.Do(func() { + ResetUserState(user, bot) + // removing ticker asap done + bot.shopViewDeleteAllStatusMsgs(ctx, user) + runtime.RemoveTicker(user.ID) + }) + } else { + ticker.ResetChan <- struct{}{} + } + + return c(ctx) + } + return ctx, errors.Create(errors.NoFileFoundError) +} diff --git a/internal/telegram/generate.go b/internal/telegram/generate.go new file mode 100644 index 00000000..03c6a03d --- /dev/null +++ b/internal/telegram/generate.go @@ -0,0 +1,263 @@ +package telegram + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + "strings" + "time" + + "github.com/LightningTipBot/LightningTipBot/internal" + "github.com/LightningTipBot/LightningTipBot/internal/dalle" + "github.com/LightningTipBot/LightningTipBot/internal/lnbits" + "github.com/LightningTipBot/LightningTipBot/internal/runtime" + "github.com/LightningTipBot/LightningTipBot/internal/telegram/intercept" + log "github.com/sirupsen/logrus" + "github.com/skip2/go-qrcode" + tb "gopkg.in/lightningtipbot/telebot.v3" +) + +// generateImages is called when the user enters /generate or /generate +// asks the user for a prompt if not given +func (bot *TipBot) generateImages(ctx intercept.Context) (intercept.Context, error) { + if !dalle.Enabled { + bot.trySendMessage(ctx.Message().Sender, "🤖💤 Dalle image generation is currently not available. Please try again later.") + return ctx, nil + } + bot.anyTextHandler(ctx) + user := LoadUser(ctx) + if user.Wallet == nil { + return ctx, fmt.Errorf("user has no wallet") + } + + if len(strings.Split(ctx.Message().Text, " ")) < 2 { + // We need to save the pay state in the user state so we can load the payment in the next handler + SetUserState(user, bot, lnbits.UserEnterDallePrompt, "") + bot.trySendMessage(ctx.Message().Sender, "⌨️ Enter image prompt.", tb.ForceReply) + return ctx, nil + } + // write the prompt into the command and call confirm + m := ctx.Message() + m.Text = GetMemoFromCommand(m.Text, 1) + return bot.confirmGenerateImages(ctx) +} + +// confirmGenerateImages is called when the user has entered a prompt through /generate +// or because he answered to the request to enter it in generateImages() +// confirmGenerateImages will create an invoice that the user can pay and if they pay +// generateDalleImages will fetch the images and send it to the user +func (bot *TipBot) confirmGenerateImages(ctx intercept.Context) (intercept.Context, error) { + user := LoadUser(ctx) + + ResetUserState(user, bot) + m := ctx.Message() + prompt := m.Text + if len(prompt) == 0 { + return ctx, fmt.Errorf("prompt not given") + } + + if user.Wallet == nil { + return ctx, fmt.Errorf("user has no wallet") + } + me, err := GetUser(bot.Telegram.Me, *bot) + if err != nil { + return ctx, err + } + invoice, err := bot.createInvoiceWithEvent(ctx, me, internal.Configuration.Generate.DallePrice, fmt.Sprintf("DALLE2 %s", GetUserStr(user.Telegram)), "", InvoiceCallbackGenerateDalle, prompt) + invoice.Payer = user + if err != nil { + return ctx, err + } + + runtime.IgnoreError(bot.Bunt.Set(invoice)) + + balance, err := bot.GetUserBalance(user) + if err != nil { + errmsg := fmt.Sprintf("[inlineReceive] Error: Could not get user balance: %s", err.Error()) + log.Warnln(errmsg) + } + + bot.trySendMessage(ctx.Message().Sender, Translate(ctx, "generateDallePayInvoiceMessage")) + + // invoke internal pay if enough balance + if balance >= internal.Configuration.Generate.DallePrice { + m.Text = fmt.Sprintf("/pay %s", invoice.PaymentRequest) + return bot.payHandler(ctx) + } + + // create qr code + qr, err := qrcode.Encode(invoice.PaymentRequest, qrcode.Medium, 256) + if err != nil { + bot.tryEditMessage(invoice.Message, Translate(ctx, "errorTryLaterMessage")) + return ctx, err + } + + // send the invoice data to user + msg := bot.trySendMessage(ctx.Message().Sender, &tb.Photo{File: tb.File{FileReader: bytes.NewReader(qr)}, Caption: fmt.Sprintf("`%s`", invoice.PaymentRequest)}) + invoice.InvoiceMessage = msg + runtime.IgnoreError(bot.Bunt.Set(invoice)) + return ctx, nil +} + +var jobChan chan func(workerId int) +var workers = internal.Configuration.Generate.Worker + +func init() { + if workers == 0 { + log.Printf("Dalle is disabled. No worker started.") + return + } + log.Printf("Starting Dalle image generation. Worker: %d, Price: %d sat(s)", workers, internal.Configuration.Generate.DallePrice) + jobChan = make(chan func(workerId int), workers) + for i := 0; i < workers; i++ { + go worker(jobChan, i) + } +} +func worker(linkChan chan func(workerId int), workerId int) { + for generatePrompt := range linkChan { + generatePrompt(workerId) + } +} + +// generateDalleImages is called by the invoice event when the user has paid +func (bot *TipBot) generateDalleImages(event Event) { + invoiceEvent := event.(*InvoiceEvent) + user := invoiceEvent.Payer + if user == nil || user.Wallet == nil { + log.Errorf("[generateDalleImages] invalid user") + return + } + bot.trySendMessage(user.Telegram, "🔄 Your images are being generated. Please wait a few moments.") + var job = func(workerId int) { + // create the client with the bearer token api key + dalleClient, err := dalle.NewHTTPClient(internal.Configuration.Generate.DalleKey) + // handle err + if err != nil { + log.Errorf("[NewHTTPClient-%d] %v", workerId, err.Error()) + bot.dalleRefundUser(user, "") + return + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Minute*15) + defer cancel() + // generate a task to create an image with a prompt + task, err := dalleClient.Generate(ctx, invoiceEvent.CallbackData) + if err != nil { + log.Errorf("[Generate-%d] %v", workerId, err.Error()) + bot.dalleRefundUser(user, "") + return + } + // poll the task.ID until status is succeeded + var t *dalle.Task + timeout := time.After(10 * time.Minute) + ticker := time.Tick(5 * time.Second) + // Keep trying until we're timed out or get a result/error + for { + select { + case <-ctx.Done(): + bot.dalleRefundUser(user, "") + log.Errorf("[DALLE-%d] ctx done. Task %s", workerId, task.ID) + return + // Got a timeout! fail with a timeout error + case <-timeout: + bot.dalleRefundUser(user, "Timeout. Please try again later.") + log.Errorf("[DALLE-%d] timeout. Task: %s", workerId, task.ID) + return + // Got a tick, we should check on checkSomething() + case <-ticker: + t, err = dalleClient.GetTask(ctx, task.ID) + // handle err + if err != nil { + log.Errorf("[GetTask-%d] Task: %s. Error: %v", workerId, task.ID, err.Error()) + //bot.dalleRefundUser(user, "") + continue + } + if t.Status == dalle.StatusSucceeded { + log.Printf("[DALLE-%d] 🎆 task succeeded for user %s", workerId, GetUserStr(user.Telegram)) + // download the first generated image + for _, data := range t.Generations.Data { + err = bot.downloadAndSendImages(ctx, dalleClient, data, invoiceEvent) + if err != nil { + log.Errorf("[downloadAndSendImages-%d] Id: %s. Error: %v", workerId, data.ID, err.Error()) + } + } + return + + } else if t.Status == dalle.StatusRejected { + log.Errorf("[DALLE-%d] rejected: %s", workerId, t.ID) + bot.dalleRefundUser(user, "Your prompt has been rejected by OpenAI. Do not use celebrity names, sexual expressions, or any other harmful content as prompt.") + return + } + log.Debugf("[DALLE-%d] pending for user %s", workerId, GetUserStr(user.Telegram)) + } + } + } + jobChan <- job +} + +// downloadAndSendImages will download dalle images and send them to the payer. +func (bot *TipBot) downloadAndSendImages(ctx context.Context, dalleClient dalle.Client, data dalle.GenerationData, event *InvoiceEvent) error { + reader, err := dalleClient.Download(ctx, data.ID) + if err != nil { + return err + } + defer reader.Close() + image := "data/dalle/" + data.ID + ".png" + file, err := os.Create(image) + if err != nil { + return err + } + defer file.Close() + _, err = io.Copy(file, reader) + if err != nil { + return err + } + f, err := os.OpenFile(image, 0, os.ModePerm) + if err != nil { + return err + } + defer f.Close() + bot.trySendMessage(event.Payer.Telegram, &tb.Photo{File: tb.File{FileReader: f}}) + return nil +} + +func (bot *TipBot) dalleRefundUser(user *lnbits.User, message string) error { + if user.Wallet == nil { + return fmt.Errorf("user has no wallet") + } + me, err := GetUser(bot.Telegram.Me, *bot) + if err != nil { + return err + } + + // create invioce for user + invoice, err := user.Wallet.Invoice( + lnbits.InvoiceParams{ + Out: false, + Amount: int64(internal.Configuration.Generate.DallePrice), + Memo: fmt.Sprintf("Refund DALLE2 %s", GetUserStr(user.Telegram)), + Webhook: internal.GetWebhookURL()}, + bot.Client) + if err != nil { + return err + } + + // pay invoice + _, err = me.Wallet.Pay(lnbits.PaymentParams{Out: true, Bolt11: invoice.PaymentRequest}, bot.Client) + if err != nil { + log.Errorln(err) + return err + } + log.Warnf("[DALLE] refunding user %s with %d sat(s)", GetUserStr(user.Telegram), internal.Configuration.Generate.DallePrice) + + var err_reason string + if len(message) > 0 { + err_reason = message + } else { + err_reason = "Something went wrong." + } + bot.trySendMessage(user.Telegram, fmt.Sprintf("🚫 %s You have been refunded.", err_reason)) + return nil +} diff --git a/internal/telegram/gpt.go b/internal/telegram/gpt.go new file mode 100644 index 00000000..bd077344 --- /dev/null +++ b/internal/telegram/gpt.go @@ -0,0 +1,73 @@ +package telegram + +import ( + "fmt" + "github.com/LightningTipBot/LightningTipBot/internal/gpt" + "github.com/LightningTipBot/LightningTipBot/internal/telegram/intercept" + uuid "github.com/satori/go.uuid" + log "github.com/sirupsen/logrus" + "gopkg.in/lightningtipbot/telebot.v3" +) + +func (bot *TipBot) gptHandler(ctx intercept.Context) (intercept.Context, error) { + question := GetMemoFromCommand(ctx.Text(), 1) + req := gpt.Request{ + Action: "next", + Model: "text-davinci-002-render", + Messages: []gpt.Messages{{ + ID: uuid.NewV4().String(), + Role: "user", + Content: gpt.Content{ + ContentType: "text", + Parts: []string{question}, + }, + }, + }, + } + + conversationIdCacheKey := fmt.Sprintf("conversation_%d", ctx.Chat().ID) + parentIdCacheKey := fmt.Sprintf("conversation_parent_%d", ctx.Chat().ID) + conversationId, _ := bot.Cache.Get(conversationIdCacheKey) + + if parentId, _ := bot.Cache.Get(parentIdCacheKey); parentId != nil { + req.ParentMessageID = parentId.(string) + } + if conversationId != nil { + req.ConversationId = conversationId.(string) + } + cbc := 0 + var msg *telebot.Message + completion, err := gpt.GetRawCompletion(ctx, req, func(s string) { + cbc++ + if ctx.Chat().Type == telebot.ChatPrivate { + if cbc == 20 { + msg = bot.trySendMessageEditable(ctx.Sender(), s) + return + } + } else { + if cbc == 20 { + msg = bot.tryReplyMessage(ctx.Message(), s) + return + } + } + if cbc%20 == 0 { + bot.tryEditMessage(msg, s) + } + }) + if err != nil { + bot.tryEditMessage(ctx.Message(), fmt.Sprintf(Translate(ctx, "errorReasonMessage"), "Could not create completion.")) + return ctx, err + } + answer := completion.Message.Content.Parts[len(completion.Message.Content.Parts)-1] + bot.tryEditMessage(msg, answer) + err = bot.Cache.Set(conversationIdCacheKey, completion.ConversationID, nil) + if err != nil { + log.Errorf("[/gpt] error setting conversation id %s: %v", conversationIdCacheKey, err) + } + err = bot.Cache.Set(parentIdCacheKey, completion.Message.ID, nil) + if err != nil { + log.Errorf("[/gpt] error setting parent message id %s: %v", parentIdCacheKey, err) + } + log.Infof("[/gpt] \"%s\" => \"%s\"", question, answer) + return ctx, nil +} diff --git a/internal/telegram/groups.go b/internal/telegram/groups.go new file mode 100644 index 00000000..c771cf50 --- /dev/null +++ b/internal/telegram/groups.go @@ -0,0 +1,619 @@ +package telegram + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "strconv" + "strings" + "time" + + "github.com/LightningTipBot/LightningTipBot/internal/telegram/intercept" + + "github.com/LightningTipBot/LightningTipBot/internal" + "github.com/LightningTipBot/LightningTipBot/internal/errors" + "github.com/LightningTipBot/LightningTipBot/internal/i18n" + "github.com/LightningTipBot/LightningTipBot/internal/lnbits" + "github.com/LightningTipBot/LightningTipBot/internal/runtime" + "github.com/LightningTipBot/LightningTipBot/internal/runtime/mutex" + "github.com/LightningTipBot/LightningTipBot/internal/storage" + "github.com/LightningTipBot/LightningTipBot/internal/str" + "github.com/LightningTipBot/LightningTipBot/internal/thirdparty" + "github.com/LightningTipBot/LightningTipBot/internal/utils" + log "github.com/sirupsen/logrus" + "github.com/skip2/go-qrcode" + tb "gopkg.in/lightningtipbot/telebot.v3" +) + +type JoinTicket struct { + Sender *tb.User `json:"sender"` + Message *tb.Message `json:"message"` + CreatedTimestamp time.Time `json:"created_timestamp"` + Ticket *Ticket `json:"ticket"` +} + +func (jt JoinTicket) Key() string { + return fmt.Sprintf("join-ticket:%d_%d", jt.Message.Chat.ID, jt.Sender.ID) +} + +func (jt JoinTicket) Type() EventType { + return EventTypeTicketInvoice +} + +func (bot *TipBot) loadGroup(groupName string) (*Group, error) { + group := &Group{} + tx := bot.DB.Groups.Where("id = ? COLLATE NOCASE", groupName).First(group) + if tx.Error != nil { + return nil, tx.Error + } + return group, nil +} + +type Ticket struct { + Price int64 `json:"price"` + Memo string `json:"memo"` + Creator *lnbits.User `gorm:"embedded;embeddedPrefix:creator_"` + Cut int64 `json:"cut"` // Percent to cut from ticket price + BaseFee int64 `json:"base_fee"` + CutCheap int64 `json:"cut_cheap"` // Percent to cut from ticket price + BaseFeeCheap int64 `json:"base_fee_cheap"` +} +type Group struct { + Name string `json:"name"` + Title string `json:"title"` + ID int64 `json:"id" gorm:"primaryKey"` + Owner *tb.User `gorm:"embedded;embeddedPrefix:owner_"` + // Chat *tb.Chat `gorm:"embedded;embeddedPrefix:chat_"` + Ticket *Ticket `gorm:"embedded;embeddedPrefix:ticket_"` +} +type CreateChatInviteLink struct { + ChatID int64 `json:"chat_id"` + Name string `json:"name"` + ExpiryDate int `json:"expiry_date"` + MemberLimit int `json:"member_limit"` + CreatesJoinRequest bool `json:"creates_join_request"` +} +type Creator struct { + ID int64 `json:"id"` + IsBot bool `json:"is_bot"` + Firstname string `json:"first_name"` + Username string `json:"username"` +} +type Result struct { + InviteLink string `json:"invite_link"` + Name string `json:"name"` + Creator Creator `json:"creator"` + CreatesJoinRequest bool `json:"creates_join_request"` + IsPrimary bool `json:"is_primary"` + IsRevoked bool `json:"is_revoked"` +} +type ChatInviteLink struct { + Ok bool `json:"ok"` + Result Result `json:"result"` +} + +type TicketEvent struct { + *storage.Base + *InvoiceEvent + Group *Group `gorm:"embedded;embeddedPrefix:group_"` +} + +func (ticketEvent TicketEvent) Type() EventType { + return EventTypeTicketInvoice +} +func (ticketEvent TicketEvent) Key() string { + return ticketEvent.Base.ID +} + +var ( + ticketPayConfirmationMenu = &tb.ReplyMarkup{ResizeKeyboard: true} + btnPayTicket = paymentConfirmationMenu.Data("✅ Pay", "pay_ticket") +) + +const ( + groupInvoiceMemo = "🎟 Ticket for group %s" + groupInvoiceCommissionMemo = "🎟 Commission for group %s" +) + +// groupHandler is called if the /group command is invoked. It then decides with other +// handler to call depending on the passed. +func (bot TipBot) groupHandler(ctx intercept.Context) (intercept.Context, error) { + m := ctx.Message() + splits := strings.Split(m.Text, " ") + // user := LoadUser(ctx) + if len(splits) == 1 { + // command: /group + if ctx.Message().Private() { + // /group help message + bot.trySendMessage(ctx.Message().Chat, fmt.Sprintf(Translate(ctx, "groupHelpMessage"), GetUserStr(bot.Telegram.Me), GetUserStr(bot.Telegram.Me))) + } + // else { + // if bot.isOwner(ctx.Message().Chat, user.Telegram) { + // bot.trySendMessage(ctx.Message().Chat, fmt.Sprintf(Translate(ctx, "commandPrivateMessage"), GetUserStr(bot.Telegram.Me))) + // } + // } + return ctx, nil + } else if len(splits) > 1 { + if splits[1] == "join" { + return bot.groupRequestJoinHandler(ctx) + } + if splits[1] == "add" { + return bot.addGroupHandler(ctx) + } + if splits[1] == "ticket" { + return bot.handleJoinTicketPayWall(ctx) + } + if splits[1] == "remove" { + // todo -- implement this + // return bot.addGroupHandler(ctx, m) + } + } + return ctx, nil +} + +// groupRequestJoinHandler sends a payment request to the user who wants to join a group +func (bot TipBot) groupRequestJoinHandler(ctx intercept.Context) (intercept.Context, error) { + user := LoadUser(ctx) + // // reply only in private message + if ctx.Chat().Type != tb.ChatPrivate { + return ctx, fmt.Errorf("not private chat") + } + splits := strings.Split(ctx.Message().Text, " ") + // if the command was /group join + splitIdx := 1 + // we also have the simpler command /join that can be used + // also by users who don't have an account with the bot yet + if splits[0] == "/join" { + splitIdx = 0 + } + if len(splits) != splitIdx+2 || len(ctx.Message().Text) > 100 { + bot.trySendMessage(ctx.Message().Chat, Translate(ctx, "groupJoinGroupHelpMessage")) + return ctx, nil + } + groupName := strings.ToLower(splits[splitIdx+1]) + + group := &Group{} + tx := bot.DB.Groups.Where("name = ? COLLATE NOCASE", groupName).First(group) + if tx.Error != nil { + bot.trySendMessage(ctx.Message().Chat, Translate(ctx, "groupNotFoundMessage")) + return ctx, fmt.Errorf("group not found") + } + + // create tickets + id := fmt.Sprintf("ticket:%d", group.ID) + invoiceEvent := &InvoiceEvent{ + Base: storage.New(storage.ID(id)), + User: group.Ticket.Creator, + LanguageCode: ctx.Value("publicLanguageCode").(string), + Payer: user, + Chat: &tb.Chat{ID: group.ID}, + CallbackData: id, + } + ticketEvent := &TicketEvent{ + Base: storage.New(storage.ID(id)), + InvoiceEvent: invoiceEvent, + Group: group, + } + // if no price is set, then we don't need to pay + if group.Ticket.Price == 0 { + // save ticketevent for later + runtime.IgnoreError(ticketEvent.Set(ticketEvent, bot.Bunt)) + bot.groupGetInviteLinkHandler(invoiceEvent) + return ctx, nil + } + + // create an invoice + memo := fmt.Sprintf(groupInvoiceMemo, groupName) + var err error + invoiceEvent, err = bot.createGroupTicketInvoice(ctx, user, group, memo, InvoiceCallbackGroupTicket, id) + if err != nil { + errmsg := fmt.Sprintf("[/invoice] Could not create an invoice: %s", err.Error()) + bot.trySendMessage(user.Telegram, Translate(ctx, "errorTryLaterMessage")) + log.Errorln(errmsg) + return ctx, err + } + + ticketEvent.InvoiceEvent = invoiceEvent + // save ticketevent for later + defer ticketEvent.Set(ticketEvent, bot.Bunt) + + // // if the user has enough balance, we send him a payment button + balance, err := bot.GetUserBalance(user) + if err != nil { + errmsg := fmt.Sprintf("[/group] Error: Could not get user balance: %s", err.Error()) + log.Errorln(errmsg) + bot.trySendMessage(ctx.Message().Sender, Translate(ctx, "errorTryLaterMessage")) + return ctx, errors.New(errors.GetBalanceError, err) + } + if balance >= group.Ticket.Price { + return bot.groupSendPayButtonHandler(ctx, *ticketEvent) + } + + // otherwise we send a payment request + + // create qr code + qr, err := qrcode.Encode(invoiceEvent.PaymentRequest, qrcode.Medium, 256) + if err != nil { + errmsg := fmt.Sprintf("[/invoice] Failed to create QR code for invoice: %s", err.Error()) + bot.trySendMessage(user.Telegram, Translate(ctx, "errorTryLaterMessage")) + log.Errorln(errmsg) + return ctx, err + } + ticketEvent.Message = bot.trySendMessage(ctx.Message().Sender, &tb.Photo{File: tb.File{FileReader: bytes.NewReader(qr)}, Caption: fmt.Sprintf("`%s`", invoiceEvent.PaymentRequest)}) + bot.trySendMessage(ctx.Message().Sender, fmt.Sprintf(Translate(ctx, "groupPayInvoiceMessage"), groupName)) + return ctx, nil +} + +// groupSendPayButtonHandler is invoked if the user has enough balance so a message with a +// pay button is sent to the user. +func (bot *TipBot) groupSendPayButtonHandler(ctx intercept.Context, ticket TicketEvent) (intercept.Context, error) { + confirmText, confirmationMenu := bot.getSendPayButton(ctx, ticket) + bot.trySendMessageEditable(ctx.Message().Chat, confirmText, confirmationMenu) + return ctx, nil +} +func (bot *TipBot) getSendPayButton(ctx intercept.Context, ticket TicketEvent) (string, *tb.ReplyMarkup) { + // object that holds all information about the send payment + // // // create inline buttons + btnPayTicket := ticketPayConfirmationMenu.Data(Translate(ctx, "payButtonMessage"), "pay_ticket", ticket.Base.ID) + ticketPayConfirmationMenu.Inline( + ticketPayConfirmationMenu.Row( + btnPayTicket), + ) + confirmText := fmt.Sprintf(Translate(ctx, "confirmPayInvoiceMessage"), thirdparty.FormatSatsWithLKR(ticket.Group.Ticket.Price)) + // if len(ticket.Group.Ticket.Memo) > 0 { + // confirmText = confirmText + fmt.Sprintf(Translate(ctx, "confirmPayAppendMemo"), str.MarkdownEscape(ticket.Group.Ticket.Memo)) + // } + return confirmText, ticketPayConfirmationMenu +} + +// groupConfirmPayButtonHandler is invoked if th user clicks the pay button. +func (bot *TipBot) groupConfirmPayButtonHandler(ctx intercept.Context) (intercept.Context, error) { + c := ctx.Callback() + tx := &TicketEvent{Base: storage.New(storage.ID(c.Data))} + mutex.LockWithContext(ctx, tx.ID) + defer mutex.UnlockWithContext(ctx, tx.ID) + sn, err := tx.Get(tx, bot.Bunt) + // immediatelly set intransaction to block duplicate calls + if err != nil { + log.Errorf("[groupConfirmPayButtonHandler] %s", err.Error()) + return ctx, err + } + ticketEvent := sn.(*TicketEvent) + + // onnly the correct user can press + if ticketEvent.Payer.Telegram.ID != c.Sender.ID { + return ctx, errors.Create(errors.UnknownError) + } + if !ticketEvent.Active { + log.Errorf("[confirmPayHandler] send not active anymore") + bot.tryEditMessage(c, i18n.Translate(ticketEvent.LanguageCode, "errorTryLaterMessage"), &tb.ReplyMarkup{}) + bot.tryDeleteMessage(c) + return ctx, errors.Create(errors.NotActiveError) + } + defer ticketEvent.Set(ticketEvent, bot.Bunt) + + user := LoadUser(ctx) + if user.Wallet == nil { + bot.tryDeleteMessage(c) + return ctx, errors.Create(errors.UserNoWalletError) + } + + log.Infof("[/pay] Attempting %s's invoice %s (%d sat(s))", GetUserStr(user.Telegram), ticketEvent.ID, ticketEvent.Group.Ticket.Price) + // // pay invoice + _, err = user.Wallet.Pay(lnbits.PaymentParams{Out: true, Bolt11: ticketEvent.Invoice.PaymentRequest}, bot.Client) + if err != nil { + errmsg := fmt.Sprintf("[/pay] Could not pay invoice of %s: %s", GetUserStr(user.Telegram), err) + err = fmt.Errorf(i18n.Translate(ticketEvent.LanguageCode, "invoiceUndefinedErrorMessage")) + if ticketEvent.Callback != InvoiceCallbackPayJoinTicket { + bot.tryEditMessage(c, fmt.Sprintf(i18n.Translate(ticketEvent.LanguageCode, "invoicePaymentFailedMessage"), err.Error()), &tb.ReplyMarkup{}) + } + if bot.ErrorLogger != nil { + bot.ErrorLogger.LogPaymentError(err, ticketEvent.Group.Ticket.Price, "Join Ticket Payment", ticketEvent.Invoice.PaymentRequest, user.Telegram) + } + log.Errorln(errmsg) + return ctx, err + } + // if this was a join-ticket, we want to delete the invoice message + + // update the message and remove the button + bot.tryEditMessage(c, i18n.Translate(ticketEvent.LanguageCode, "invoicePaidMessage"), &tb.ReplyMarkup{}) + + return ctx, nil +} + +// groupGetInviteLinkHandler is called when the invoice is paid and sends a one-time group invite link to the payer +func (bot *TipBot) groupGetInviteLinkHandler(event Event) { + invoiceEvent := event.(*InvoiceEvent) + // take a cut + // amount_bot := int64(ticketEvent.Group.Ticket.Price * int64(ticketEvent.Group.Ticket.Cut) / 100) + + log.Infof(invoiceEvent.CallbackData) + ticketEvent := &TicketEvent{Base: storage.New(storage.ID(invoiceEvent.CallbackData))} + err := bot.Bunt.Get(ticketEvent) + if err != nil { + log.Errorf("[groupGetInviteLinkHandler] %s", err.Error()) + return + } + + log.Infof("[groupGetInviteLinkHandler] group: %d", ticketEvent.Chat.ID) + params := map[string]interface { + }{ + "chat_id": ticketEvent.Group.ID, // must be the chat ID of the group + "name": fmt.Sprintf("%s link for %s", GetUserStr(bot.Telegram.Me), GetUserStr(ticketEvent.Payer.Telegram)), // the name of the invite link + "member_limit": 1, // only one user can join with this link + // "expire_date": time.Now().AddDate(0, 0, 1), // expiry date of the invite link, add one day + // "creates_join_request": false, // True, if users joining the chat via the link need to be approved by chat administrators. If True, member_limit can't be specified + } + data, err := bot.Telegram.Raw("createChatInviteLink", params) + if err != nil { + return + } + + var resp ChatInviteLink + if err := json.Unmarshal(data, &resp); err != nil { + return + } + + if ticketEvent.Message != nil { + bot.tryDeleteMessage(ticketEvent.Message) + // do balance check for keyboard update + _, err = bot.GetUserBalance(ticketEvent.Payer) + if err != nil { + errmsg := fmt.Sprintf("could not get balance of user %s", GetUserStr(ticketEvent.Payer.Telegram)) + log.Errorln(errmsg) + } + bot.trySendMessage(ticketEvent.Payer.Telegram, i18n.Translate(ticketEvent.LanguageCode, "invoicePaidText")) + } + + // send confirmation text with the ticket to the user + bot.trySendMessage(ticketEvent.Payer.Telegram, fmt.Sprintf(i18n.Translate(ticketEvent.LanguageCode, "groupClickToJoinMessage"), resp.Result.InviteLink, ticketEvent.Group.Title)) + + // send a notification to the group that sold the ticket + bot.trySendMessage(&tb.Chat{ID: ticketEvent.Group.ID}, fmt.Sprintf(i18n.Translate(ticketEvent.LanguageCode, "groupTicketIssuedGroupMessage"), GetUserStrMd(ticketEvent.Payer.Telegram))) + + // take a commission + ticketSat := ticketEvent.Group.Ticket.Price + if commissionSat := getTicketCommission(ticketEvent.Group.Ticket); commissionSat > 0 { + me, err := GetUser(bot.Telegram.Me, *bot) + if err != nil { + log.Errorf("[groupGetInviteLinkHandler] Could not get bot user from DB: %s", err.Error()) + return + } + ticketSat = ticketEvent.Group.Ticket.Price - commissionSat + invoice, err := me.Wallet.Invoice( + lnbits.InvoiceParams{ + Out: false, + Amount: commissionSat, + Memo: "🎟 Ticket commission for group " + ticketEvent.Group.Title, + Webhook: internal.GetWebhookURL()}, + bot.Client) + if err != nil { + errmsg := fmt.Sprintf("[/invoice] Could not create an invoice: %s", err.Error()) + log.Errorln(errmsg) + return + } + _, err = ticketEvent.User.Wallet.Pay(lnbits.PaymentParams{Out: true, Bolt11: invoice.PaymentRequest}, bot.Client) + if err != nil { + errmsg := fmt.Sprintf("[groupGetInviteLinkHandler] Could not pay commission of %s: %s", GetUserStr(ticketEvent.User.Telegram), err) + log.Errorln(errmsg) + return + } + // do balance check for keyboard update + _, err = bot.GetUserBalance(ticketEvent.User) + if err != nil { + errmsg := fmt.Sprintf("could not get balance of user %s", GetUserStr(ticketEvent.Payer.Telegram)) + log.Errorln(errmsg) + } + bot.trySendMessage(ticketEvent.User.Telegram, fmt.Sprintf(i18n.Translate(ticketEvent.LanguageCode, "groupReceiveTicketInvoiceCommission"), utils.FormatSats(ticketSat), utils.FormatSats(commissionSat), ticketEvent.Group.Title, GetUserStrMd(ticketEvent.Payer.Telegram))) + } else { + bot.trySendMessage(ticketEvent.User.Telegram, fmt.Sprintf(i18n.Translate(ticketEvent.LanguageCode, "groupReceiveTicketInvoice"), utils.FormatSats(ticketSat), ticketEvent.Group.Title, GetUserStrMd(ticketEvent.Payer.Telegram))) + } +} + +func (bot TipBot) handleJoinTicketPayWall(ctx intercept.Context) (intercept.Context, error) { + var err error + var cmd string + if cmd, err = getArgumentFromCommand(ctx.Message().Text, 2); err == nil { + switch strings.TrimSpace(strings.ToLower(cmd)) { + case "del": + fallthrough + case "delete": + fallthrough + case "remove": + return bot.removeJoinTicketPayWallHandler(ctx) + default: + return bot.addJoinTicketPayWallHandler(ctx) + } + } + return ctx, err +} +func (bot TipBot) removeJoinTicketPayWallHandler(ctx intercept.Context) (intercept.Context, error) { + groupName := strconv.FormatInt(ctx.Chat().ID, 10) + tx := bot.DB.Groups.Where("id = ? COLLATE NOCASE", groupName).Delete(&Group{}) + if tx.Error != nil { + return ctx, tx.Error + } + bot.trySendMessage(ctx.Message().Chat, "🎟 Ticket removed.") + return ctx, nil +} + +// addJoinTicketPayWallHandler is invoked if the user calls the "/group ticket" command +func (bot TipBot) addJoinTicketPayWallHandler(ctx intercept.Context) (intercept.Context, error) { + m := ctx.Message() + if m.Chat.Type == tb.ChatPrivate { + bot.trySendMessage(m.Chat, Translate(ctx, "groupAddGroupHelpMessage")) + return ctx, fmt.Errorf("not in group") + } + // parse command "/group ticket []" + splits := strings.Split(m.Text, " ") + if len(splits) < 3 || len(m.Text) > 100 { + bot.trySendMessage(m.Chat, Translate(ctx, "groupAddGroupHelpMessage")) + return ctx, nil + } + groupName := strconv.FormatInt(ctx.Chat().ID, 10) + + user := LoadUser(ctx) + // check if the user is the owner of the group + if !bot.isOwner(m.Chat, user.Telegram) { + return ctx, fmt.Errorf("not owner") + } + + if !bot.isAdminAndCanInviteUsers(m.Chat, bot.Telegram.Me) { + bot.trySendMessage(m.Chat, Translate(ctx, "groupBotIsNotAdminMessage")) + return ctx, fmt.Errorf("bot is not admin") + } + + // check if the group with this name is already in db + // only if a group with this name is owned by this user, it can be overwritten + group := &Group{} + tx := bot.DB.Groups.Where("id = ? COLLATE NOCASE", groupName).First(group) + if tx.Error == nil { + // if it is already added, check if this user is the admin + if user.Telegram.ID != group.Owner.ID || group.ID != m.Chat.ID { + bot.trySendMessage(m.Chat, Translate(ctx, "groupNameExists")) + return ctx, fmt.Errorf("not owner") + } + } + + amount := int64(0) // default amount is zero + if amount_str, err := getArgumentFromCommand(m.Text, 2); err == nil { + amount, err = GetAmount(amount_str) + if err != nil { + bot.trySendMessage(m.Sender, Translate(ctx, "lnurlInvalidAmountMessage")) + return ctx, err + } + } + + ticket := &Ticket{ + Price: amount, + Memo: "Ticket for group " + groupName, + Creator: user, + Cut: 2, + BaseFee: 100, + CutCheap: 10, + BaseFeeCheap: 10, + } + + group = &Group{ + Name: groupName, + Title: m.Chat.Title, + ID: m.Chat.ID, + Owner: user.Telegram, + Ticket: ticket, + } + + bot.DB.Groups.Save(group) + log.Infof("[group] Ticket of %d sat(s) added to group %s.", group.Ticket.Price, group.Name) + bot.trySendMessage(m.Chat, Translate(ctx, "groupAddedMessagePublic")) + + return ctx, nil +} + +// addGroupHandler is invoked if the user calls the "/group add" command +func (bot TipBot) addGroupHandler(ctx intercept.Context) (intercept.Context, error) { + m := ctx.Message() + if m.Chat.Type == tb.ChatPrivate { + bot.trySendMessage(m.Chat, Translate(ctx, "groupAddGroupHelpMessage")) + return ctx, fmt.Errorf("not in group") + } + // parse command "/group add []" + splits := strings.Split(m.Text, " ") + if len(splits) < 3 || len(m.Text) > 100 { + bot.trySendMessage(m.Chat, Translate(ctx, "groupAddGroupHelpMessage")) + return ctx, nil + } + groupName := strings.ToLower(splits[2]) + + user := LoadUser(ctx) + // check if the user is the owner of the group + if !bot.isOwner(m.Chat, user.Telegram) { + return ctx, fmt.Errorf("not owner") + } + + if !bot.isAdminAndCanInviteUsers(m.Chat, bot.Telegram.Me) { + bot.trySendMessage(m.Chat, Translate(ctx, "groupBotIsNotAdminMessage")) + return ctx, fmt.Errorf("bot is not admin") + } + + // check if the group with this name is already in db + // only if a group with this name is owned by this user, it can be overwritten + group := &Group{} + tx := bot.DB.Groups.Where("name = ? COLLATE NOCASE", groupName).First(group) + if tx.Error == nil { + // if it is already added, check if this user is the admin + if user.Telegram.ID != group.Owner.ID || group.ID != m.Chat.ID { + bot.trySendMessage(m.Chat, Translate(ctx, "groupNameExists")) + return ctx, fmt.Errorf("not owner") + } + } + + amount := int64(0) // default amount is zero + if amount_str, err := getArgumentFromCommand(m.Text, 3); err == nil { + amount, err = GetAmount(amount_str) + if err != nil { + bot.trySendMessage(m.Sender, Translate(ctx, "lnurlInvalidAmountMessage")) + return ctx, err + } + } + + ticket := &Ticket{ + Price: amount, + Memo: "Ticket for group " + groupName, + Creator: user, + Cut: 2, + BaseFee: 100, + CutCheap: 10, + BaseFeeCheap: 10, + } + + group = &Group{ + Name: groupName, + Title: m.Chat.Title, + ID: m.Chat.ID, + Owner: user.Telegram, + Ticket: ticket, + } + + bot.DB.Groups.Save(group) + log.Infof("[group] Ticket of %d sat(s) added to group %s.", group.Ticket.Price, group.Name) + bot.trySendMessage(m.Chat, fmt.Sprintf(Translate(ctx, "groupAddedMessagePrivate"), str.MarkdownEscape(m.Chat.Title), group.Name, utils.FormatSats(group.Ticket.Price), GetUserStrMd(bot.Telegram.Me), group.Name)) + + return ctx, nil +} + +// createGroupTicketInvoice produces an invoice for the group ticket with a +// callback that then calls groupGetInviteLinkHandler upton payment +func (bot *TipBot) createGroupTicketInvoice(ctx context.Context, payer *lnbits.User, group *Group, memo string, callback int, callbackData string) (*InvoiceEvent, error) { + invoice, err := group.Ticket.Creator.Wallet.Invoice( + lnbits.InvoiceParams{ + Out: false, + Amount: group.Ticket.Price, + Memo: memo, + Webhook: internal.GetWebhookURL()}, + bot.Client) + if err != nil { + errmsg := fmt.Sprintf("[/invoice] Could not create an invoice: %s", err.Error()) + log.Errorln(errmsg) + return &InvoiceEvent{}, err + } + + // save the invoice event + id := fmt.Sprintf("invoice:%s", invoice.PaymentHash) + invoiceEvent := &InvoiceEvent{ + Base: storage.New(storage.ID(id)), + Invoice: &Invoice{PaymentHash: invoice.PaymentHash, + PaymentRequest: invoice.PaymentRequest, + Amount: group.Ticket.Price, + Memo: memo}, + User: group.Ticket.Creator, + Callback: callback, + CallbackData: callbackData, + LanguageCode: ctx.Value("publicLanguageCode").(string), + Payer: payer, + Chat: &tb.Chat{ID: group.ID}, + } + // add result to persistent struct + runtime.IgnoreError(invoiceEvent.Set(invoiceEvent, bot.Bunt)) + return invoiceEvent, nil +} diff --git a/internal/telegram/handler.go b/internal/telegram/handler.go new file mode 100644 index 00000000..c2c08738 --- /dev/null +++ b/internal/telegram/handler.go @@ -0,0 +1,1468 @@ +package telegram + +import ( + "fmt" + "strings" + + "github.com/LightningTipBot/LightningTipBot/internal/telegram/intercept" + log "github.com/sirupsen/logrus" + tb "gopkg.in/lightningtipbot/telebot.v3" +) + +type InterceptionWrapper struct { + Endpoints []interface{} + Handler intercept.Func + Interceptor *Interceptor +} + +// registerTelegramHandlers will register all Telegram handlers. +func (bot TipBot) registerTelegramHandlers() { + telegramHandlerRegistration.Do(func() { + // Set up handlers + for _, h := range bot.getHandler() { + log.Traceln("registering", h.Endpoints) + bot.register(h) + } + + }) +} + +func getDefaultBeforeInterceptor(bot TipBot) []intercept.Func { + return []intercept.Func{bot.idInterceptor} +} +func getDefaultDeferInterceptor(bot TipBot) []intercept.Func { + return []intercept.Func{bot.unlockInterceptor} +} +func getDefaultAfterInterceptor(bot TipBot) []intercept.Func { + return []intercept.Func{} +} + +// registerHandlerWithInterceptor will register a ctx with all the predefined interceptors, based on the interceptor type +func (bot TipBot) registerHandlerWithInterceptor(h InterceptionWrapper) { + h.Interceptor.Before = append(getDefaultBeforeInterceptor(bot), h.Interceptor.Before...) + //h.Interceptor.After = append(h.Interceptor.After, getDefaultAfterInterceptor(bot)...) + //h.Interceptor.OnDefer = append(h.Interceptor.OnDefer, getDefaultDeferInterceptor(bot)...) + for _, endpoint := range h.Endpoints { + bot.handle(endpoint, intercept.WithHandler(h.Handler, + intercept.WithBefore(h.Interceptor.Before...), + intercept.WithAfter(h.Interceptor.After...), + intercept.WithDefer(h.Interceptor.OnDefer...))) + } +} + +// handle accepts an endpoint and handler for Telegram handler registration. +// function will automatically register string handlers as uppercase and first letter uppercase. +func (bot TipBot) handle(endpoint interface{}, handler tb.HandlerFunc) { + // register the endpoint + bot.Telegram.Handle(endpoint, handler) + switch endpoint.(type) { + case string: + // check if this is a string endpoint + sEndpoint := endpoint.(string) + if strings.HasPrefix(sEndpoint, "/") { + // Uppercase endpoint registration, because starting with slash + bot.Telegram.Handle(strings.ToUpper(sEndpoint), handler) + if len(sEndpoint) > 2 { + // Also register endpoint with first letter uppercase + bot.Telegram.Handle(fmt.Sprintf("/%s%s", strings.ToUpper(string(sEndpoint[1])), sEndpoint[2:]), handler) + } + } + } +} + +// register registers a handler, so that Telegram can handle the endpoint correctly. +func (bot TipBot) register(h InterceptionWrapper) { + if h.Interceptor != nil { + bot.registerHandlerWithInterceptor(h) + } else { + for _, endpoint := range h.Endpoints { + bot.handle(endpoint, intercept.WithHandler(h.Handler)) + } + } +} + +// getHandler returns a list of all handlers, that need to be registered with Telegram +func (bot TipBot) getHandler() []InterceptionWrapper { + return []InterceptionWrapper{ + { + Endpoints: []interface{}{"/start"}, + Handler: bot.startHandler, + Interceptor: &Interceptor{ + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.logMessageInterceptor, + bot.loadUserInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }, + }, + }, + // { + // Endpoints: []interface{}{"/generate"}, + // Handler: bot.generateImages, + // Interceptor: &Interceptor{ + // Before: []intercept.Func{ + // bot.requirePrivateChatInterceptor, + // bot.localizerInterceptor, + // bot.logMessageInterceptor, + // bot.loadUserInterceptor, + // bot.lockInterceptor, + // }, + // OnDefer: []intercept.Func{ + // bot.unlockInterceptor, + // }, + // }, + // }, + { + Endpoints: []interface{}{"/tip", "/t", "/honk", "/zap"}, + Handler: bot.tipHandler, + Interceptor: &Interceptor{ + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.logMessageInterceptor, + bot.requireUserInterceptor, + bot.loadReplyToInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }, + }, + }, + { + Endpoints: []interface{}{"/pay"}, + Handler: bot.payHandler, + Interceptor: &Interceptor{ + Before: []intercept.Func{ + bot.requirePrivateChatInterceptor, + bot.localizerInterceptor, + bot.logMessageInterceptor, + bot.requireUserInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }, + }, + }, + { + Endpoints: []interface{}{"/invoice", &btnInvoiceMainMenu}, + Handler: bot.invoiceHandler, + Interceptor: &Interceptor{ + + Before: []intercept.Func{ + bot.requirePrivateChatInterceptor, + bot.localizerInterceptor, + bot.logMessageInterceptor, + bot.requireUserInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }, + }, + }, + { + Endpoints: []interface{}{"/set"}, + Handler: bot.settingHandler, + Interceptor: &Interceptor{ + Before: []intercept.Func{ + bot.requirePrivateChatInterceptor, + bot.localizerInterceptor, + bot.logMessageInterceptor, + bot.requireUserInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }, + }, + }, + { + Endpoints: []interface{}{"/nostr"}, + Handler: bot.nostrHandler, + Interceptor: &Interceptor{ + Before: []intercept.Func{ + bot.requirePrivateChatInterceptor, + bot.localizerInterceptor, + bot.logMessageInterceptor, + bot.requireUserInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }, + }, + }, + { + Endpoints: []interface{}{"/node"}, + Handler: bot.nodeHandler, + Interceptor: &Interceptor{ + Before: []intercept.Func{ + bot.requirePrivateChatInterceptor, + bot.localizerInterceptor, + bot.logMessageInterceptor, + bot.requireUserInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }, + }, + }, + { + Endpoints: []interface{}{&btnSatdressCheckInvoice}, + Handler: bot.satdressCheckInvoiceHandler, + Interceptor: &Interceptor{ + + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.requireUserInterceptor, + bot.answerCallbackInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }, + }, + }, + { + Endpoints: []interface{}{"/shops"}, + Handler: bot.shopsHandler, + Interceptor: &Interceptor{ + + Before: []intercept.Func{ + bot.logMessageInterceptor, + bot.requireUserInterceptor, + bot.loadUserInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }}, + }, + { + Endpoints: []interface{}{"/shop"}, + Handler: bot.shopHandler, + Interceptor: &Interceptor{ + + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.logMessageInterceptor, + bot.requireUserInterceptor, + bot.loadUserInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }}, + }, + // { + // Endpoints: []interface{}{"/gpt", "/chat"}, + // Handler: bot.gptHandler, + // Interceptor: &Interceptor{ + + // Before: []intercept.Func{ + // bot.localizerInterceptor, + // bot.logMessageInterceptor, + // bot.requireUserInterceptor, + // bot.lockInterceptor, + // }, + // OnDefer: []intercept.Func{ + // bot.unlockInterceptor, + // }, + // }, + // }, + { + Endpoints: []interface{}{"/balance", &btnBalanceMainMenu}, + Handler: bot.balanceHandler, + Interceptor: &Interceptor{ + + Before: []intercept.Func{ + bot.requirePrivateChatInterceptor, + bot.localizerInterceptor, + bot.logMessageInterceptor, + bot.requireUserInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }, + }, + }, + { + Endpoints: []interface{}{"/createpot"}, + Handler: bot.createPotHandler, + Interceptor: &Interceptor{ + Before: []intercept.Func{ + bot.requirePrivateChatInterceptor, + bot.localizerInterceptor, + bot.logMessageInterceptor, + bot.requireUserInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }, + }, + }, + { + Endpoints: []interface{}{"/pots"}, + Handler: bot.listPotsHandler, + Interceptor: &Interceptor{ + Before: []intercept.Func{ + bot.requirePrivateChatInterceptor, + bot.localizerInterceptor, + bot.logMessageInterceptor, + bot.requireUserInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }, + }, + }, + { + Endpoints: []interface{}{"/addtopot"}, + Handler: bot.addToPotHandler, + Interceptor: &Interceptor{ + Before: []intercept.Func{ + bot.requirePrivateChatInterceptor, + bot.localizerInterceptor, + bot.logMessageInterceptor, + bot.requireUserInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }, + }, + }, + { + Endpoints: []interface{}{"/withdrawfrompot"}, + Handler: bot.withdrawFromPotHandler, + Interceptor: &Interceptor{ + Before: []intercept.Func{ + bot.requirePrivateChatInterceptor, + bot.localizerInterceptor, + bot.logMessageInterceptor, + bot.requireUserInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }, + }, + }, + { + Endpoints: []interface{}{"/deletepot"}, + Handler: bot.deletePotHandler, + Interceptor: &Interceptor{ + Before: []intercept.Func{ + bot.requirePrivateChatInterceptor, + bot.localizerInterceptor, + bot.logMessageInterceptor, + bot.requireUserInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }, + }, + }, + { + Endpoints: []interface{}{"/so"}, + Handler: bot.soHandler, + Interceptor: &Interceptor{ + Before: []intercept.Func{ + bot.requirePrivateChatInterceptor, + bot.localizerInterceptor, + bot.logMessageInterceptor, + bot.requireUserInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }, + }, + }, + { + Endpoints: []interface{}{"/send", &btnSendMenuEnter}, + Handler: bot.sendHandler, + Interceptor: &Interceptor{ + + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.logMessageInterceptor, + bot.requireUserInterceptor, + bot.loadReplyToInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }, + }, + }, + { + Endpoints: []interface{}{&btnSendMainMenu}, + Handler: bot.keyboardSendHandler, + Interceptor: &Interceptor{ + + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.logMessageInterceptor, + bot.requireUserInterceptor, + bot.loadReplyToInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }, + }, + }, + // previously, this was the send menu but it + // was replaced with the webapp + // { + // Endpoints: []interface{}{&btnWebAppMainMenu}, + // Handler: bot.keyboardSendHandler, + // Interceptor: &Interceptor{ + + // Before: []intercept.Func{ + // bot.localizerInterceptor, + // bot.logMessageInterceptor, + // bot.requireUserInterceptor, + // bot.loadReplyToInterceptor, + // bot.lockInterceptor, + // }, + // OnDefer: []intercept.Func{ + // bot.unlockInterceptor, + // }, + // }, + // }, + { + Endpoints: []interface{}{"/transactions"}, + Handler: bot.transactionsHandler, + Interceptor: &Interceptor{ + Before: []intercept.Func{ + bot.requirePrivateChatInterceptor, + bot.localizerInterceptor, + bot.logMessageInterceptor, + bot.requireUserInterceptor, + }}, + }, + { + Endpoints: []interface{}{&btnLeftTransactionsButton}, + Handler: bot.transactionsScrollLeftHandler, + Interceptor: &Interceptor{ + + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.loadUserInterceptor, + bot.answerCallbackInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }, + }, + }, + { + Endpoints: []interface{}{&btnRightTransactionsButton}, + Handler: bot.transactionsScrollRightHandler, + Interceptor: &Interceptor{ + + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.loadUserInterceptor, + bot.answerCallbackInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }, + }, + }, + { + Endpoints: []interface{}{"/faucet", "/zapfhahn", "/kraan", "/grifo"}, + Handler: bot.faucetHandler, + Interceptor: &Interceptor{ + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.logMessageInterceptor, + bot.requireUserInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }, + }, + }, + { + Endpoints: []interface{}{"/tipjar", "/spendendose"}, + Handler: bot.tipjarHandler, + Interceptor: &Interceptor{ + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.logMessageInterceptor, + bot.requireUserInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }, + }, + }, + { + Endpoints: []interface{}{"/help", &btnHelpMainMenu}, + Handler: bot.helpHandler, + Interceptor: &Interceptor{ + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.logMessageInterceptor, + bot.loadUserInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }, + }, + }, + { + Endpoints: []interface{}{"/basics"}, + Handler: bot.basicsHandler, + Interceptor: &Interceptor{ + + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.logMessageInterceptor, + bot.loadUserInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }, + }, + }, + { + Endpoints: []interface{}{"/donate"}, + Handler: bot.donationHandler, + Interceptor: &Interceptor{ + + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.logMessageInterceptor, + bot.requireUserInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }, + }, + }, + { + Endpoints: []interface{}{"/lkrsats", &btnConvertMainMenu}, + Handler: bot.lkrToSatHandler, + Interceptor: &Interceptor{ + Before: []intercept.Func{ + bot.requirePrivateChatInterceptor, + bot.localizerInterceptor, + bot.logMessageInterceptor, + }, + }, + }, + { + Endpoints: []interface{}{"/convert"}, + Handler: bot.satToFiatHandler, + Interceptor: &Interceptor{ + Before: []intercept.Func{ + bot.requirePrivateChatInterceptor, + bot.localizerInterceptor, + bot.logMessageInterceptor, + }, + }, + }, + { + Endpoints: []interface{}{"/advanced"}, + Handler: bot.advancedHelpHandler, + Interceptor: &Interceptor{ + Before: []intercept.Func{ + bot.requirePrivateChatInterceptor, + bot.localizerInterceptor, + bot.logMessageInterceptor, + bot.requireUserInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }, + }, + }, + { + Endpoints: []interface{}{"/link"}, + Handler: bot.lndhubHandler, + Interceptor: &Interceptor{ + Before: []intercept.Func{ + bot.requirePrivateChatInterceptor, + bot.localizerInterceptor, + bot.logMessageInterceptor, + bot.requireUserInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }, + }, + }, + { + Endpoints: []interface{}{"/api"}, + Handler: bot.apiHandler, + Interceptor: &Interceptor{ + Before: []intercept.Func{ + bot.requirePrivateChatInterceptor, + bot.localizerInterceptor, + bot.logMessageInterceptor, + bot.requireUserInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }, + }, + }, + { + Endpoints: []interface{}{"/lnurl"}, + Handler: bot.lnurlHandler, + Interceptor: &Interceptor{ + Before: []intercept.Func{ + bot.requirePrivateChatInterceptor, + bot.localizerInterceptor, + bot.logMessageInterceptor, + bot.requireUserInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }, + }, + }, + // group join + { + Endpoints: []interface{}{tb.OnUserJoined, tb.OnAddedToGroup}, + Handler: bot.handleTelegramNewMember, + Interceptor: &Interceptor{ + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.logMessageInterceptor, + bot.tryLoadUserInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }, + }, + }, + // group tickets + { + Endpoints: []interface{}{"/group"}, + Handler: bot.groupHandler, + Interceptor: &Interceptor{ + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.logMessageInterceptor, + bot.requireUserInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }, + }, + }, + { + Endpoints: []interface{}{"/join"}, + Handler: bot.groupRequestJoinHandler, + Interceptor: &Interceptor{ + + Before: []intercept.Func{ + bot.requirePrivateChatInterceptor, + bot.localizerInterceptor, + bot.logMessageInterceptor, + bot.startUserInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }, + }, + }, + { + Endpoints: []interface{}{&btnPayTicket}, + Handler: bot.groupConfirmPayButtonHandler, + Interceptor: &Interceptor{ + + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.requireUserInterceptor, + bot.answerCallbackInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }, + }, + }, + { + Endpoints: []interface{}{tb.OnPhoto}, + Handler: bot.photoHandler, + Interceptor: &Interceptor{ + + Before: []intercept.Func{ + bot.requirePrivateChatInterceptor, + bot.localizerInterceptor, + bot.logMessageInterceptor, + bot.requireUserInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }, + }, + }, + { + Endpoints: []interface{}{tb.OnDocument, tb.OnVideo, tb.OnAnimation, tb.OnVoice, tb.OnAudio, tb.OnSticker, tb.OnVideoNote}, + Handler: bot.fileHandler, + Interceptor: &Interceptor{ + + Before: []intercept.Func{ + bot.requirePrivateChatInterceptor, + bot.logMessageInterceptor, + bot.loadUserInterceptor}}, + }, + { + Endpoints: []interface{}{tb.OnText}, + Handler: bot.anyTextHandler, + Interceptor: &Interceptor{ + + Before: []intercept.Func{ + bot.requirePrivateChatInterceptor, // Respond to any text only in private chat + bot.localizerInterceptor, + bot.logMessageInterceptor, + bot.loadUserInterceptor, // need to use loadUserInterceptor instead of requireUserInterceptor, because user might not be registered yet + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }, + }, + }, + { + Endpoints: []interface{}{tb.OnQuery}, + Handler: bot.anyQueryHandler, + Interceptor: &Interceptor{ + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.requireUserInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }, + }, + }, + { + Endpoints: []interface{}{tb.OnInlineResult}, + Handler: bot.anyChosenInlineHandler, + }, + { + Endpoints: []interface{}{&btnPay}, + Handler: bot.confirmPayHandler, + Interceptor: &Interceptor{ + + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.requireUserInterceptor, + bot.answerCallbackInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }, + }, + }, + { + Endpoints: []interface{}{&btnCancelPay}, + Handler: bot.cancelPaymentHandler, + Interceptor: &Interceptor{ + + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.requireUserInterceptor, + bot.answerCallbackInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }, + }, + }, + { + Endpoints: []interface{}{&btnSend}, + Handler: bot.confirmSendHandler, + Interceptor: &Interceptor{ + + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.requireUserInterceptor, + bot.answerCallbackInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }, + }, + }, + { + Endpoints: []interface{}{&btnCancelSend}, + Handler: bot.cancelSendHandler, + Interceptor: &Interceptor{ + + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.requireUserInterceptor, + bot.answerCallbackInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }, + }, + }, + { + Endpoints: []interface{}{&btnApproveAPITx}, + Handler: bot.approveAPITransactionHandler, + Interceptor: &Interceptor{ + + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.requireUserInterceptor, + bot.answerCallbackInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }, + }, + }, + { + Endpoints: []interface{}{&btnCancelAPITx}, + Handler: bot.cancelAPITransactionHandler, + Interceptor: &Interceptor{ + + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.requireUserInterceptor, + bot.answerCallbackInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }, + }, + }, + { + Endpoints: []interface{}{&btnAcceptInlineSend}, + Handler: bot.acceptInlineSendHandler, + Interceptor: &Interceptor{ + + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.loadUserInterceptor, + bot.answerCallbackInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }, + }, + }, + { + Endpoints: []interface{}{&btnCancelInlineSend}, + Handler: bot.cancelInlineSendHandler, + Interceptor: &Interceptor{ + + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.requireUserInterceptor, + bot.answerCallbackInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }, + }, + }, + { + Endpoints: []interface{}{&btnAcceptInlineReceive}, + Handler: bot.acceptInlineReceiveHandler, + Interceptor: &Interceptor{ + + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.loadUserInterceptor, + bot.answerCallbackInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }, + }, + }, + { + Endpoints: []interface{}{&btnCancelInlineReceive}, + Handler: bot.cancelInlineReceiveHandler, + Interceptor: &Interceptor{ + + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.requireUserInterceptor, + bot.answerCallbackInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }, + }, + }, + { + Endpoints: []interface{}{&btnAcceptInlineFaucet}, + Handler: bot.acceptInlineFaucetHandler, + Interceptor: &Interceptor{ + + Before: []intercept.Func{ + bot.singletonCallbackInterceptor, + bot.localizerInterceptor, + bot.loadUserInterceptor, + bot.answerCallbackInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + bot.answerCallbackInterceptor, + }, + }, + }, + { + Endpoints: []interface{}{&btnCancelInlineFaucet}, + Handler: bot.cancelInlineFaucetHandler, + Interceptor: &Interceptor{ + + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.requireUserInterceptor, + bot.answerCallbackInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }, + }, + }, + { + Endpoints: []interface{}{&btnAcceptInlineTipjar}, + Handler: bot.acceptInlineTipjarHandler, + Interceptor: &Interceptor{ + + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.requireUserInterceptor, + bot.answerCallbackInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }, + }, + }, + { + Endpoints: []interface{}{&btnCancelInlineTipjar}, + Handler: bot.cancelInlineTipjarHandler, + Interceptor: &Interceptor{ + + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.requireUserInterceptor, + bot.answerCallbackInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }, + }, + }, + { + Endpoints: []interface{}{&btnWithdraw}, + Handler: bot.confirmWithdrawHandler, + Interceptor: &Interceptor{ + + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.requireUserInterceptor, + bot.answerCallbackInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }, + }, + }, + { + Endpoints: []interface{}{&btnCancelWithdraw}, + Handler: bot.cancelWithdrawHandler, + Interceptor: &Interceptor{ + + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.requireUserInterceptor, + bot.answerCallbackInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }, + }, + }, + { + Endpoints: []interface{}{&btnAuth}, + Handler: bot.confirmLnurlAuthHandler, + Interceptor: &Interceptor{ + + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.requireUserInterceptor, + bot.answerCallbackInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }, + }, + }, + { + Endpoints: []interface{}{&btnCancelAuth}, + Handler: bot.cancelLnurlAuthHandler, + Interceptor: &Interceptor{ + + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.requireUserInterceptor, + bot.answerCallbackInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }, + }, + }, + { + Endpoints: []interface{}{&shopNewShopButton}, + Handler: bot.shopNewShopHandler, + Interceptor: &Interceptor{ + + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.loadUserInterceptor, + bot.answerCallbackInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }}, + }, + { + Endpoints: []interface{}{&shopAddItemButton}, + Handler: bot.shopNewItemHandler, + Interceptor: &Interceptor{ + + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.loadUserInterceptor, + bot.answerCallbackInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }}, + }, + { + Endpoints: []interface{}{&shopBuyitemButton}, + Handler: bot.shopGetItemFilesHandler, + Interceptor: &Interceptor{ + + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.loadUserInterceptor, + bot.answerCallbackInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }}, + }, + { + Endpoints: []interface{}{&shopNextitemButton}, + Handler: bot.shopNextItemButtonHandler, + Interceptor: &Interceptor{ + + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.loadUserInterceptor, + bot.answerCallbackInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }}, + }, + { + Endpoints: []interface{}{&browseShopButton}, + Handler: bot.shopsBrowser, + Interceptor: &Interceptor{ + + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.loadUserInterceptor, + bot.answerCallbackInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }}, + }, + { + Endpoints: []interface{}{&shopSelectButton}, + Handler: bot.shopSelect, + Interceptor: &Interceptor{ + + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.loadUserInterceptor, + bot.answerCallbackInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }}, + }, + // button that opens selection of shops to delete + { + Endpoints: []interface{}{&shopDeleteShopButton}, + Handler: bot.shopsDeleteShopBrowser, + Interceptor: &Interceptor{ + + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.loadUserInterceptor, + bot.answerCallbackInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }}, + }, + // button that selects which shop to delete + { + Endpoints: []interface{}{&shopDeleteSelectButton}, + Handler: bot.shopSelectDelete, + Interceptor: &Interceptor{ + + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.loadUserInterceptor, + bot.answerCallbackInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }}, + }, + // button that opens selection of shops to get links of + { + Endpoints: []interface{}{&shopLinkShopButton}, + Handler: bot.shopsLinkShopBrowser, + Interceptor: &Interceptor{ + + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.loadUserInterceptor, + bot.answerCallbackInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }}, + }, + // button that selects which shop to link + { + Endpoints: []interface{}{&shopLinkSelectButton}, + Handler: bot.shopSelectLink, + Interceptor: &Interceptor{ + + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.loadUserInterceptor, + bot.answerCallbackInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }}, + }, + // button that opens selection of shops to rename + { + Endpoints: []interface{}{&shopRenameShopButton}, + Handler: bot.shopsRenameShopBrowser, + Interceptor: &Interceptor{ + + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.loadUserInterceptor, + bot.answerCallbackInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }}, + }, + // button that selects which shop to rename + { + Endpoints: []interface{}{&shopRenameSelectButton}, + Handler: bot.shopSelectRename, + Interceptor: &Interceptor{ + + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.loadUserInterceptor, + bot.answerCallbackInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }}, + }, + // button that opens shops settings buttons view + { + Endpoints: []interface{}{&shopSettingsButton}, + Handler: bot.shopSettingsHandler, + Interceptor: &Interceptor{ + + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.loadUserInterceptor, + bot.answerCallbackInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }}, + }, + // button that lets user enter description for shops + { + Endpoints: []interface{}{&shopDescriptionShopButton}, + Handler: bot.shopsDescriptionHandler, + Interceptor: &Interceptor{ + + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.loadUserInterceptor, + bot.answerCallbackInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }}, + }, + // button that resets user shops + { + Endpoints: []interface{}{&shopResetShopButton}, + Handler: bot.shopsResetHandler, + Interceptor: &Interceptor{ + + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.loadUserInterceptor, + bot.answerCallbackInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }}, + }, + { + Endpoints: []interface{}{&shopResetShopAskButton}, + Handler: bot.shopsAskDeleteAllShopsHandler, + Interceptor: &Interceptor{ + + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.loadUserInterceptor, + bot.answerCallbackInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }}, + }, + { + Endpoints: []interface{}{&shopPrevitemButton}, + Handler: bot.shopPrevItemButtonHandler, + Interceptor: &Interceptor{ + + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.loadUserInterceptor, + bot.answerCallbackInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }}, + }, + { + Endpoints: []interface{}{&shopShopsButton}, + Handler: bot.shopsHandlerCallback, + Interceptor: &Interceptor{ + + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.loadUserInterceptor, + bot.answerCallbackInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }}, + }, + // shop item settings buttons + { + Endpoints: []interface{}{&shopItemSettingsButton}, + Handler: bot.shopItemSettingsHandler, + Interceptor: &Interceptor{ + + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.loadUserInterceptor, + bot.answerCallbackInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }}, + }, + { + Endpoints: []interface{}{&shopItemSettingsBackButton}, + Handler: bot.displayShopItemHandler, + Interceptor: &Interceptor{ + + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.loadUserInterceptor, + bot.answerCallbackInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }}, + }, + { + Endpoints: []interface{}{&shopItemDeleteButton}, + Handler: bot.shopItemDeleteHandler, + Interceptor: &Interceptor{ + + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.loadUserInterceptor, + bot.answerCallbackInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }}, + }, + { + Endpoints: []interface{}{&shopItemPriceButton}, + Handler: bot.shopItemPriceHandler, + Interceptor: &Interceptor{ + + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.loadUserInterceptor, + bot.answerCallbackInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }}, + }, + { + Endpoints: []interface{}{&shopItemTitleButton}, + Handler: bot.shopItemTitleHandler, + Interceptor: &Interceptor{ + + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.loadUserInterceptor, + bot.answerCallbackInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }}, + }, + { + Endpoints: []interface{}{&shopItemAddFileButton}, + Handler: bot.shopItemAddItemHandler, + Interceptor: &Interceptor{ + + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.loadUserInterceptor, + bot.answerCallbackInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }}, + }, + { + Endpoints: []interface{}{&shopItemBuyButton}, + Handler: bot.shopConfirmBuyHandler, + Interceptor: &Interceptor{ + + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.loadUserInterceptor, + bot.answerCallbackInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }}, + }, + { + Endpoints: []interface{}{&shopItemCancelBuyButton}, + Handler: bot.displayShopItemHandler, + Interceptor: &Interceptor{ + + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.loadUserInterceptor, + bot.answerCallbackInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }}, + }, + } +} diff --git a/internal/telegram/help.go b/internal/telegram/help.go new file mode 100644 index 00000000..b69699c7 --- /dev/null +++ b/internal/telegram/help.go @@ -0,0 +1,88 @@ +package telegram + +import ( + "context" + "fmt" + + "github.com/LightningTipBot/LightningTipBot/internal/telegram/intercept" + + tb "gopkg.in/lightningtipbot/telebot.v3" +) + +func (bot TipBot) makeHelpMessage(ctx context.Context, m *tb.Message) string { + fromUser := LoadUser(ctx) + dynamicHelpMessage := "" + // user has no username set + if len(m.Sender.Username) == 0 { + // return fmt.Sprintf(helpMessage, fmt.Sprintf("%s\n\n", helpNoUsernameMessage)) + dynamicHelpMessage = dynamicHelpMessage + "\n" + Translate(ctx, "helpNoUsernameMessage") + } + lnaddr, _ := bot.UserGetLightningAddress(fromUser) + if len(lnaddr) > 0 { + dynamicHelpMessage = dynamicHelpMessage + "\n" + fmt.Sprintf(Translate(ctx, "infoYourLightningAddress"), lnaddr) + } + if len(dynamicHelpMessage) > 0 { + dynamicHelpMessage = Translate(ctx, "infoHelpMessage") + dynamicHelpMessage + } + helpMessage := Translate(ctx, "helpMessage") + return fmt.Sprintf(helpMessage, dynamicHelpMessage) +} + +func (bot TipBot) helpHandler(ctx intercept.Context) (intercept.Context, error) { + // check and print all commands + bot.anyTextHandler(ctx) + if !ctx.Message().Private() { + // delete message + bot.tryDeleteMessage(ctx.Message()) + } + bot.trySendMessage(ctx.Sender(), bot.makeHelpMessage(ctx, ctx.Message()), tb.NoPreview) + return ctx, nil +} + +func (bot TipBot) basicsHandler(ctx intercept.Context) (intercept.Context, error) { + // check and print all commands + bot.anyTextHandler(ctx) + if !ctx.Message().Private() { + // delete message + bot.tryDeleteMessage(ctx.Message()) + } + bot.trySendMessage(ctx.Sender(), Translate(ctx, "basicsMessage"), tb.NoPreview) + return ctx, nil +} + +func (bot TipBot) makeAdvancedHelpMessage(ctx context.Context, m *tb.Message) string { + fromUser := LoadUser(ctx) + dynamicHelpMessage := "ℹ️ *Info*\n" + // 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", Translate(ctx, "helpNoUsernameMessage")) + "\n" + } + // we print the anonymous ln address in the advanced help + lnaddr, err := bot.UserGetAnonLightningAddress(fromUser) + if err == nil { + dynamicHelpMessage = dynamicHelpMessage + fmt.Sprintf("Anonymous Lightning address: `%s`\n", lnaddr) + } + lnurl, err := UserGetAnonLNURL(fromUser) + if err == nil { + dynamicHelpMessage = dynamicHelpMessage + fmt.Sprintf("Anonymous LNURL: `%s`", lnurl) + } + + // this is so stupid: + return fmt.Sprintf( + Translate(ctx, "advancedMessage"), + dynamicHelpMessage, + GetUserStr(bot.Telegram.Me), + ) +} + +func (bot TipBot) advancedHelpHandler(ctx intercept.Context) (intercept.Context, error) { + // check and print all commands + bot.anyTextHandler(ctx) + if !ctx.Message().Private() { + // delete message + bot.tryDeleteMessage(ctx.Message()) + } + bot.trySendMessage(ctx.Sender(), bot.makeAdvancedHelpMessage(ctx, ctx.Message()), tb.NoPreview) + return ctx, nil +} diff --git a/helpers.go b/internal/telegram/helpers.go similarity index 70% rename from helpers.go rename to internal/telegram/helpers.go index 9211b835..74e42828 100644 --- a/helpers.go +++ b/internal/telegram/helpers.go @@ -1,4 +1,4 @@ -package main +package telegram import ( "math" @@ -34,10 +34,18 @@ func GetMemoFromCommand(command string, fromWord int) string { return memo } -func MakeProgressbar(current int, total int) string { +func MakeProgressbar(current int64, total int64) string { MAX_BARS := 16 progress := math.Round((float64(current) / float64(total)) * float64(MAX_BARS)) progressbar := strings.Repeat("🟩", int(progress)) progressbar += strings.Repeat("⬜️", MAX_BARS-int(progress)) return progressbar } + +func MakeTipjarbar(current int64, total int64) string { + MAX_BARS := 16 + progress := math.Round((float64(current) / float64(total)) * float64(MAX_BARS)) + progressbar := strings.Repeat("🍯", int(progress)) + progressbar += strings.Repeat("⬜️", MAX_BARS-int(progress)) + return progressbar +} diff --git a/internal/telegram/inline_query.go b/internal/telegram/inline_query.go new file mode 100644 index 00000000..a239c8a1 --- /dev/null +++ b/internal/telegram/inline_query.go @@ -0,0 +1,174 @@ +package telegram + +import ( + "context" + "fmt" + "reflect" + "strconv" + "strings" + + "github.com/LightningTipBot/LightningTipBot/internal/telegram/intercept" + + "github.com/LightningTipBot/LightningTipBot/internal/runtime" + "github.com/LightningTipBot/LightningTipBot/internal/storage" + + "github.com/LightningTipBot/LightningTipBot/internal/i18n" + i18n2 "github.com/nicksnyder/go-i18n/v2/i18n" + log "github.com/sirupsen/logrus" + tb "gopkg.in/lightningtipbot/telebot.v3" +) + +const queryImage = "https://i.imgur.com/VB3zep8.png" + +func (bot TipBot) inlineQueryInstructions(ctx intercept.Context) (intercept.Context, error) { + instructions := []struct { + url string + title string + description string + }{ + { + url: queryImage, + title: TranslateUser(ctx, "inlineQuerySendTitle"), + description: fmt.Sprintf(TranslateUser(ctx, "inlineQuerySendDescription"), bot.Telegram.Me.Username), + }, + { + url: queryImage, + title: TranslateUser(ctx, "inlineQueryReceiveTitle"), + description: fmt.Sprintf(TranslateUser(ctx, "inlineQueryReceiveDescription"), bot.Telegram.Me.Username), + }, + { + url: queryImage, + title: TranslateUser(ctx, "inlineQueryFaucetTitle"), + description: fmt.Sprintf(TranslateUser(ctx, "inlineQueryFaucetDescription"), bot.Telegram.Me.Username), + }, + { + url: queryImage, + title: TranslateUser(ctx, "inlineQueryTipjarTitle"), + description: fmt.Sprintf(TranslateUser(ctx, "inlineQueryTipjarDescription"), 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 := ctx.Answer(&tb.QueryResponse{ + Results: results, + CacheTime: 5, // a minute + IsPersonal: true, + QueryID: ctx.Query().ID, + }) + + if err != nil { + log.Errorln(err) + } + return ctx, err +} + +func (bot TipBot) inlineQueryReplyWithError(ctx intercept.Context, message string, help string) { + results := make(tb.Results, 1) // []tb.Result + result := &tb.ArticleResult{ + // URL: url, + Text: help, + Title: message, + Description: help, + // required for photos + ThumbURL: queryImage, + } + id := fmt.Sprintf("inl-error-%d-%s", ctx.Query().Sender.ID, RandStringRunes(5)) + result.SetResultID(id) + results[0] = result + err := ctx.Answer(&tb.QueryResponse{ + Results: results, + + CacheTime: 1, // 60 == 1 minute, todo: make higher than 1 s in production + + }) + if err != nil { + log.Errorln(err) + } +} + +// anyChosenInlineHandler will load any inline object from cache and store into bunt. +// this is used to decrease bunt db write ops. +func (bot TipBot) anyChosenInlineHandler(ctx intercept.Context) (intercept.Context, error) { + // load inline object from cache + inlineObject, err := bot.Cache.Get(ctx.InlineResult().ResultID) + // check error + if err != nil { + log.Errorf("[anyChosenInlineHandler] could not find inline object in cache. %v", err.Error()) + return ctx, err + } + switch inlineObject.(type) { + case storage.Storable: + // persist inline object in bunt + runtime.IgnoreError(bot.Bunt.Set(inlineObject.(storage.Storable))) + default: + log.Errorf("[anyChosenInlineHandler] invalid inline object type: %s, query: %s", reflect.TypeOf(inlineObject).String(), ctx.InlineResult().Query) + } + return ctx, nil +} + +func (bot TipBot) commandTranslationMap(ctx context.Context, command string) context.Context { + switch command { + // is default, we don't have to check it + // case "faucet": + // ctx = context.WithValue(ctx, "publicLanguageCode", "en") + // ctx = context.WithValue(ctx, "publicLocalizer", i18n.NewLocalizer(i18n.Bundle, "en")) + case "zapfhahn", "spendendose": + ctx = context.WithValue(ctx, "publicLanguageCode", "de") + ctx = context.WithValue(ctx, "publicLocalizer", i18n2.NewLocalizer(i18n.Bundle, "de")) + case "kraan": + ctx = context.WithValue(ctx, "publicLanguageCode", "nl") + ctx = context.WithValue(ctx, "publicLocalizer", i18n2.NewLocalizer(i18n.Bundle, "nl")) + case "grifo": + ctx = context.WithValue(ctx, "publicLanguageCode", "es") + ctx = context.WithValue(ctx, "publicLocalizer", i18n2.NewLocalizer(i18n.Bundle, "es")) + } + return ctx +} + +func (bot TipBot) anyQueryHandler(ctx intercept.Context) (intercept.Context, error) { + if ctx.Query().Text == "" { + return bot.inlineQueryInstructions(ctx) + } + + // create the inline send result + var text = ctx.Query().Text + if strings.HasPrefix(text, "/") { + text = strings.TrimPrefix(text, "/") + } + if strings.HasPrefix(text, "send") || strings.HasPrefix(text, "pay") { + return bot.handleInlineSendQuery(ctx) + } + + if strings.HasPrefix(text, "faucet") || strings.HasPrefix(text, "zapfhahn") || strings.HasPrefix(text, "kraan") || strings.HasPrefix(text, "grifo") { + if len(strings.Split(text, " ")) > 1 { + c := strings.Split(text, " ")[0] + ctx.Context = bot.commandTranslationMap(ctx, c) + } + return bot.handleInlineFaucetQuery(ctx) + } + if strings.HasPrefix(text, "tipjar") || strings.HasPrefix(text, "spendendose") { + if len(strings.Split(text, " ")) > 1 { + c := strings.Split(text, " ")[0] + ctx.Context = bot.commandTranslationMap(ctx, c) + } + return bot.handleInlineTipjarQuery(ctx) + } + + if strings.HasPrefix(text, "receive") || strings.HasPrefix(text, "get") || strings.HasPrefix(text, "payme") || strings.HasPrefix(text, "request") { + return bot.handleInlineReceiveQuery(ctx) + } + return ctx, nil +} diff --git a/internal/telegram/inline_receive.go b/internal/telegram/inline_receive.go new file mode 100644 index 00000000..f455a12d --- /dev/null +++ b/internal/telegram/inline_receive.go @@ -0,0 +1,380 @@ +package telegram + +import ( + "bytes" + "context" + "fmt" + "strconv" + "strings" + "time" + + "github.com/LightningTipBot/LightningTipBot/internal/errors" + "github.com/LightningTipBot/LightningTipBot/internal/telegram/intercept" + + "github.com/LightningTipBot/LightningTipBot/internal/runtime/mutex" + "github.com/LightningTipBot/LightningTipBot/internal/storage" + + "github.com/eko/gocache/store" + "github.com/skip2/go-qrcode" + + "github.com/LightningTipBot/LightningTipBot/internal/i18n" + "github.com/LightningTipBot/LightningTipBot/internal/lnbits" + "github.com/LightningTipBot/LightningTipBot/internal/thirdparty" + + "github.com/LightningTipBot/LightningTipBot/internal/runtime" + log "github.com/sirupsen/logrus" + tb "gopkg.in/lightningtipbot/telebot.v3" +) + +var ( + inlineReceiveMenu = &tb.ReplyMarkup{ResizeKeyboard: true} + btnCancelInlineReceive = inlineReceiveMenu.Data("🚫 Cancel", "cancel_receive_inline") + btnAcceptInlineReceive = inlineReceiveMenu.Data("💸 Pay", "confirm_receive_inline") +) + +type InlineReceive struct { + *storage.Base + MessageText string `json:"inline_receive_messagetext"` + Message tb.Editable `json:"inline_receive_message"` + Amount int64 `json:"inline_receive_amount"` + From *lnbits.User `json:"inline_receive_from"` + To *lnbits.User `json:"inline_receive_to"` + From_SpecificUser bool `json:"from_specific_user"` + Memo string `json:"inline_receive_memo"` + LanguageCode string `json:"languagecode"` +} + +func (bot TipBot) makeReceiveKeyboard(ctx context.Context, id string) *tb.ReplyMarkup { + inlineReceiveMenu := &tb.ReplyMarkup{ResizeKeyboard: true} + acceptInlineReceiveButton := inlineReceiveMenu.Data(Translate(ctx, "payReceiveButtonMessage"), "confirm_receive_inline") + cancelInlineReceiveButton := inlineReceiveMenu.Data(Translate(ctx, "cancelButtonMessage"), "cancel_receive_inline") + acceptInlineReceiveButton.Data = id + cancelInlineReceiveButton.Data = id + inlineReceiveMenu.Inline( + inlineReceiveMenu.Row( + cancelInlineReceiveButton, + acceptInlineReceiveButton, + ), + ) + return inlineReceiveMenu +} + +func (bot TipBot) handleInlineReceiveQuery(ctx intercept.Context) (intercept.Context, error) { + q := ctx.Query() + to := LoadUser(ctx) + amount, err := decodeAmountFromCommand(q.Text) + if err != nil { + bot.inlineQueryReplyWithError(ctx, Translate(ctx, "inlineQueryReceiveTitle"), fmt.Sprintf(Translate(ctx, "inlineQueryReceiveDescription"), bot.Telegram.Me.Username)) + return ctx, err + } + if amount < 1 { + bot.inlineQueryReplyWithError(ctx, Translate(ctx, "inlineSendInvalidAmountMessage"), fmt.Sprintf(Translate(ctx, "inlineQueryReceiveDescription"), bot.Telegram.Me.Username)) + return ctx, errors.Create(errors.InvalidAmountError) + } + toUserStr := GetUserStr(q.Sender) + + // check whether the 3rd argument is a username + // command is "@LightningTipBot receive 123 @from_user This is the memo" + memo_argn := 2 // argument index at which the memo starts, will be 3 if there is a from_username in command + fromUserDb := &lnbits.User{} + from_SpecificUser := false + if len(strings.Split(q.Text, " ")) > 2 { + from_username := strings.Split(q.Text, " ")[2] + if strings.HasPrefix(from_username, "@") { + fromUserDb, err = GetUserByTelegramUsername(from_username[1:], bot) // must be without the @ + if err != nil { + //bot.tryDeleteMessage(m) + //bot.trySendMessage(m.Sender, fmt.Sprintf(Translate(ctx, "sendUserHasNoWalletMessage"), toUserStrMention)) + bot.inlineQueryReplyWithError(ctx, + fmt.Sprintf(TranslateUser(ctx, "sendUserHasNoWalletMessage"), from_username), + fmt.Sprintf(TranslateUser(ctx, "inlineQueryReceiveDescription"), + bot.Telegram.Me.Username)) + return ctx, err + } + memo_argn = 3 // assume that memo starts after the from_username + from_SpecificUser = true + } + } + + // check for memo in command + memo := GetMemoFromCommand(q.Text, memo_argn) + results := make(tb.Results, 0, 2) // []tb.Result + + // helper function to create a result and store inline receive data + createResult := func(amountSat int64, title string, description string) { + inlineMessage := fmt.Sprintf(Translate(ctx, "inlineReceiveMessage"), toUserStr, thirdparty.FormatSatsWithLKR(amountSat)) + + // modify message if payment is to specific user + if from_SpecificUser { + inlineMessage = fmt.Sprintf("@%s: %s", fromUserDb.Telegram.Username, inlineMessage) + } + + if len(memo) > 0 { + inlineMessage = inlineMessage + fmt.Sprintf(Translate(ctx, "inlineReceiveAppendMemo"), memo) + } + result := &tb.ArticleResult{ + Text: inlineMessage, + Title: title, + Description: description, + ThumbURL: queryImage, + } + id := fmt.Sprintf("inl-receive-%d-%d-%s", q.Sender.ID, amountSat, RandStringRunes(5)) + result.ReplyMarkup = &tb.ReplyMarkup{InlineKeyboard: bot.makeReceiveKeyboard(ctx, id).InlineKeyboard} + result.SetResultID(id) + results = append(results, result) + + // create persistend inline receive struct + inlineReceive := InlineReceive{ + Base: storage.New(storage.ID(id)), + MessageText: inlineMessage, + To: to, + Memo: memo, + Amount: amountSat, + From: fromUserDb, + From_SpecificUser: from_SpecificUser, + LanguageCode: ctx.Value("publicLanguageCode").(string), + } + bot.Cache.Set(inlineReceive.ID, inlineReceive, &store.Options{Expiration: 5 * time.Minute}) + } + + // result treating the amount as sats + title := fmt.Sprintf(TranslateUser(ctx, "inlineResultReceiveTitle"), thirdparty.FormatSatsWithLKR(amount)) + description := fmt.Sprintf(TranslateUser(ctx, "inlineResultReceiveDescription"), thirdparty.FormatSatsWithLKR(amount)) + createResult(amount, title, description) + + // result treating the amount as LKR + amountStr, errArg := getArgumentFromCommand(q.Text, 1) + if errArg == nil { + if f, err := strconv.ParseFloat(strings.ReplaceAll(amountStr, ",", ""), 64); err == nil { + if satFromLKR, err := thirdparty.LKRToSat(f); err == nil { + title := fmt.Sprintf("💸 Request %.2f LKR (~%d sat)", f, satFromLKR) + description := fmt.Sprintf("👉 Click to request %.2f LKR (~%d sat) from this chat.", f, satFromLKR) + createResult(satFromLKR, title, description) + } + } + } + + 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) + return ctx, err + } + return ctx, nil +} + +func (bot *TipBot) acceptInlineReceiveHandler(ctx intercept.Context) (intercept.Context, error) { + c := ctx.Callback() + tx := &InlineReceive{Base: storage.New(storage.ID(c.Data))} + // immediatelly set intransaction to block duplicate calls + mutex.LockWithContext(ctx, tx.ID) + defer mutex.UnlockWithContext(ctx, tx.ID) + rn, err := tx.Get(tx, bot.Bunt) + if err != nil { + log.Errorf("[getInlineReceive] %s", err.Error()) + return ctx, err + } + inlineReceive := rn.(*InlineReceive) + if !inlineReceive.Active { + log.Errorf("[acceptInlineReceiveHandler] inline receive not active anymore") + return ctx, errors.Create(errors.NotActiveError) + } + + // user `from` is the one who is SENDING + // user `to` is the one who is RECEIVING + from := LoadUser(ctx) + // check if this payment is requested from a specific user + if inlineReceive.From_SpecificUser { + if inlineReceive.From.Telegram.ID != from.Telegram.ID { + // log.Infof("User %d is not User %d", inlineReceive.From.Telegram.ID, from.Telegram.ID) + return ctx, errors.Create(errors.UnknownError) + } + } else { + // otherwise, we just set it to the user who has clicked + inlineReceive.From = from + + } + inlineReceive.Message = c + runtime.IgnoreError(inlineReceive.Set(inlineReceive, bot.Bunt)) + + to := inlineReceive.To + if from.Telegram.ID == to.Telegram.ID { + bot.trySendMessage(from.Telegram, Translate(ctx, "sendYourselfMessage")) + return ctx, errors.Create(errors.SelfPaymentError) + } + + balance, err := bot.GetUserBalance(from) + if err != nil { + errmsg := fmt.Sprintf("[inlineReceive] Error: Could not get user balance: %s", err.Error()) + log.Warnln(errmsg) + } + + if from.Wallet == nil || balance < inlineReceive.Amount { + // if user has no wallet, show invoice + bot.tryEditMessage(inlineReceive.Message, inlineReceive.MessageText, &tb.ReplyMarkup{}) + // runtime.IgnoreError(inlineReceive.Set(inlineReceive, bot.Bunt)) + bot.inlineReceiveInvoice(ctx, inlineReceive) + return ctx, errors.Create(errors.BalanceToLowError) + } else { + // else, do an internal transaction + return bot.sendInlineReceiveHandler(ctx) + } +} + +func (bot *TipBot) sendInlineReceiveHandler(ctx intercept.Context) (intercept.Context, error) { + c := ctx.Callback() + tx := &InlineReceive{Base: storage.New(storage.ID(c.Data))} + mutex.LockWithContext(ctx, tx.ID) + defer mutex.UnlockWithContext(ctx, tx.ID) + rn, err := tx.Get(tx, bot.Bunt) + // immediatelly set intransaction to block duplicate calls + if err != nil { + // log.Errorf("[getInlineReceive] %s", err.Error()) + return ctx, err + } + inlineReceive := rn.(*InlineReceive) + + if !inlineReceive.Active { + log.Errorf("[acceptInlineReceiveHandler] inline receive not active anymore") + return ctx, errors.Create(errors.NotActiveError) + } + + // defer inlineReceive.Release(inlineReceive, bot.Bunt) + + // from := inlineReceive.From + from := LoadUser(ctx) + to := inlineReceive.To + toUserStr := GetUserStr(to.Telegram) + fromUserStr := GetUserStr(from.Telegram) + // balance check of the user + balance, err := bot.GetUserBalanceCached(from) + if err != nil { + errmsg := fmt.Sprintf("could not get balance of user %s", fromUserStr) + log.Errorln(errmsg) + return ctx, err + } + // check if fromUser has balance + if balance < inlineReceive.Amount { + log.Errorf("[acceptInlineReceiveHandler] balance of user %s too low", fromUserStr) + bot.trySendMessage(from.Telegram, Translate(ctx, "inlineSendBalanceLowMessage")) + return ctx, errors.Create(errors.BalanceToLowError) + } + + // set inactive to avoid double-sends + inlineReceive.Inactivate(inlineReceive, bot.Bunt) + + // todo: user new get username function to get userStrings + transactionMemo := fmt.Sprintf("💸 Receive from %s to %s.", fromUserStr, toUserStr) + t := NewTransaction(bot, from, to, inlineReceive.Amount, TransactionType("inline receive")) + t.Memo = transactionMemo + success, err := t.Send() + if !success { + errMsg := fmt.Sprintf("[acceptInlineReceiveHandler] Transaction failed: %s", err.Error()) + log.Errorln(errMsg) + bot.tryEditMessage(c, i18n.Translate(inlineReceive.LanguageCode, "inlineReceiveFailedMessage"), &tb.ReplyMarkup{}) + return ctx, errors.Create(errors.UnknownError) + } + + log.Infof("[💸 inlineReceive] Send from %s to %s (%d sat).", fromUserStr, toUserStr, inlineReceive.Amount) + inlineReceive.Set(inlineReceive, bot.Bunt) + ctx.Context, err = bot.finishInlineReceiveHandler(ctx, ctx.Callback()) + return ctx, err + +} + +func (bot *TipBot) inlineReceiveInvoice(ctx intercept.Context, inlineReceive *InlineReceive) { + if !inlineReceive.Active { + log.Errorf("[acceptInlineReceiveHandler] inline receive not active anymore") + return + } + invoice, err := bot.createInvoiceWithEvent(ctx, inlineReceive.To, inlineReceive.Amount, fmt.Sprintf("Pay to %s", GetUserStr(inlineReceive.To.Telegram)), "", InvoiceCallbackInlineReceive, inlineReceive.ID) + if err != nil { + errmsg := fmt.Sprintf("[/invoice] Could not create an invoice: %s", err.Error()) + bot.tryEditMessage(inlineReceive.Message, Translate(ctx, "errorTryLaterMessage")) + 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.Error()) + bot.tryEditMessage(inlineReceive.Message, Translate(ctx, "errorTryLaterMessage")) + log.Errorln(errmsg) + return + } + + // send the invoice data to user + msg := bot.trySendMessage(ctx.Callback().Sender, &tb.Photo{File: tb.File{FileReader: bytes.NewReader(qr)}, Caption: fmt.Sprintf("`%s`", invoice.PaymentRequest)}) + bot.tryEditMessage(inlineReceive.Message, fmt.Sprintf("%s\n\nPay this invoice:\n```%s```", inlineReceive.MessageText, invoice.PaymentRequest)) + invoice.InvoiceMessage = msg + runtime.IgnoreError(bot.Bunt.Set(invoice)) + log.Printf("[/invoice] Incvoice created. User: %s, amount: %d sat.", GetUserStr(inlineReceive.To.Telegram), inlineReceive.Amount) + +} +func (bot *TipBot) inlineReceiveEvent(event Event) { + invoiceEvent := event.(*InvoiceEvent) + bot.tryDeleteMessage(invoiceEvent.InvoiceMessage) + bot.notifyInvoiceReceivedEvent(invoiceEvent) + bot.finishInlineReceiveHandler(nil, &tb.Callback{Data: string(invoiceEvent.CallbackData)}) +} + +func (bot *TipBot) finishInlineReceiveHandler(ctx context.Context, c *tb.Callback) (context.Context, error) { + tx := &InlineReceive{Base: storage.New(storage.ID(c.Data))} + // immediatelly set intransaction to block duplicate calls + if ctx != nil { + mutex.LockWithContext(ctx, tx.ID) + defer mutex.UnlockWithContext(ctx, tx.ID) + } + rn, err := tx.Get(tx, bot.Bunt) + if err != nil { + log.Errorf("[getInlineReceive] %s", err.Error()) + return ctx, err + } + inlineReceive := rn.(*InlineReceive) + + from := inlineReceive.From + to := inlineReceive.To + toUserStrMd := GetUserStrMd(to.Telegram) + fromUserStrMd := GetUserStrMd(from.Telegram) + inlineReceive.MessageText = fmt.Sprintf(i18n.Translate(inlineReceive.LanguageCode, "inlineSendUpdateMessageAccept"), thirdparty.FormatSatsWithLKR(inlineReceive.Amount), fromUserStrMd, toUserStrMd) + memo := inlineReceive.Memo + if len(memo) > 0 { + inlineReceive.MessageText += fmt.Sprintf(i18n.Translate(inlineReceive.LanguageCode, "inlineReceiveAppendMemo"), memo) + } + + if !to.Initialized { + inlineReceive.MessageText += "\n\n" + fmt.Sprintf(i18n.Translate(inlineReceive.LanguageCode, "inlineSendCreateWalletMessage"), GetUserStrMd(bot.Telegram.Me)) + } + + bot.tryEditMessage(inlineReceive.Message, inlineReceive.MessageText, &tb.ReplyMarkup{}) + // notify users + bot.trySendMessage(to.Telegram, fmt.Sprintf(i18n.Translate(to.Telegram.LanguageCode, "sendReceivedMessage"), fromUserStrMd, thirdparty.FormatSatsWithLKR(inlineReceive.Amount))) + bot.trySendMessage(from.Telegram, fmt.Sprintf(i18n.Translate(from.Telegram.LanguageCode, "sendSentMessage"), thirdparty.FormatSatsWithLKR(inlineReceive.Amount), toUserStrMd)) + return ctx, nil + // inlineReceive.Release(inlineReceive, bot.Bunt) +} + +func (bot *TipBot) cancelInlineReceiveHandler(ctx intercept.Context) (intercept.Context, error) { + c := ctx.Callback() + tx := &InlineReceive{Base: storage.New(storage.ID(c.Data))} + mutex.LockWithContext(ctx, tx.ID) + defer mutex.UnlockWithContext(ctx, tx.ID) + // immediatelly set intransaction to block duplicate calls + rn, err := tx.Get(tx, bot.Bunt) + if err != nil { + log.Errorf("[cancelInlineReceiveHandler] %s", err.Error()) + return ctx, err + } + inlineReceive := rn.(*InlineReceive) + if c.Sender.ID != inlineReceive.To.Telegram.ID { + return ctx, errors.Create(errors.UnknownError) + } + bot.tryEditMessage(c, i18n.Translate(inlineReceive.LanguageCode, "inlineReceiveCancelledMessage"), &tb.ReplyMarkup{}) + // set the inlineReceive inactive + inlineReceive.Active = false + return ctx, inlineReceive.Set(inlineReceive, bot.Bunt) +} diff --git a/internal/telegram/inline_send.go b/internal/telegram/inline_send.go new file mode 100644 index 00000000..9b4fe4bb --- /dev/null +++ b/internal/telegram/inline_send.go @@ -0,0 +1,292 @@ +package telegram + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" + + "github.com/LightningTipBot/LightningTipBot/internal/errors" + "github.com/LightningTipBot/LightningTipBot/internal/telegram/intercept" + + "github.com/LightningTipBot/LightningTipBot/internal/runtime/mutex" + "github.com/LightningTipBot/LightningTipBot/internal/storage" + + "github.com/eko/gocache/store" + + "github.com/LightningTipBot/LightningTipBot/internal/i18n" + + "github.com/LightningTipBot/LightningTipBot/internal/lnbits" + "github.com/LightningTipBot/LightningTipBot/internal/thirdparty" + + log "github.com/sirupsen/logrus" + tb "gopkg.in/lightningtipbot/telebot.v3" +) + +var ( + inlineSendMenu = &tb.ReplyMarkup{ResizeKeyboard: true} + btnCancelInlineSend = inlineSendMenu.Data("🚫 Cancel", "cancel_send_inline") + btnAcceptInlineSend = inlineSendMenu.Data("✅ Receive", "confirm_send_inline") +) + +type InlineSend struct { + *storage.Base + Message string `json:"inline_send_message"` + Amount int64 `json:"inline_send_amount"` + From *lnbits.User `json:"inline_send_from"` + To *lnbits.User `json:"inline_send_to"` + To_SpecificUser bool `json:"to_specific_user"` + Memo string `json:"inline_send_memo"` + LanguageCode string `json:"languagecode"` +} + +func (bot TipBot) makeSendKeyboard(ctx context.Context, id string) *tb.ReplyMarkup { + inlineSendMenu := &tb.ReplyMarkup{ResizeKeyboard: true} + acceptInlineSendButton := inlineSendMenu.Data(Translate(ctx, "receiveButtonMessage"), "confirm_send_inline") + cancelInlineSendButton := inlineSendMenu.Data(Translate(ctx, "cancelButtonMessage"), "cancel_send_inline") + acceptInlineSendButton.Data = id + cancelInlineSendButton.Data = id + + inlineSendMenu.Inline( + inlineSendMenu.Row( + acceptInlineSendButton, + cancelInlineSendButton), + ) + return inlineSendMenu +} + +func (bot TipBot) handleInlineSendQuery(ctx intercept.Context) (intercept.Context, error) { + q := ctx.Query() + // inlineSend := NewInlineSend() + // var err error + amount, err := decodeAmountFromCommand(q.Text) + if err != nil { + bot.inlineQueryReplyWithError(ctx, TranslateUser(ctx, "inlineQuerySendTitle"), fmt.Sprintf(TranslateUser(ctx, "inlineQuerySendDescription"), bot.Telegram.Me.Username)) + return ctx, err + } + if amount < 1 { + bot.inlineQueryReplyWithError(ctx, TranslateUser(ctx, "inlineSendInvalidAmountMessage"), fmt.Sprintf(Translate(ctx, "inlineQuerySendDescription"), bot.Telegram.Me.Username)) + return ctx, errors.Create(errors.InvalidAmountError) + } + fromUser := LoadUser(ctx) + fromUserStr := GetUserStr(q.Sender) + balance, err := bot.GetUserAvailableBalance(fromUser) + if err != nil { + errmsg := fmt.Sprintf("could not get available balance of user %s", fromUserStr) + log.Errorln(errmsg) + return ctx, err + } + // check if fromUser has sufficient available balance + if balance < amount { + log.Errorf("Available balance of user %s too low", fromUserStr) + bot.inlineQueryReplyWithError(ctx, TranslateUser(ctx, "inlineSendBalanceLowMessage"), fmt.Sprintf(TranslateUser(ctx, "inlineQuerySendDescription"), bot.Telegram.Me.Username)) + return ctx, errors.Create(errors.InvalidAmountError) + } + + // check whether the 3rd argument is a username + // command is "@LightningTipBot send 123 @to_user This is the memo" + memo_argn := 2 // argument index at which the memo starts, will be 3 if there is a to_username in command + toUserDb := &lnbits.User{} + to_SpecificUser := false + if len(strings.Split(q.Text, " ")) > 2 { + to_username := strings.Split(q.Text, " ")[2] + if strings.HasPrefix(to_username, "@") { + toUserDb, err = GetUserByTelegramUsername(to_username[1:], bot) // must be without the @ + if err != nil { + //bot.tryDeleteMessage(m) + //bot.trySendMessage(m.Sender, fmt.Sprintf(Translate(ctx, "sendUserHasNoWalletMessage"), toUserStrMention)) + bot.inlineQueryReplyWithError(ctx, + fmt.Sprintf(TranslateUser(ctx, "sendUserHasNoWalletMessage"), to_username), + fmt.Sprintf(TranslateUser(ctx, "inlineQuerySendDescription"), + bot.Telegram.Me.Username)) + return ctx, err + } + memo_argn = 3 // assume that memo starts after the to_username + to_SpecificUser = true + } + } + + // check for memo in command + memo := GetMemoFromCommand(q.Text, memo_argn) + results := make(tb.Results, 0, 2) // []tb.Result + + // helper function to create a result and store inline send data + createResult := func(amountSat int64, title string, description string) { + inlineMessage := fmt.Sprintf(Translate(ctx, "inlineSendMessage"), fromUserStr, thirdparty.FormatSatsWithLKR(amountSat)) + + // modify message if payment is to specific user + if to_SpecificUser { + inlineMessage = fmt.Sprintf("@%s: %s", toUserDb.Telegram.Username, inlineMessage) + } + + if len(memo) > 0 { + inlineMessage = inlineMessage + fmt.Sprintf(Translate(ctx, "inlineSendAppendMemo"), memo) + } + result := &tb.ArticleResult{ + Text: inlineMessage, + Title: title, + Description: description, + ThumbURL: queryImage, + } + id := fmt.Sprintf("inl-send-%d-%d-%s", q.Sender.ID, amountSat, RandStringRunes(5)) + result.ReplyMarkup = &tb.ReplyMarkup{InlineKeyboard: bot.makeSendKeyboard(ctx, id).InlineKeyboard} + result.SetResultID(id) + results = append(results, result) + + inlineSend := InlineSend{ + Base: storage.New(storage.ID(id)), + Message: inlineMessage, + From: fromUser, + To: toUserDb, + To_SpecificUser: to_SpecificUser, + Memo: memo, + Amount: amountSat, + LanguageCode: ctx.Value("publicLanguageCode").(string), + } + bot.Cache.Set(inlineSend.ID, inlineSend, &store.Options{Expiration: 5 * time.Minute}) + } + + // result treating the amount as sats + if balance >= amount { + title := fmt.Sprintf(TranslateUser(ctx, "inlineResultSendTitle"), thirdparty.FormatSatsWithLKR(amount)) + description := fmt.Sprintf(TranslateUser(ctx, "inlineResultSendDescription"), thirdparty.FormatSatsWithLKR(amount)) + createResult(amount, title, description) + } + + // result treating the amount as LKR + amountStr, errArg := getArgumentFromCommand(q.Text, 1) + if errArg == nil { + if f, err := strconv.ParseFloat(strings.ReplaceAll(amountStr, ",", ""), 64); err == nil { + if satFromLKR, err := thirdparty.LKRToSat(f); err == nil && balance >= satFromLKR { + title := fmt.Sprintf("💸 Send %.2f LKR (~%d sat)", f, satFromLKR) + description := fmt.Sprintf("👉 Click to send %.2f LKR (~%d sat) to this chat.", f, satFromLKR) + createResult(satFromLKR, title, description) + } + } + } + + 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) + return ctx, err + } + return ctx, nil +} + +func (bot *TipBot) acceptInlineSendHandler(ctx intercept.Context) (intercept.Context, error) { + c := ctx.Callback() + to := LoadUser(ctx) + tx := &InlineSend{Base: storage.New(storage.ID(c.Data))} + mutex.LockWithContext(ctx, tx.ID) + defer mutex.UnlockWithContext(ctx, tx.ID) + sn, err := tx.Get(tx, bot.Bunt) + // immediatelly set intransaction to block duplicate calls + if err != nil { + // log.Errorf("[acceptInlineSendHandler] %s", err.Error()) + return ctx, err + } + inlineSend := sn.(*InlineSend) + + fromUser := inlineSend.From + if !inlineSend.Active { + log.Errorf("[acceptInlineSendHandler] inline send not active anymore") + return ctx, errors.Create(errors.NotActiveError) + } + + defer inlineSend.Set(inlineSend, bot.Bunt) + + amount := inlineSend.Amount + + // check if this payment goes to a specific user + if inlineSend.To_SpecificUser { + if inlineSend.To.Telegram.ID != to.Telegram.ID { + // log.Infof("User %d is not User %d", inlineSend.To.Telegram.ID, to.Telegram.ID) + return ctx, errors.Create(errors.UnknownError) + } + } else { + // otherwise, we just set it to the user who has clicked + inlineSend.To = to + } + + if fromUser.Telegram.ID == to.Telegram.ID { + bot.trySendMessage(fromUser.Telegram, Translate(ctx, "sendYourselfMessage")) + return ctx, errors.Create(errors.UnknownError) + } + + toUserStrMd := GetUserStrMd(to.Telegram) + fromUserStrMd := GetUserStrMd(fromUser.Telegram) + toUserStr := GetUserStr(to.Telegram) + fromUserStr := GetUserStr(fromUser.Telegram) + + // check if user exists and create a wallet if not + _, exists := bot.UserExists(to.Telegram) + if !exists { + log.Infof("[sendInline] User %s has no wallet.", toUserStr) + to, err = bot.CreateWalletForTelegramUser(to.Telegram) + if err != nil { + errmsg := fmt.Errorf("[sendInline] Error: Could not create wallet for %s", toUserStr) + log.Errorln(errmsg) + return ctx, err + } + } + // set inactive to avoid double-sends + inlineSend.Inactivate(inlineSend, bot.Bunt) + + // todo: user new get username function to get userStrings + transactionMemo := fmt.Sprintf("💸 Send from %s to %s.", fromUserStr, toUserStr) + t := NewTransaction(bot, fromUser, to, amount, TransactionType("inline send")) + t.Memo = transactionMemo + success, err := t.Send() + if !success { + errMsg := fmt.Sprintf("[sendInline] Transaction failed: %s", err.Error()) + log.Errorln(errMsg) + bot.tryEditMessage(c, i18n.Translate(inlineSend.LanguageCode, "inlineSendFailedMessage"), &tb.ReplyMarkup{}) + return ctx, errors.Create(errors.UnknownError) + } + + log.Infof("[💸 sendInline] Send from %s to %s (%d sat).", fromUserStr, toUserStr, amount) + + inlineSend.Message = fmt.Sprintf("%s", fmt.Sprintf(i18n.Translate(inlineSend.LanguageCode, "inlineSendUpdateMessageAccept"), thirdparty.FormatSatsWithLKR(amount), fromUserStrMd, toUserStrMd)) + memo := inlineSend.Memo + if len(memo) > 0 { + inlineSend.Message = inlineSend.Message + fmt.Sprintf(i18n.Translate(inlineSend.LanguageCode, "inlineSendAppendMemo"), memo) + } + if !to.Initialized { + inlineSend.Message += "\n\n" + fmt.Sprintf(i18n.Translate(inlineSend.LanguageCode, "inlineSendCreateWalletMessage"), GetUserStrMd(bot.Telegram.Me)) + } + bot.tryEditMessage(c, inlineSend.Message, &tb.ReplyMarkup{}) + // notify users + bot.trySendMessage(to.Telegram, fmt.Sprintf(i18n.Translate(to.Telegram.LanguageCode, "sendReceivedMessage"), fromUserStrMd, thirdparty.FormatSatsWithLKR(amount))) + bot.trySendMessage(fromUser.Telegram, fmt.Sprintf(i18n.Translate(fromUser.Telegram.LanguageCode, "sendSentMessage"), thirdparty.FormatSatsWithLKR(amount), toUserStrMd)) + if err != nil { + errmsg := fmt.Errorf("[sendInline] Error: Send message to %s: %s", toUserStr, err) + log.Warnln(errmsg) + } + return ctx, nil +} + +func (bot *TipBot) cancelInlineSendHandler(ctx intercept.Context) (intercept.Context, error) { + c := ctx.Callback() + tx := &InlineSend{Base: storage.New(storage.ID(c.Data))} + mutex.LockWithContext(ctx, tx.ID) + defer mutex.UnlockWithContext(ctx, tx.ID) + // immediatelly set intransaction to block duplicate calls + sn, err := tx.Get(tx, bot.Bunt) + if err != nil { + log.Errorf("[cancelInlineSendHandler] %s", err.Error()) + return ctx, err + } + inlineSend := sn.(*InlineSend) + if c.Sender.ID != inlineSend.From.Telegram.ID { + return ctx, errors.Create(errors.UnknownError) + } + bot.tryEditMessage(c, i18n.Translate(inlineSend.LanguageCode, "sendCancelledMessage"), &tb.ReplyMarkup{}) + // set the inlineSend inactive + inlineSend.Active = false + return ctx, inlineSend.Set(inlineSend, bot.Bunt) +} diff --git a/internal/telegram/intercept/context.go b/internal/telegram/intercept/context.go new file mode 100644 index 00000000..39e3bb0d --- /dev/null +++ b/internal/telegram/intercept/context.go @@ -0,0 +1,84 @@ +package intercept + +import ( + "context" + + log "github.com/sirupsen/logrus" + tb "gopkg.in/lightningtipbot/telebot.v3" +) + +type Context struct { + context.Context + TeleContext +} +type TeleContext struct { + tb.Context +} + +type Func func(ctx Context) (Context, error) + +type handlerInterceptor struct { + handler Func + before Chain + after Chain + onDefer Chain +} +type Chain []Func +type Option func(*handlerInterceptor) + +func WithBefore(chain ...Func) Option { + return func(a *handlerInterceptor) { + a.before = chain + } +} +func WithAfter(chain ...Func) Option { + return func(a *handlerInterceptor) { + a.after = chain + } +} +func WithDefer(chain ...Func) Option { + return func(a *handlerInterceptor) { + a.onDefer = chain + } +} + +func intercept(h Context, hm Chain) (Context, error) { + + if hm != nil { + var err error + for _, m := range hm { + h, err = m(h) + if err != nil { + return h, err + } + } + } + return h, nil +} + +func WithHandler(handler Func, option ...Option) tb.HandlerFunc { + hm := &handlerInterceptor{handler: handler} + for _, opt := range option { + opt(hm) + } + return func(c tb.Context) error { + h := Context{TeleContext: TeleContext{Context: c}, Context: context.Background()} + h, err := intercept(h, hm.before) + if err != nil { + log.Traceln(err) + return err + } + defer intercept(h, hm.onDefer) + h, err = hm.handler(h) + if err != nil { + log.Traceln(err) + return err + } + _, err = intercept(h, hm.after) + if err != nil { + log.Traceln(err) + return err + } + return nil + } +} diff --git a/internal/telegram/interceptor.go b/internal/telegram/interceptor.go new file mode 100644 index 00000000..edbcfd4c --- /dev/null +++ b/internal/telegram/interceptor.go @@ -0,0 +1,257 @@ +package telegram + +import ( + "context" + "fmt" + "strconv" + + "github.com/LightningTipBot/LightningTipBot/internal/errors" + + "github.com/LightningTipBot/LightningTipBot/internal/runtime/mutex" + "github.com/LightningTipBot/LightningTipBot/internal/runtime/once" + + "github.com/LightningTipBot/LightningTipBot/internal/i18n" + i18n2 "github.com/nicksnyder/go-i18n/v2/i18n" + + "github.com/LightningTipBot/LightningTipBot/internal/lnbits" + "github.com/LightningTipBot/LightningTipBot/internal/telegram/intercept" + log "github.com/sirupsen/logrus" + tb "gopkg.in/lightningtipbot/telebot.v3" +) + +type Interceptor struct { + Before []intercept.Func + After []intercept.Func + OnDefer []intercept.Func +} + +// singletonClickInterceptor uses the onceMap to determine whether the object k1 already interacted +// with the user k2. If so, it will return an error. +func (bot TipBot) singletonCallbackInterceptor(ctx intercept.Context) (intercept.Context, error) { + if ctx.Callback() != nil { + return ctx, once.Once(ctx.Callback().Data, strconv.FormatInt(ctx.Callback().Sender.ID, 10)) + } + return ctx, errors.Create(errors.InvalidTypeError) +} + +// lockInterceptor invoked as first before interceptor +func (bot TipBot) lockInterceptor(ctx intercept.Context) (intercept.Context, error) { + user := ctx.Sender() + if user != nil { + mutex.Lock(strconv.FormatInt(user.ID, 10)) + return ctx, nil + } + return ctx, errors.Create(errors.InvalidTypeError) +} + +// unlockInterceptor invoked as onDefer interceptor +func (bot TipBot) unlockInterceptor(ctx intercept.Context) (intercept.Context, error) { + user := ctx.Sender() + if user != nil { + mutex.Unlock(strconv.FormatInt(user.ID, 10)) + return ctx, nil + } + return ctx, errors.Create(errors.InvalidTypeError) +} +func (bot TipBot) idInterceptor(ctx intercept.Context) (intercept.Context, error) { + ctx.Context = context.WithValue(ctx, "uid", RandStringRunes(64)) + return ctx, nil +} + +// answerCallbackInterceptor will answer the callback with the given text in the context +func (bot TipBot) answerCallbackInterceptor(ctx intercept.Context) (intercept.Context, error) { + c := ctx.Callback() + if c != nil { + ctxcr := ctx.Value("callback_response") + var res []*tb.CallbackResponse + if ctxcr != nil { + res = append(res, &tb.CallbackResponse{CallbackID: c.ID, Text: ctxcr.(string)}) + } + // if the context wasn't set, still respond with an empty callback response + if len(res) == 0 { + res = append(res, &tb.CallbackResponse{CallbackID: c.ID, Text: ""}) + } + var err error + err = bot.Telegram.Respond(c, res...) + return ctx, err + } + return ctx, errors.Create(errors.InvalidTypeError) +} + +// requireUserInterceptor will return an error if user is not found +// user is here an lnbits.User +func (bot TipBot) requireUserInterceptor(ctx intercept.Context) (intercept.Context, error) { + var user *lnbits.User + var err error + u := ctx.Sender() + if u != nil { + user, err = GetUser(u, bot) + // do not respond to banned users + if bot.UserIsBanned(user) { + ctx.Context = context.WithValue(ctx, "banned", true) + ctx.Context = context.WithValue(ctx, "user", user) + return ctx, errors.Create(errors.InvalidTypeError) + } + if user != nil { + ctx.Context = context.WithValue(ctx, "user", user) + return ctx, err + } + } + return ctx, errors.Create(errors.InvalidTypeError) +} + +// startUserInterceptor will invoke /start if user not exists. +func (bot TipBot) startUserInterceptor(ctx intercept.Context) (intercept.Context, error) { + handler, err := bot.loadUserInterceptor(ctx) + if err != nil { + // user banned + return handler, err + } + // load user + u := ctx.Value("user") + // check user nil + if u != nil { + user := u.(*lnbits.User) + // check wallet nil or !initialized + if user.Wallet == nil || !user.Initialized { + handler, err = bot.startHandler(handler) + if err != nil { + return handler, err + } + return handler, nil + } + } + return handler, nil +} +func (bot TipBot) loadUserInterceptor(ctx intercept.Context) (intercept.Context, error) { + ctx, _ = bot.requireUserInterceptor(ctx) + // if user is banned, also loadUserInterceptor will return an error + if ctx.Value("banned") != nil && ctx.Value("banned").(bool) { + return ctx, errors.Create(errors.InvalidTypeError) + } + return ctx, nil +} +func (bot TipBot) tryLoadUserInterceptor(ctx intercept.Context) (intercept.Context, error) { + ctx, _ = bot.requireUserInterceptor(ctx) + return ctx, nil +} + +// loadReplyToInterceptor Loading the Telegram user with message intercept +func (bot TipBot) loadReplyToInterceptor(ctx intercept.Context) (intercept.Context, error) { + if ctx.Message() != nil { + if ctx.Message().ReplyTo != nil { + if ctx.Message().ReplyTo.Sender != nil { + user, _ := GetUser(ctx.Message().ReplyTo.Sender, bot) + user.Telegram = ctx.Message().ReplyTo.Sender + ctx.Context = context.WithValue(ctx, "reply_to_user", user) + return ctx, nil + + } + } + return ctx, nil + } + return ctx, errors.Create(errors.InvalidTypeError) +} + +func (bot TipBot) localizerInterceptor(ctx intercept.Context) (intercept.Context, error) { + var userLocalizer *i18n2.Localizer + var publicLocalizer *i18n2.Localizer + + // default language is english + publicLocalizer = i18n2.NewLocalizer(i18n.Bundle, "en") + ctx.Context = context.WithValue(ctx, "publicLanguageCode", "en") + ctx.Context = context.WithValue(ctx, "publicLocalizer", publicLocalizer) + + if ctx.Message() != nil { + userLocalizer = i18n2.NewLocalizer(i18n.Bundle, ctx.Message().Sender.LanguageCode) + ctx.Context = context.WithValue(ctx, "userLanguageCode", ctx.Message().Sender.LanguageCode) + ctx.Context = context.WithValue(ctx, "userLocalizer", userLocalizer) + if ctx.Message().Private() { + // in pm overwrite public localizer with user localizer + ctx.Context = context.WithValue(ctx, "publicLanguageCode", ctx.Message().Sender.LanguageCode) + ctx.Context = context.WithValue(ctx, "publicLocalizer", userLocalizer) + } + return ctx, nil + } else if ctx.Callback() != nil { + userLocalizer = i18n2.NewLocalizer(i18n.Bundle, ctx.Callback().Sender.LanguageCode) + ctx.Context = context.WithValue(ctx, "userLanguageCode", ctx.Callback().Sender.LanguageCode) + ctx.Context = context.WithValue(ctx, "userLocalizer", userLocalizer) + return ctx, nil + } else if ctx.Query() != nil { + userLocalizer = i18n2.NewLocalizer(i18n.Bundle, ctx.Query().Sender.LanguageCode) + ctx.Context = context.WithValue(ctx, "userLanguageCode", ctx.Query().Sender.LanguageCode) + ctx.Context = context.WithValue(ctx, "userLocalizer", userLocalizer) + return ctx, nil + } + + return ctx, nil +} + +func (bot TipBot) requirePrivateChatInterceptor(ctx intercept.Context) (intercept.Context, error) { + if ctx.Message() != nil { + if ctx.Message().Chat.Type != tb.ChatPrivate { + return ctx, fmt.Errorf("[requirePrivateChatInterceptor] no private chat") + } + return ctx, nil + } + return ctx, errors.Create(errors.InvalidTypeError) +} + +const photoTag = "" + +func (bot TipBot) logMessageInterceptor(ctx intercept.Context) (intercept.Context, error) { + if ctx.Message() != nil { + + if ctx.Message().Text != "" { + log_string := fmt.Sprintf("[%s:%d %s:%d] %s", ctx.Message().Chat.Title, ctx.Message().Chat.ID, GetUserStr(ctx.Message().Sender), ctx.Message().Sender.ID, ctx.Message().Text) + if ctx.Message().IsReply() { + log_string = fmt.Sprintf("%s -> %s", log_string, GetUserStr(ctx.Message().ReplyTo.Sender)) + } + log.Infof(log_string) + } else if ctx.Message().Photo != nil { + log.Infof("[%s:%d %s:%d] %s", ctx.Message().Chat.Title, ctx.Message().Chat.ID, GetUserStr(ctx.Message().Sender), ctx.Message().Sender.ID, photoTag) + } + return ctx, nil + } else if ctx.Callback() != nil { + log.Infof("[Callback %s:%d] Data: %s", GetUserStr(ctx.Callback().Sender), ctx.Callback().Sender.ID, ctx.Callback().Data) + return ctx, nil + + } + return ctx, errors.Create(errors.InvalidTypeError) +} + +// LoadUser from context +func LoadUserLocalizer(ctx context.Context) *i18n2.Localizer { + u := ctx.Value("userLocalizer") + if u != nil { + return u.(*i18n2.Localizer) + } + return nil +} + +// LoadUser from context +func LoadPublicLocalizer(ctx context.Context) *i18n2.Localizer { + u := ctx.Value("publicLocalizer") + if u != nil { + return u.(*i18n2.Localizer) + } + return nil +} + +// LoadUser from context +func LoadUser(ctx context.Context) *lnbits.User { + u := ctx.Value("user") + if u != nil { + return u.(*lnbits.User) + } + return nil +} + +// LoadReplyToUser from context +func LoadReplyToUser(ctx context.Context) *lnbits.User { + u := ctx.Value("reply_to_user") + if u != nil { + return u.(*lnbits.User) + } + return nil +} diff --git a/internal/telegram/invoice.go b/internal/telegram/invoice.go new file mode 100644 index 00000000..ae9eb7f0 --- /dev/null +++ b/internal/telegram/invoice.go @@ -0,0 +1,293 @@ +package telegram + +import ( + "bytes" + "context" + "fmt" + "strings" + "time" + + "github.com/LightningTipBot/LightningTipBot/internal/telegram/intercept" + "github.com/nbd-wtf/go-nostr" + + "github.com/LightningTipBot/LightningTipBot/internal/errors" + "github.com/LightningTipBot/LightningTipBot/internal/storage" + + "github.com/LightningTipBot/LightningTipBot/internal" + + log "github.com/sirupsen/logrus" + + "github.com/LightningTipBot/LightningTipBot/internal/i18n" + "github.com/LightningTipBot/LightningTipBot/internal/lnbits" + "github.com/LightningTipBot/LightningTipBot/internal/runtime" + "github.com/LightningTipBot/LightningTipBot/internal/str" + "github.com/LightningTipBot/LightningTipBot/internal/thirdparty" + "github.com/LightningTipBot/LightningTipBot/internal/utils" + "github.com/skip2/go-qrcode" + tb "gopkg.in/lightningtipbot/telebot.v3" +) + +type InvoiceEventCallback map[int]EventHandler + +type EventHandler struct { + Function func(event Event) + Type EventType +} + +var InvoiceCallback InvoiceEventCallback + +func initInvoiceEventCallbacks(bot *TipBot) { + InvoiceCallback = InvoiceEventCallback{ + InvoiceCallbackGeneric: EventHandler{Function: bot.notifyInvoiceReceivedEvent, Type: EventTypeInvoice}, + InvoiceCallbackInlineReceive: EventHandler{Function: bot.inlineReceiveEvent, Type: EventTypeInvoice}, + InvoiceCallbackLNURLPayReceive: EventHandler{Function: bot.lnurlReceiveEvent, Type: EventTypeInvoice}, + InvoiceCallbackGroupTicket: EventHandler{Function: bot.groupGetInviteLinkHandler, Type: EventTypeInvoice}, + InvoiceCallbackSatdressProxy: EventHandler{Function: bot.satdressProxyRelayPaymentHandler, Type: EventTypeInvoice}, + InvoiceCallbackGenerateDalle: EventHandler{Function: bot.generateDalleImages, Type: EventTypeInvoice}, + InvoiceCallbackPayJoinTicket: EventHandler{Function: bot.stopJoinTicketTimer, Type: EventTypeInvoice}, + } +} + +type InvoiceEventKey int + +const ( + InvoiceCallbackGeneric = iota + 1 + InvoiceCallbackInlineReceive + InvoiceCallbackLNURLPayReceive + InvoiceCallbackGroupTicket + InvoiceCallbackSatdressProxy + InvoiceCallbackGenerateDalle + InvoiceCallbackPayJoinTicket +) + +const ( + EventTypeInvoice EventType = "invoice" + EventTypeTicketInvoice EventType = "ticket-invoice" +) + +type EventType string + +func AssertEventType(event Event, eventType EventType) error { + if event.Type() != eventType { + return fmt.Errorf("invalid event type") + } + return nil +} + +type Invoice struct { + PaymentHash string `json:"payment_hash"` + PaymentRequest string `json:"payment_request"` + Amount int64 `json:"amount"` + Memo string `json:"memo"` +} +type InvoiceEvent struct { + *Invoice + *storage.Base + User *lnbits.User `json:"user"` // the user that is being paid + Message *tb.Message `json:"message,omitempty"` // the message that the invoice replies to + InvoiceMessage *tb.Message `json:"invoice_message,omitempty"` // the message that displays the invoice + LanguageCode string `json:"languagecode"` // language code of the user + Callback int `json:"func"` // which function to call if the invoice is paid + CallbackData string `json:"callbackdata"` // add some data for the callback + Chat *tb.Chat `json:"chat,omitempty"` // if invoice is supposed to be sent to a particular chat + Payer *lnbits.User `json:"payer,omitempty"` // if a particular user is supposed to pay this + UserCurrency string `json:"usercurrency,omitempty"` // the currency a user selected +} + +func (invoiceEvent InvoiceEvent) Type() EventType { + return EventTypeInvoice +} + +type Event interface { + Type() EventType +} + +func (invoiceEvent InvoiceEvent) Key() string { + return fmt.Sprintf("invoice:%s", invoiceEvent.PaymentHash) +} + +func helpInvoiceUsage(ctx context.Context, errormsg string) string { + if len(errormsg) > 0 { + return fmt.Sprintf(Translate(ctx, "invoiceHelpText"), fmt.Sprintf("%s", errormsg)) + } else { + return fmt.Sprintf(Translate(ctx, "invoiceHelpText"), "") + } +} + +func (bot *TipBot) invoiceHandler(ctx intercept.Context) (intercept.Context, error) { + m := ctx.Message() + // check and print all commands + bot.anyTextHandler(ctx) + user := LoadUser(ctx) + // load user settings + user, err := GetLnbitsUserWithSettings(user.Telegram, *bot) + if user.Wallet == nil { + return ctx, errors.Create(errors.UserNoWalletError) + } + userStr := GetUserStr(user.Telegram) + if m.Chat.Type != tb.ChatPrivate { + // delete message + bot.tryDeleteMessage(m) + return ctx, errors.Create(errors.NoPrivateChatError) + } + // if no amount is in the command, ask for it + amount, err := decodeAmountFromCommand(m.Text) + if (err != nil || amount < 1) && m.Chat.Type == tb.ChatPrivate { + // // no amount was entered, set user state and ask fo""r amount + _, err = bot.askForAmount(ctx, "", "CreateInvoiceState", 0, 0, m.Text) + return ctx, err + } + + // check for memo in command + memo := "Powered by @BitcoinDeepaBot" + if len(strings.Split(m.Text, " ")) > 2 { + memo = GetMemoFromCommand(m.Text, 2) + tag := " (@BitcoinDeepaBot)" + memoMaxLen := 159 - len(tag) + if len(memo) > memoMaxLen { + memo = memo[:memoMaxLen-len(tag)] + } + memo = memo + tag + } + + creatingMsg := bot.trySendMessageEditable(m.Sender, Translate(ctx, "lnurlGettingUserMessage")) + log.Debugf("[/invoice] Creating invoice for %s of %d sat.", userStr, amount) + + currency := user.Settings.Display.DisplayCurrency + if currency == "" { + currency = "BTC" + } + + invoice, err := bot.createInvoiceWithEvent(ctx, user, amount, memo, currency, InvoiceCallbackGeneric, "") + if err != nil { + errmsg := fmt.Sprintf("[/invoice] Could not create an invoice: %s", err.Error()) + bot.tryEditMessage(creatingMsg, Translate(ctx, "errorTryLaterMessage")) + log.Errorln(errmsg) + return ctx, err + } + + // 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.Error()) + bot.tryEditMessage(creatingMsg, Translate(ctx, "errorTryLaterMessage")) + log.Errorln(errmsg) + return ctx, err + } + + // deleting messages will delete the main menu. + //bot.tryDeleteMessage(creatingMsg) + + // 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 ctx, nil +} + +func (bot *TipBot) createInvoiceWithEvent(ctx context.Context, user *lnbits.User, amount int64, memo string, currency string, callback int, callbackData string) (InvoiceEvent, error) { + invoice, err := user.Wallet.Invoice( + lnbits.InvoiceParams{ + Out: false, + Amount: int64(amount), + Memo: memo, + Webhook: internal.GetWebhookURL()}, + bot.Client) + if err != nil { + errmsg := fmt.Sprintf("[/invoice] Could not create an invoice: %s", err.Error()) + log.Errorln(errmsg) + return InvoiceEvent{}, err + } + invoiceEvent := InvoiceEvent{ + Invoice: &Invoice{PaymentHash: invoice.PaymentHash, + PaymentRequest: invoice.PaymentRequest, + Amount: amount, + Memo: memo}, + User: user, + Callback: callback, + CallbackData: callbackData, + LanguageCode: ctx.Value("publicLanguageCode").(string), + UserCurrency: currency, + } + // save invoice struct for later use + runtime.IgnoreError(bot.Bunt.Set(invoiceEvent)) + return invoiceEvent, nil +} + +func (bot *TipBot) notifyInvoiceReceivedEvent(event Event) { + invoiceEvent := event.(*InvoiceEvent) + // do balance check for keyboard update + _, err := bot.GetUserBalance(invoiceEvent.User) + if err != nil { + errmsg := fmt.Sprintf("could not get balance of user %s", GetUserStr(invoiceEvent.User.Telegram)) + log.Errorln(errmsg) + } + + if invoiceEvent.UserCurrency == "" || strings.ToLower(invoiceEvent.UserCurrency) == "btc" { + bot.trySendMessage(invoiceEvent.User.Telegram, fmt.Sprintf(i18n.Translate(invoiceEvent.User.Telegram.LanguageCode, "invoiceReceivedMessage"), thirdparty.FormatSatsWithLKR(invoiceEvent.Amount))) + } else { + fiatAmount, err := SatoshisToFiat(invoiceEvent.Amount, strings.ToUpper(invoiceEvent.UserCurrency)) + if err != nil { + log.Errorln(err) + // fallback to satoshis + bot.trySendMessage(invoiceEvent.User.Telegram, fmt.Sprintf(i18n.Translate(invoiceEvent.User.Telegram.LanguageCode, "invoiceReceivedMessage"), thirdparty.FormatSatsWithLKR(invoiceEvent.Amount))) + return + } + bot.trySendMessage(invoiceEvent.User.Telegram, fmt.Sprintf(i18n.Translate(invoiceEvent.User.Telegram.LanguageCode, "invoiceReceivedCurrencyMessage"), thirdparty.FormatSatsWithLKR(invoiceEvent.Amount), utils.FormatFloatWithCommas(fiatAmount), strings.ToUpper(invoiceEvent.UserCurrency))) + } +} + +type LNURLInvoice struct { + *Invoice + Comment string `json:"comment"` + User *lnbits.User `json:"user"` + CreatedAt time.Time `json:"created_at"` + Paid bool `json:"paid"` + PaidAt time.Time `json:"paid_at"` + From string `json:"from"` + Nip57Receipt nostr.Event `json:"nip57_receipt"` + Nip57ReceiptRelays []string `json:"nip57_receipt_relays"` +} + +func (lnurlInvoice LNURLInvoice) Key() string { + return fmt.Sprintf("lnurl-p:%s", lnurlInvoice.PaymentHash) +} + +func (bot *TipBot) lnurlReceiveEvent(event Event) { + invoiceEvent := event.(*InvoiceEvent) + bot.notifyInvoiceReceivedEvent(invoiceEvent) + + tx := &LNURLInvoice{Invoice: &Invoice{PaymentHash: invoiceEvent.PaymentHash}} + err := bot.Bunt.Get(tx) + log.Debugf("[lnurl-p] Received invoice for %s of %d sat.", GetUserStr(invoiceEvent.User.Telegram), tx.Amount) + if err == nil { + // filter: if tx.Comment includes a URL, return if tx.Amount is less than 100 sat(s) + if len(tx.Comment) > 0 && tx.Amount < 100 { + if strings.Contains(tx.Comment, "http") { + log.Debugf("[lnurl-p] Filtered LNURL comment for %s of %d sat.", GetUserStr(invoiceEvent.User.Telegram), tx.Amount) + return + } + } + + if tx.Amount < 21 { + log.Debugf("[lnurl-p] Filtered LNURL comment for %s of %d sat.", GetUserStr(invoiceEvent.User.Telegram), tx.Amount) + return + } + + // notify user with LNURL comment and sender Information + if len(tx.Comment) > 0 { + if len(tx.From) == 0 { + bot.trySendMessage(tx.User.Telegram, fmt.Sprintf("✉️ %s", str.MarkdownEscape(tx.Comment)), tb.NoPreview) + } else { + bot.trySendMessage(tx.User.Telegram, fmt.Sprintf("✉️ From `%s`: %s", tx.From, str.MarkdownEscape(tx.Comment)), tb.NoPreview) + } + } else if len(tx.From) > 0 { + bot.trySendMessage(tx.User.Telegram, fmt.Sprintf("From `%s`", str.MarkdownEscape(tx.From)), tb.NoPreview) + } + // send out NIP57 zap receipt + if len(tx.Nip57Receipt.Sig) > 0 { + // zapEventSerialized, _ := json.Marshal(tx.Nip57Receipt) + bot.trySendMessage(tx.User.Telegram, "💜 This was a zap on nostr.") + go bot.publishNostrEvent(tx.Nip57Receipt, tx.Nip57ReceiptRelays) + } + } +} diff --git a/internal/telegram/link.go b/internal/telegram/link.go new file mode 100644 index 00000000..64114ac9 --- /dev/null +++ b/internal/telegram/link.go @@ -0,0 +1,58 @@ +package telegram + +import ( + "bytes" + "fmt" + "time" + + "github.com/LightningTipBot/LightningTipBot/internal/telegram/intercept" + + "github.com/LightningTipBot/LightningTipBot/internal" + + log "github.com/sirupsen/logrus" + "github.com/skip2/go-qrcode" + tb "gopkg.in/lightningtipbot/telebot.v3" +) + +func (bot *TipBot) lndhubHandler(ctx intercept.Context) (intercept.Context, error) { + m := ctx.Message() + if internal.Configuration.Lnbits.LnbitsPublicUrl == "" { + bot.trySendMessage(m.Sender, Translate(ctx, "couldNotLinkMessage")) + return ctx, fmt.Errorf("invalid configuration") + } + // first check whether the user is initialized + fromUser := LoadUser(ctx) + linkmsg := bot.trySendMessageEditable(m.Sender, Translate(ctx, "walletConnectMessage")) + + lndhubUrl := fmt.Sprintf("lndhub://admin:%s@%slndhub/ext/", fromUser.Wallet.Adminkey, internal.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.Error()) + log.Errorln(errmsg) + return ctx, err + } + + // send the link to the user + qrmsg := bot.trySendMessage(m.Sender, &tb.Photo{File: tb.File{FileReader: bytes.NewReader(qr)}, Caption: fmt.Sprintf("`%s`", lndhubUrl)}) + // auto delete + go func() { + time.Sleep(time.Second * 60) + bot.tryDeleteMessage(qrmsg) + bot.tryEditMessage(linkmsg, Translate(ctx, "linkHiddenMessage"), tb.Silent) + }() + return ctx, nil +} + +func (bot *TipBot) apiHandler(ctx intercept.Context) (intercept.Context, error) { + m := ctx.Message() + fromUser := LoadUser(ctx) + apimesg := bot.trySendMessageEditable(m.Sender, fmt.Sprintf(Translate(ctx, "apiConnectMessage"), fromUser.Wallet.Adminkey, fromUser.Wallet.Inkey)) + // auto delete + go func() { + time.Sleep(time.Second * 60) + bot.tryEditMessage(apimesg, Translate(ctx, "apiHiddenMessage")) + }() + return ctx, nil +} diff --git a/internal/telegram/lkrsats.go b/internal/telegram/lkrsats.go new file mode 100644 index 00000000..3ce1a803 --- /dev/null +++ b/internal/telegram/lkrsats.go @@ -0,0 +1,38 @@ +package telegram + +import ( + "fmt" + "github.com/LightningTipBot/LightningTipBot/internal/telegram/intercept" + "github.com/LightningTipBot/LightningTipBot/internal/thirdparty" + "github.com/LightningTipBot/LightningTipBot/internal/utils" + log "github.com/sirupsen/logrus" + "strconv" + "strings" +) + +func (bot *TipBot) lkrToSatHandler(ctx intercept.Context) (intercept.Context, error) { + m := ctx.Message() + bot.anyTextHandler(ctx) + + args := strings.Split(m.Text, " ") + if len(args) < 2 { + bot.trySendMessage(m.Sender, Translate(ctx, "convertEnterAmountMessage")) + return ctx, nil + } + amountStr := strings.ReplaceAll(args[1], ",", "") + amount, err := strconv.ParseFloat(amountStr, 64) + if err != nil || amount <= 0 { + bot.trySendMessage(m.Sender, Translate(ctx, "convertInvalidAmountMessage")) + return ctx, nil + } + + lkrPerSat, _, err := thirdparty.GetSatPrice() + if err != nil || lkrPerSat == 0 { + log.Errorf("[lkrToSat] error fetching price: %v", err) + bot.trySendMessage(m.Sender, Translate(ctx, "convertPriceErrorMessage")) + return ctx, err + } + sats := int64(amount / lkrPerSat) + bot.trySendMessage(m.Sender, fmt.Sprintf(Translate(ctx, "convertResultMessage"), utils.FormatFloatWithCommas(amount), utils.FormatSats(sats))) + return ctx, nil +} diff --git a/internal/telegram/lnurl-auth.go b/internal/telegram/lnurl-auth.go new file mode 100644 index 00000000..7072f61e --- /dev/null +++ b/internal/telegram/lnurl-auth.go @@ -0,0 +1,158 @@ +package telegram + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + + "github.com/LightningTipBot/LightningTipBot/internal/network" + "github.com/LightningTipBot/LightningTipBot/internal/telegram/intercept" + + "github.com/LightningTipBot/LightningTipBot/internal/errors" + "github.com/LightningTipBot/LightningTipBot/internal/i18n" + "github.com/LightningTipBot/LightningTipBot/internal/runtime" + "github.com/LightningTipBot/LightningTipBot/internal/runtime/mutex" + "github.com/LightningTipBot/LightningTipBot/internal/storage" + "github.com/imroc/req" + + "github.com/LightningTipBot/LightningTipBot/internal/lnbits" + + lnurl "github.com/fiatjaf/go-lnurl" + log "github.com/sirupsen/logrus" + tb "gopkg.in/lightningtipbot/telebot.v3" +) + +var ( + authConfirmationMenu = &tb.ReplyMarkup{ResizeKeyboard: true} + btnCancelAuth = paymentConfirmationMenu.Data("🚫 Cancel", "cancel_login") + btnAuth = paymentConfirmationMenu.Data("✅ Login", "confirm_login") +) + +type LnurlAuthState struct { + *storage.Base + From *lnbits.User `json:"from"` + LNURLAuthParams lnurl.LNURLAuthParams `json:"LNURLAuthParams"` + Comment string `json:"comment"` + LanguageCode string `json:"languagecode"` + Message *tb.Message `json:"message"` +} + +// lnurlPayHandler1 is invoked when the first lnurl response was a lnurlpay response +// at this point, the user hans't necessarily entered an amount yet +func (bot *TipBot) lnurlAuthHandler(ctx context.Context, m *tb.Message, authParams *LnurlAuthState) (context.Context, error) { + user := LoadUser(ctx) + if user.Wallet == nil { + return ctx, errors.Create(errors.UserNoWalletError) + } + // object that holds all information about the send payment + id := fmt.Sprintf("lnurlauth-%d-%s", m.Sender.ID, RandStringRunes(5)) + authParams.Base = storage.New(storage.ID(id)) + authParams.From = user + authParams.LanguageCode = ctx.Value("publicLanguageCode").(string) + + // // // create inline buttons + btnAuth = paymentConfirmationMenu.Data(Translate(ctx, "loginButtonMessage"), "confirm_login", id) + btnCancelAuth = paymentConfirmationMenu.Data(Translate(ctx, "cancelButtonMessage"), "cancel_login", id) + + paymentConfirmationMenu.Inline( + paymentConfirmationMenu.Row( + btnAuth, + btnCancelAuth), + ) + authParams.Message = bot.trySendMessageEditable(m.Chat, + fmt.Sprintf(Translate(ctx, "confirmLnurlAuthMessager"), + authParams.LNURLAuthParams.CallbackURL.Host, + ), + paymentConfirmationMenu, + ) + + // save to bunt + runtime.IgnoreError(authParams.Set(authParams, bot.Bunt)) + return ctx, nil +} + +func (bot *TipBot) confirmLnurlAuthHandler(ctx intercept.Context) (intercept.Context, error) { + c := ctx.Callback() + tx := &LnurlAuthState{Base: storage.New(storage.ID(c.Data))} + mutex.LockWithContext(ctx, tx.ID) + defer mutex.UnlockWithContext(ctx, tx.ID) + sn, err := tx.Get(tx, bot.Bunt) + // immediatelly set intransaction to block duplicate calls + if err != nil { + log.Errorf("[confirmPayHandler] %s", err.Error()) + return ctx, err + } + lnurlAuthState := sn.(*LnurlAuthState) + + if !lnurlAuthState.Active { + return ctx, fmt.Errorf("LnurlAuthData not active.") + } + + user := LoadUser(ctx) + if user.Wallet == nil { + return ctx, errors.Create(errors.UserNoWalletError) + } + + // statusMsg := bot.trySendMessageEditable(c.Sender, + // Translate(ctx, "lnurlResolvingUrlMessage"), + // ) + bot.editSingleButton(ctx, c.Message, EditSingleButtonParams{Message: lnurlAuthState.Message.Text, ButtonText: Translate(ctx, "lnurlResolvingUrlMessage")}) + + // from fiatjaf/go-lnurl + p := lnurlAuthState.LNURLAuthParams + key, sig, err := user.SignKeyAuth(p.Host, p.K1) + if err != nil { + return ctx, err + } + + var sentsigres lnurl.LNURLResponse + client, err := network.GetClientForScheme(p.CallbackURL) + if err != nil { + return ctx, err + } + r := req.New() + r.SetClient(client) + res, err := r.Get(p.CallbackURL.String(), url.Values{"sig": {sig}, "key": {key}}) + if err != nil { + return ctx, err + } + err = json.Unmarshal(res.Bytes(), &sentsigres) + if err != nil { + return ctx, err + } + if sentsigres.Status == "ERROR" { + bot.tryEditMessage(c, fmt.Sprintf(Translate(ctx, "errorReasonMessage"), sentsigres.Reason)) + return ctx, err + } + bot.editSingleButton(ctx, c.Message, EditSingleButtonParams{ + Message: lnurlAuthState.Message.Text, + ButtonText: Translate(ctx, "lnurlSuccessfulLogin"), + URL: fmt.Sprintf("https://%s", lnurlAuthState.LNURLAuthParams.Host), + }) + return ctx, lnurlAuthState.Inactivate(lnurlAuthState, bot.Bunt) +} + +// cancelPaymentHandler invoked when user clicked cancel on payment confirmation +func (bot *TipBot) cancelLnurlAuthHandler(ctx intercept.Context) (intercept.Context, error) { + c := ctx.Callback() + tx := &LnurlAuthState{Base: storage.New(storage.ID(c.Data))} + mutex.LockWithContext(ctx, tx.ID) + defer mutex.UnlockWithContext(ctx, tx.ID) + sn, err := tx.Get(tx, bot.Bunt) + // immediatelly set intransaction to block duplicate calls + if err != nil { + log.Errorf("[confirmPayHandler] %s", err.Error()) + return ctx, err + } + lnurlAuthState := sn.(*LnurlAuthState) + + // onnly the correct user can press + if lnurlAuthState.From.Telegram.ID != c.Sender.ID { + return ctx, errors.Create(errors.UnknownError) + } + // delete and send instead of edit for the keyboard to pop up after sending + bot.tryEditMessage(c, i18n.Translate(lnurlAuthState.LanguageCode, "loginCancelledMessage"), &tb.ReplyMarkup{}) + // bot.tryEditMessage(c.Message, i18n.Translate(payData.LanguageCode, "paymentCancelledMessage"), &tb.ReplyMarkup{}) + return ctx, lnurlAuthState.Inactivate(lnurlAuthState, bot.Bunt) +} diff --git a/internal/telegram/lnurl-pay.go b/internal/telegram/lnurl-pay.go new file mode 100644 index 00000000..1c0815ea --- /dev/null +++ b/internal/telegram/lnurl-pay.go @@ -0,0 +1,255 @@ +package telegram + +import ( + "context" + "encoding/json" + "fmt" + "io/ioutil" + "net/url" + "strconv" + "strings" + + "github.com/LightningTipBot/LightningTipBot/internal/network" + "github.com/LightningTipBot/LightningTipBot/internal/telegram/intercept" + + "github.com/LightningTipBot/LightningTipBot/internal/errors" + "github.com/LightningTipBot/LightningTipBot/internal/runtime/mutex" + "github.com/LightningTipBot/LightningTipBot/internal/storage" + + "github.com/LightningTipBot/LightningTipBot/internal/lnbits" + "github.com/LightningTipBot/LightningTipBot/internal/runtime" + "github.com/LightningTipBot/LightningTipBot/internal/utils" + + lnurl "github.com/fiatjaf/go-lnurl" + log "github.com/sirupsen/logrus" +) + +// LnurlPayState saves the state of the user for an LNURL payment +type LnurlPayState struct { + *storage.Base + From *lnbits.User `json:"from"` + LNURLPayParams lnurl.LNURLPayParams `json:"LNURLPayParams"` + LNURLPayValues lnurl.LNURLPayValues `json:"LNURLPayValues"` + Amount int64 `json:"amount"` + Comment string `json:"comment"` + DescriptionHash string `json:"descriptionHash,omitempty"` + LanguageCode string `json:"languagecode"` +} + +// lnurlPayHandler is invoked when the first lnurl response was a lnurlpay response +// at this point, the user hasn't necessarily entered an amount yet +func (bot *TipBot) lnurlPayHandler(ctx intercept.Context, payParams *LnurlPayState) (context.Context, error) { + m := ctx.Message() + user := LoadUser(ctx) + if user.Wallet == nil { + return ctx, fmt.Errorf("user has no wallet") + } + // object that holds all information about the send payment + id := fmt.Sprintf("lnurlp-%d-%s", m.Sender.ID, RandStringRunes(5)) + payParams.Base = storage.New(storage.ID(id)) + payParams.From = user + payParams.LanguageCode = ctx.Value("publicLanguageCode").(string) + + // first we check whether an amount is present in the command + amount, amount_err := decodeAmountFromCommand(m.Text) + + // we need to figure out whether the memo starts at position 2 or 3 + // so either /lnurl [memo] or /lnurl [memo] + memoStartsAt := 2 + if amount_err == nil { + // amount was present + memoStartsAt = 3 + } + // check if memo is present in command + memo := GetMemoFromCommand(m.Text, memoStartsAt) + // shorten memo to allowed length + if len(memo) > int(payParams.LNURLPayParams.CommentAllowed) { + memo = memo[:payParams.LNURLPayParams.CommentAllowed] + } + if len(memo) > 0 { + payParams.Comment = memo + } + + // amount is already present in the command, i.e., /lnurl + // amount not in allowed range from LNURL + if amount_err == nil && + (int64(amount) > (payParams.LNURLPayParams.MaxSendable/1000) || int64(amount) < (payParams.LNURLPayParams.MinSendable/1000)) && + (payParams.LNURLPayParams.MaxSendable != 0 && payParams.LNURLPayParams.MinSendable != 0) { // only if max and min are set + err := fmt.Errorf("amount not in range") + log.Warnf("[lnurlPayHandler] Error: %s", err.Error()) + bot.trySendMessage(m.Sender, fmt.Sprintf(Translate(ctx, "lnurlInvalidAmountRangeMessage"), utils.FormatSats(payParams.LNURLPayParams.MinSendable/1000), utils.FormatSats(payParams.LNURLPayParams.MaxSendable/1000))) + ResetUserState(user, bot) + return ctx, err + } + // set also amount in the state of the user + payParams.Amount = amount * 1000 // save as mSat + + // calculate description hash of the metadata and save it + descriptionHash, err := bot.DescriptionHash(payParams.LNURLPayParams.Metadata, "") + if err != nil { + return nil, err + } + payParams.DescriptionHash = descriptionHash + + // add result to persistent struct + runtime.IgnoreError(payParams.Set(payParams, bot.Bunt)) + + // now we actualy check whether the amount was in the command and if not, ask for it + if payParams.LNURLPayParams.MinSendable == payParams.LNURLPayParams.MaxSendable { + amount = payParams.LNURLPayParams.MaxSendable / 1000 + payParams.Amount = amount * 1000 // save as mSat + } else if amount_err != nil || amount < 1 { + // // no amount was entered, set user state and ask for amount + bot.askForAmount(ctx, id, "LnurlPayState", payParams.LNURLPayParams.MinSendable, payParams.LNURLPayParams.MaxSendable, m.Text) + return ctx, nil + } + + // We need to save the pay state in the user state so we can load the payment in the next ctx + paramsJson, err := json.Marshal(payParams) + if err != nil { + log.Errorf("[lnurlPayHandler] Error: %s", err.Error()) + // bot.trySendMessage(m.Sender, err.Error()) + return ctx, err + } + SetUserState(user, bot, lnbits.UserHasEnteredAmount, string(paramsJson)) + // directly go to confirm + bot.lnurlPayHandlerSend(ctx) + return ctx, nil +} + +// lnurlPayHandlerSend is invoked when the user has delivered an amount and is ready to pay +func (bot *TipBot) lnurlPayHandlerSend(ctx intercept.Context) (intercept.Context, error) { + m := ctx.Message() + user := LoadUser(ctx) + if user.Wallet == nil { + return ctx, errors.Create(errors.UserNoWalletError) + } + statusMsg := bot.trySendMessage(m.Sender, Translate(ctx, "lnurlGettingUserMessage")) + + // assert that user has entered an amount + if user.StateKey != lnbits.UserHasEnteredAmount { + log.Errorln("[lnurlPayHandlerSend] state keys don't match") + bot.tryEditMessage(statusMsg, Translate(ctx, "errorTryLaterMessage")) + return ctx, fmt.Errorf("wrong state key") + } + + // read the enter amount state from user.StateData + var enterAmountData EnterAmountStateData + err := json.Unmarshal([]byte(user.StateData), &enterAmountData) + if err != nil { + log.Errorf("[lnurlPayHandlerSend] Error: %s", err.Error()) + bot.tryEditMessage(statusMsg, Translate(ctx, "errorTryLaterMessage")) + return ctx, err + } + // use the enter amount state of the user to load the LNURL payment state + tx := &LnurlPayState{Base: storage.New(storage.ID(enterAmountData.ID))} + mutex.LockWithContext(ctx, tx.ID) + defer mutex.UnlockWithContext(ctx, tx.ID) + fn, err := tx.Get(tx, bot.Bunt) + if err != nil { + log.Errorf("[lnurlPayHandlerSend] Error: %s", err.Error()) + bot.tryEditMessage(statusMsg, Translate(ctx, "errorTryLaterMessage")) + return ctx, err + } + lnurlPayState := fn.(*LnurlPayState) + + // LnurlPayState loaded + + callbackUrl, err := url.Parse(lnurlPayState.LNURLPayParams.Callback) + if err != nil { + log.Errorf("[lnurlPayHandlerSend] Error: %s", err.Error()) + bot.tryEditMessage(statusMsg, Translate(ctx, "errorTryLaterMessage")) + return ctx, err + } + client, err := network.GetClientForScheme(callbackUrl) + if err != nil { + log.Errorf("[lnurlPayHandlerSend] Error: %s", err.Error()) + bot.tryEditMessage(statusMsg, Translate(ctx, "errorTryLaterMessage")) + return ctx, err + } + qs := callbackUrl.Query() + // add amount to query string + qs.Set("amount", strconv.FormatInt(lnurlPayState.Amount, 10)) // msat + // add comment to query string + if len(lnurlPayState.Comment) > 0 { + qs.Set("comment", lnurlPayState.Comment) + } + + callbackUrl.RawQuery = qs.Encode() + + res, err := client.Get(callbackUrl.String()) + if err != nil { + log.Errorf("[lnurlPayHandlerSend] Error: %s", err.Error()) + bot.tryEditMessage(statusMsg, Translate(ctx, "errorTryLaterMessage")) + return ctx, err + } + body, err := ioutil.ReadAll(res.Body) + if err != nil { + log.Errorf("[lnurlPayHandlerSend] Error: %s", err.Error()) + bot.tryEditMessage(statusMsg, Translate(ctx, "errorTryLaterMessage")) + return ctx, err + } + + var response2 lnurl.LNURLPayValues + json.Unmarshal(body, &response2) + if response2.Status == "ERROR" || len(response2.PR) < 1 { + error_reason := "Could not receive invoice." + if len(response2.Reason) > 0 { + error_reason = response2.Reason + } + log.Errorf("[lnurlPayHandlerSend] Error in LNURLPayValues: %s", error_reason) + bot.tryEditMessage(statusMsg, fmt.Sprintf(Translate(ctx, "lnurlPaymentFailed"), error_reason)) + return ctx, fmt.Errorf("error in LNURLPayValues: %s", error_reason) + } + + // all good + lnurlPayState.LNURLPayValues = response2 + // add result to persistent struct + runtime.IgnoreError(lnurlPayState.Set(lnurlPayState, bot.Bunt)) + bot.Telegram.Delete(statusMsg) + + // store success action in context for printing after the payHandler + ctx.Context = context.WithValue(ctx, "SuccessAction", lnurlPayState.LNURLPayValues.SuccessAction) + + m.Text = fmt.Sprintf("/pay %s", response2.PR) + return bot.payHandler(ctx) +} + +func (bot *TipBot) sendToLightningAddress(ctx intercept.Context, address string, amount int64) (intercept.Context, error) { + m := ctx.Message() + split := strings.Split(address, "@") + if len(split) != 2 { + return ctx, 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 ctx, err + } + + if amount > 0 { + // only when amount is given, we will also add a comment to the command + // we do this because if the amount is not given, we will have to ask for it later + // in the lnurl handler and we don't want to add another step where we ask for a comment + // the command to pay to lnurl with comment is /lnurl + // check if comment is presentin lnrul-p + memo := GetMemoFromCommand(m.Text, 3) + m.Text = fmt.Sprintf("/lnurl %d %s", amount, lnurl) + // shorten comment to allowed length + if len(memo) > 0 { + m.Text = m.Text + " " + memo + } + } else { + // no amount was given so we will just send the lnurl + // this will invoke the "enter amount" dialog in the lnurl handler + m.Text = fmt.Sprintf("/lnurl %s", lnurl) + } + return bot.lnurlHandler(ctx) +} diff --git a/internal/telegram/lnurl-withdraw.go b/internal/telegram/lnurl-withdraw.go new file mode 100644 index 00000000..5978ecd1 --- /dev/null +++ b/internal/telegram/lnurl-withdraw.go @@ -0,0 +1,353 @@ +package telegram + +import ( + "context" + "encoding/json" + "fmt" + "io/ioutil" + "net/url" + + "github.com/LightningTipBot/LightningTipBot/internal/network" + "github.com/LightningTipBot/LightningTipBot/internal/telegram/intercept" + + "github.com/LightningTipBot/LightningTipBot/internal/errors" + + "github.com/LightningTipBot/LightningTipBot/internal/runtime/mutex" + "github.com/LightningTipBot/LightningTipBot/internal/storage" + + "github.com/LightningTipBot/LightningTipBot/internal" + "github.com/LightningTipBot/LightningTipBot/internal/i18n" + "github.com/LightningTipBot/LightningTipBot/internal/lnbits" + "github.com/LightningTipBot/LightningTipBot/internal/runtime" + + "github.com/LightningTipBot/LightningTipBot/internal/str" + "github.com/LightningTipBot/LightningTipBot/internal/utils" + lnurl "github.com/fiatjaf/go-lnurl" + log "github.com/sirupsen/logrus" + tb "gopkg.in/lightningtipbot/telebot.v3" +) + +var ( + withdrawConfirmationMenu = &tb.ReplyMarkup{ResizeKeyboard: true} + btnCancelWithdraw = paymentConfirmationMenu.Data("🚫 Cancel", "cancel_withdraw") + btnWithdraw = paymentConfirmationMenu.Data("✅ Withdraw", "confirm_withdraw") +) + +// LnurlWithdrawState saves the state of the user for an LNURL payment +type LnurlWithdrawState struct { + *storage.Base + From *lnbits.User `json:"from"` + LNURLWithdrawResponse lnurl.LNURLWithdrawResponse `json:"LNURLWithdrawResponse"` + LNURResponse lnurl.LNURLResponse `json:"LNURLResponse"` + Amount int64 `json:"amount"` + Comment string `json:"comment"` + LanguageCode string `json:"languagecode"` + Success bool `json:"success"` + Invoice lnbits.Invoice `json:"invoice"` + Message string `json:"message"` +} + +type EditSingleButtonParams struct { + Message string + ButtonText string + Data string + URL string +} + +// editSingleButton edits a message to display a single button (for something like a progress indicator) +func (bot *TipBot) editSingleButton(ctx context.Context, m *tb.Message, params EditSingleButtonParams) (*tb.Message, error) { + if len(params.URL) > 0 && len(params.Data) > 0 { + return &tb.Message{}, fmt.Errorf("URL and Data cannot be set at the same time.") + } + if len(params.URL) == 0 && len(params.Data) == 0 { + params.Data = "placeholder" + } + return bot.tryEditMessage( + m, + params.Message, + &tb.ReplyMarkup{ + InlineKeyboard: [][]tb.InlineButton{ + {tb.InlineButton{Text: params.ButtonText, Data: params.Data, URL: params.URL}}, + }, + }, + ) + +} + +// lnurlWithdrawHandler is invoked when the first lnurl response was a lnurl-withdraw response +// at this point, the user hans't necessarily entered an amount yet +func (bot *TipBot) lnurlWithdrawHandler(ctx intercept.Context, withdrawParams *LnurlWithdrawState) { + m := ctx.Message() + user := LoadUser(ctx) + if user.Wallet == nil { + return + } + // object that holds all information about the send payment + id := fmt.Sprintf("lnurlw-%d-%s", m.Sender.ID, RandStringRunes(5)) + + withdrawParams.Base = storage.New(storage.ID(id)) + withdrawParams.From = user + withdrawParams.LanguageCode = ctx.Value("publicLanguageCode").(string) + + // first we check whether an amount is present in the command + amount, amount_err := decodeAmountFromCommand(m.Text) + + // amount is already present in the command, i.e., /lnurl + // amount not in allowed range from LNURL + if amount_err == nil && + (int64(amount) > (withdrawParams.LNURLWithdrawResponse.MaxWithdrawable/1000) || int64(amount) < (withdrawParams.LNURLWithdrawResponse.MinWithdrawable/1000)) && + (withdrawParams.LNURLWithdrawResponse.MaxWithdrawable != 0 && withdrawParams.LNURLWithdrawResponse.MinWithdrawable != 0) { // only if max and min are set + err := fmt.Errorf("amount not in range") + log.Warnf("[lnurlWithdrawHandler] Error: %s", err.Error()) + bot.trySendMessage(m.Sender, fmt.Sprintf(Translate(ctx, "lnurlInvalidAmountRangeMessage"), utils.FormatSats(withdrawParams.LNURLWithdrawResponse.MinWithdrawable/1000), utils.FormatSats(withdrawParams.LNURLWithdrawResponse.MaxWithdrawable/1000))) + ResetUserState(user, bot) + return + } + + // if no amount is entered, and if only one amount is possible, we use it + if amount_err != nil && withdrawParams.LNURLWithdrawResponse.MaxWithdrawable == withdrawParams.LNURLWithdrawResponse.MinWithdrawable { + amount = int64(withdrawParams.LNURLWithdrawResponse.MaxWithdrawable / 1000) + amount_err = nil + } + + // set also amount in the state of the user + withdrawParams.Amount = amount * 1000 // save as mSat + + // add result to persistent struct + runtime.IgnoreError(withdrawParams.Set(withdrawParams, bot.Bunt)) + + // now we actualy check whether the amount was in the command and if not, ask for it + if amount_err != nil || amount < 1 { + // // no amount was entered, set user state and ask for amount + bot.askForAmount(ctx, id, "LnurlWithdrawState", withdrawParams.LNURLWithdrawResponse.MinWithdrawable, withdrawParams.LNURLWithdrawResponse.MaxWithdrawable, m.Text) + return + } + + // We need to save the pay state in the user state so we can load the payment in the next ctx + paramsJson, err := json.Marshal(withdrawParams) + if err != nil { + log.Errorf("[lnurlWithdrawHandler] Error: %s", err.Error()) + // bot.trySendMessage(m.Sender, err.Error()) + return + } + SetUserState(user, bot, lnbits.UserHasEnteredAmount, string(paramsJson)) + // directly go to confirm + bot.lnurlWithdrawHandlerWithdraw(ctx) + return +} + +// lnurlWithdrawHandlerWithdraw is invoked when the user has delivered an amount and is ready to pay +func (bot *TipBot) lnurlWithdrawHandlerWithdraw(ctx intercept.Context) (intercept.Context, error) { + m := ctx.Message() + user := LoadUser(ctx) + if user.Wallet == nil { + return ctx, errors.Create(errors.UserNoWalletError) + } + statusMsg := bot.trySendMessageEditable(m.Sender, Translate(ctx, "lnurlPreparingWithdraw")) + + // assert that user has entered an amount + if user.StateKey != lnbits.UserHasEnteredAmount { + log.Errorln("[lnurlWithdrawHandlerWithdraw] state keys don't match") + bot.tryEditMessage(statusMsg, Translate(ctx, "errorTryLaterMessage")) + return ctx, fmt.Errorf("wrong state key") + } + + // read the enter amount state from user.StateData + var enterAmountData EnterAmountStateData + err := json.Unmarshal([]byte(user.StateData), &enterAmountData) + if err != nil { + log.Errorf("[lnurlWithdrawHandlerWithdraw] Error: %s", err.Error()) + bot.tryEditMessage(statusMsg, Translate(ctx, "errorTryLaterMessage")) + return ctx, err + } + + // use the enter amount state of the user to load the LNURL payment state + tx := &LnurlWithdrawState{Base: storage.New(storage.ID(enterAmountData.ID))} + mutex.LockWithContext(ctx, tx.ID) + defer mutex.UnlockWithContext(ctx, tx.ID) + fn, err := tx.Get(tx, bot.Bunt) + if err != nil { + log.Errorf("[lnurlWithdrawHandlerWithdraw] Error: %s", err.Error()) + bot.tryEditMessage(statusMsg, Translate(ctx, "errorTryLaterMessage")) + return ctx, err + } + var lnurlWithdrawState *LnurlWithdrawState + switch fn.(type) { + case *LnurlWithdrawState: + lnurlWithdrawState = fn.(*LnurlWithdrawState) + default: + log.Errorf("[lnurlWithdrawHandlerWithdraw] invalid type") + bot.tryEditMessage(statusMsg, Translate(ctx, "errorTryLaterMessage")) + return ctx, fmt.Errorf("invalid type") + } + + confirmText := fmt.Sprintf(Translate(ctx, "confirmLnurlWithdrawMessage"), lnurlWithdrawState.Amount/1000) + if len(lnurlWithdrawState.LNURLWithdrawResponse.DefaultDescription) > 0 { + confirmText = confirmText + fmt.Sprintf(Translate(ctx, "confirmPayAppendMemo"), str.MarkdownEscape(lnurlWithdrawState.LNURLWithdrawResponse.DefaultDescription)) + } + lnurlWithdrawState.Message = confirmText + + // create inline buttons + withdrawButton := paymentConfirmationMenu.Data(Translate(ctx, "withdrawButtonMessage"), "confirm_withdraw", lnurlWithdrawState.ID) + btnCancelWithdraw := paymentConfirmationMenu.Data(Translate(ctx, "cancelButtonMessage"), "cancel_withdraw", lnurlWithdrawState.ID) + + withdrawConfirmationMenu.Inline( + withdrawConfirmationMenu.Row( + withdrawButton, + btnCancelWithdraw), + ) + + bot.tryEditMessage(statusMsg, confirmText, withdrawConfirmationMenu) + + // // add response to persistent struct + // lnurlWithdrawState.LNURResponse = response2 + runtime.IgnoreError(lnurlWithdrawState.Set(lnurlWithdrawState, bot.Bunt)) + return ctx, nil +} + +// confirmPayHandler when user clicked pay on payment confirmation +func (bot *TipBot) confirmWithdrawHandler(ctx intercept.Context) (intercept.Context, error) { + c := ctx.Callback() + tx := &LnurlWithdrawState{Base: storage.New(storage.ID(c.Data))} + mutex.LockWithContext(ctx, tx.ID) + defer mutex.UnlockWithContext(ctx, tx.ID) + fn, err := tx.Get(tx, bot.Bunt) + if err != nil { + log.Errorf("[confirmWithdrawHandler] Error: %s", err.Error()) + return ctx, err + } + + var lnurlWithdrawState *LnurlWithdrawState + switch fn.(type) { + case *LnurlWithdrawState: + lnurlWithdrawState = fn.(*LnurlWithdrawState) + default: + log.Errorf("[confirmWithdrawHandler] invalid type") + return ctx, errors.Create(errors.InvalidTypeError) + } + // onnly the correct user can press + if lnurlWithdrawState.From.Telegram.ID != c.Sender.ID { + return ctx, errors.Create(errors.UnknownError) + } + if !lnurlWithdrawState.Active { + log.Errorf("[confirmPayHandler] send not active anymore") + bot.tryEditMessage(c, i18n.Translate(lnurlWithdrawState.LanguageCode, "errorTryLaterMessage"), &tb.ReplyMarkup{}) + bot.tryDeleteMessage(c) + return ctx, errors.Create(errors.NotActiveError) + } + defer lnurlWithdrawState.Set(lnurlWithdrawState, bot.Bunt) + + user := LoadUser(ctx) + if user.Wallet == nil { + bot.tryDeleteMessage(c) + return ctx, errors.Create(errors.UserNoWalletError) + } + + // reset state immediately + ResetUserState(user, bot) + + // update button text + bot.editSingleButton(ctx, c.Message, EditSingleButtonParams{Message: lnurlWithdrawState.Message, ButtonText: i18n.Translate(lnurlWithdrawState.LanguageCode, "lnurlPreparingWithdraw")}) + + callbackUrl, err := url.Parse(lnurlWithdrawState.LNURLWithdrawResponse.Callback) + if err != nil { + log.Errorf("[lnurlWithdrawHandlerWithdraw] Error: %s", err.Error()) + // bot.trySendMessage(c.Sender, Translate(handler.Ctx, "errorTryLaterMessage")) + bot.editSingleButton(ctx, c.Message, EditSingleButtonParams{Message: lnurlWithdrawState.Message, ButtonText: i18n.Translate(lnurlWithdrawState.LanguageCode, "errorTryLaterMessage")}) + return ctx, err + } + + // generate an invoice and add the pr to the request + // generate invoice + invoice, err := user.Wallet.Invoice( + lnbits.InvoiceParams{ + Out: false, + Amount: int64(lnurlWithdrawState.Amount) / 1000, + Memo: "Withdraw", + Webhook: internal.GetWebhookURL()}, + bot.Client) + if err != nil { + errmsg := fmt.Sprintf("[lnurlWithdrawHandlerWithdraw] Could not create an invoice: %s", err.Error()) + log.Errorln(errmsg) + bot.editSingleButton(ctx, c.Message, EditSingleButtonParams{Message: lnurlWithdrawState.Message, ButtonText: i18n.Translate(lnurlWithdrawState.LanguageCode, "errorTryLaterMessage")}) + return ctx, err + } + lnurlWithdrawState.Invoice = invoice + + qs := callbackUrl.Query() + // add amount to query string + qs.Set("pr", invoice.PaymentRequest) + qs.Set("k1", lnurlWithdrawState.LNURLWithdrawResponse.K1) + callbackUrl.RawQuery = qs.Encode() + + // lnurlWithdrawState loaded + client, err := network.GetClientForScheme(callbackUrl) + if err != nil { + log.Errorf("[lnurlWithdrawHandlerWithdraw] Error: %s", err.Error()) + // bot.trySendMessage(c.Sender, Translate(ctx, "errorTryLaterMessage")) + bot.editSingleButton(ctx, c.Message, EditSingleButtonParams{Message: lnurlWithdrawState.Message, ButtonText: i18n.Translate(lnurlWithdrawState.LanguageCode, "errorTryLaterMessage")}) + return ctx, err + } + res, err := client.Get(callbackUrl.String()) + if err != nil || res.StatusCode >= 300 { + log.Errorf("[lnurlWithdrawHandlerWithdraw] Failed.") + // bot.trySendMessage(c.Sender, Translate(handler.Ctx, "errorTryLaterMessage")) + bot.editSingleButton(ctx, c.Message, EditSingleButtonParams{Message: lnurlWithdrawState.Message, ButtonText: i18n.Translate(lnurlWithdrawState.LanguageCode, "errorTryLaterMessage")}) + return ctx, errors.New(errors.UnknownError, err) + } + body, err := ioutil.ReadAll(res.Body) + if err != nil { + log.Errorf("[lnurlWithdrawHandlerWithdraw] Error: %s", err.Error()) + // bot.trySendMessage(c.Sender, Translate(handler.Ctx, "errorTryLaterMessage")) + bot.editSingleButton(ctx, c.Message, EditSingleButtonParams{Message: lnurlWithdrawState.Message, ButtonText: i18n.Translate(lnurlWithdrawState.LanguageCode, "errorTryLaterMessage")}) + return ctx, err + } + + // parse the response + var response2 lnurl.LNURLResponse + json.Unmarshal(body, &response2) + if response2.Status == "OK" { + // update button text + bot.editSingleButton(ctx, c.Message, EditSingleButtonParams{Message: lnurlWithdrawState.Message, ButtonText: i18n.Translate(lnurlWithdrawState.LanguageCode, "lnurlWithdrawSuccess")}) + + } else { + log.Errorf("[lnurlWithdrawHandlerWithdraw] LNURLWithdraw failed.") + // update button text + bot.editSingleButton(ctx, c.Message, EditSingleButtonParams{Message: lnurlWithdrawState.Message, ButtonText: i18n.Translate(lnurlWithdrawState.LanguageCode, "lnurlWithdrawFailed")}) + return ctx, errors.New(errors.UnknownError, fmt.Errorf("LNURLWithdraw failed")) + } + + // add response to persistent struct + lnurlWithdrawState.LNURResponse = response2 + return ctx, lnurlWithdrawState.Set(lnurlWithdrawState, bot.Bunt) + +} + +// cancelPaymentHandler invoked when user clicked cancel on payment confirmation +func (bot *TipBot) cancelWithdrawHandler(ctx intercept.Context) (intercept.Context, error) { + c := ctx.Callback() + // reset state immediately + user := LoadUser(ctx) + ResetUserState(user, bot) + tx := &LnurlWithdrawState{Base: storage.New(storage.ID(c.Data))} + mutex.LockWithContext(ctx, tx.ID) + defer mutex.UnlockWithContext(ctx, tx.ID) + fn, err := tx.Get(tx, bot.Bunt) + if err != nil { + log.Errorf("[cancelWithdrawHandler] Error: %s", err.Error()) + return ctx, err + } + var lnurlWithdrawState *LnurlWithdrawState + switch fn.(type) { + case *LnurlWithdrawState: + lnurlWithdrawState = fn.(*LnurlWithdrawState) + default: + log.Errorf("[cancelWithdrawHandler] invalid type") + } + // onnly the correct user can press + if lnurlWithdrawState.From.Telegram.ID != c.Sender.ID { + return ctx, errors.Create(errors.UnknownError) + } + bot.tryEditMessage(c, i18n.Translate(lnurlWithdrawState.LanguageCode, "lnurlWithdrawCancelled"), &tb.ReplyMarkup{}) + return ctx, lnurlWithdrawState.Inactivate(lnurlWithdrawState, bot.Bunt) +} diff --git a/internal/telegram/lnurl.go b/internal/telegram/lnurl.go new file mode 100644 index 00000000..0afe20e9 --- /dev/null +++ b/internal/telegram/lnurl.go @@ -0,0 +1,294 @@ +package telegram + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "fmt" + "io/ioutil" + "net/url" + "strings" + + "github.com/LightningTipBot/LightningTipBot/internal/network" + "github.com/LightningTipBot/LightningTipBot/internal/telegram/intercept" + + "github.com/LightningTipBot/LightningTipBot/internal/errors" + + "github.com/LightningTipBot/LightningTipBot/internal" + "github.com/tidwall/gjson" + + "github.com/LightningTipBot/LightningTipBot/internal/lnbits" + lnurl "github.com/fiatjaf/go-lnurl" + log "github.com/sirupsen/logrus" + "github.com/skip2/go-qrcode" + tb "gopkg.in/lightningtipbot/telebot.v3" +) + +func (bot *TipBot) cancelLnUrlHandler(c *tb.Callback) { +} + +// lnurlHandler is invoked on /lnurl command +func (bot *TipBot) lnurlHandler(ctx intercept.Context) (intercept.Context, error) { + // commands: + // /lnurl + // /lnurl + // or /lnurl + m := ctx.Message() + if m.Chat.Type != tb.ChatPrivate { + return ctx, errors.Create(errors.NoPrivateChatError) + } + log.Infof("[lnurlHandler] %s", m.Text) + user := LoadUser(ctx) + if user.Wallet == nil { + return ctx, errors.Create(errors.UserNoWalletError) + } + + // if only /lnurl is entered, show the lnurl of the user + if m.Text == "/lnurl" { + return bot.lnurlReceiveHandler(ctx) + } + statusMsg := bot.trySendMessageEditable(m.Sender, Translate(ctx, "lnurlResolvingUrlMessage")) + + var lnurlSplit string + split := strings.Split(m.Text, " ") + if _, err := decodeAmountFromCommand(m.Text); err == nil { + // command is /lnurl 123 [memo] + if len(split) > 2 { + lnurlSplit = split[2] + } + } else if len(split) > 1 { + lnurlSplit = split[1] + } else { + bot.tryEditMessage(statusMsg, fmt.Sprintf(Translate(ctx, "errorReasonMessage"), "Could not parse command.")) + log.Warnln("[/lnurl] Could not parse command.") + return ctx, errors.Create(errors.InvalidSyntaxError) + } + + // get rid of the URI prefix + lnurlSplit = strings.TrimPrefix(lnurlSplit, "lightning:") + + // log.Debugf("[lnurlHandler] lnurlSplit: %s", lnurlSplit) + // HandleLNURL by fiatjaf/go-lnurl + _, params, err := bot.HandleLNURL(lnurlSplit) + if err != nil { + bot.tryEditMessage(statusMsg, fmt.Sprintf(Translate(ctx, "errorReasonMessage"), "LNURL error.")) + // bot.tryEditMessage(statusMsg, fmt.Sprintf(Translate(ctx, "errorReasonMessage"), err.Error())) + log.Warnf("[HandleLNURL] Error: %s", err.Error()) + return ctx, err + } + switch params.(type) { + case lnurl.LNURLAuthParams: + authParams := &LnurlAuthState{LNURLAuthParams: params.(lnurl.LNURLAuthParams)} + log.Infof("[LNURL-auth] %s", authParams.LNURLAuthParams.Callback) + bot.tryDeleteMessage(statusMsg) + ctx.Context, err = bot.lnurlAuthHandler(ctx, m, authParams) + return ctx, err + + case lnurl.LNURLPayParams: + payParams := &LnurlPayState{LNURLPayParams: params.(lnurl.LNURLPayParams)} + log.Infof("[LNURL-p] %s", payParams.LNURLPayParams.Callback) + bot.tryDeleteMessage(statusMsg) + + // display the metadata image from the first LNURL-p response + if len(payParams.LNURLPayParams.Metadata.Image.Bytes) > 0 { + bot.trySendMessage(m.Sender, &tb.Photo{ + File: tb.File{FileReader: bytes.NewReader(payParams.LNURLPayParams.Metadata.Image.Bytes)}, + Caption: payParams.LNURLPayParams.Metadata.Description}) + } else if len(payParams.LNURLPayParams.Metadata.Description) > 0 { + // display the metadata text from the first LNURL-p response + // if there was no photo in the last step + bot.trySendMessage(m.Sender, fmt.Sprintf("`%s`", payParams.LNURLPayParams.Metadata.Description)) + } + // ask whether to make payment + bot.lnurlPayHandler(ctx, payParams) + + case lnurl.LNURLWithdrawResponse: + withdrawParams := &LnurlWithdrawState{LNURLWithdrawResponse: params.(lnurl.LNURLWithdrawResponse)} + log.Infof("[LNURL-w] %s", withdrawParams.LNURLWithdrawResponse.Callback) + bot.tryDeleteMessage(statusMsg) + bot.lnurlWithdrawHandler(ctx, withdrawParams) + default: + if err == nil { + err = fmt.Errorf("invalid LNURL type") + } + log.Warnln(err) + bot.tryEditMessage(statusMsg, fmt.Sprintf(Translate(ctx, "errorReasonMessage"), err.Error())) + // bot.trySendMessage(m.Sender, err.Error()) + return ctx, err + } + return ctx, nil +} + +func (bot *TipBot) UserGetLightningAddress(user *lnbits.User) (string, error) { + if len(user.Telegram.Username) > 0 { + return fmt.Sprintf("%s@%s", strings.ToLower(user.Telegram.Username), strings.ToLower(internal.Configuration.Bot.LNURLHostUrl.Hostname())), nil + } else { + lnaddr, err := bot.UserGetAnonLightningAddress(user) + return lnaddr, err + } +} + +func (bot *TipBot) UserGetAnonLightningAddress(user *lnbits.User) (string, error) { + return fmt.Sprintf("%s@%s", fmt.Sprint(user.AnonIDSha256), strings.ToLower(internal.Configuration.Bot.LNURLHostUrl.Hostname())), nil +} + +func UserGetLNURL(user *lnbits.User) (string, error) { + name := fmt.Sprint(user.UUID) + callback := fmt.Sprintf("%s/.well-known/lnurlp/%s", internal.Configuration.Bot.LNURLHostName, name) + + lnurlEncode, err := lnurl.LNURLEncode(callback) + if err != nil { + return "", err + } + return lnurlEncode, nil +} + +func UserGetAnonLNURL(user *lnbits.User) (string, error) { + callback := fmt.Sprintf("%s/.well-known/lnurlp/%s", internal.Configuration.Bot.LNURLHostName, user.AnonIDSha256) + lnurlEncode, err := lnurl.LNURLEncode(callback) + if err != nil { + return "", err + } + return lnurlEncode, nil +} + +// lnurlReceiveHandler outputs the LNURL of the user +func (bot *TipBot) lnurlReceiveHandler(ctx intercept.Context) (intercept.Context, error) { + m := ctx.Message() + fromUser := LoadUser(ctx) + lnurlEncode, err := UserGetLNURL(fromUser) + if err != nil { + errmsg := fmt.Sprintf("[userLnurlHandler] Failed to get LNURL: %s", err.Error()) + log.Errorln(errmsg) + bot.trySendMessage(m.Sender, Translate(ctx, "lnurlNoUsernameMessage")) + return ctx, err + } + // create qr code + qr, err := qrcode.Encode(lnurlEncode, qrcode.Medium, 256) + if err != nil { + errmsg := fmt.Sprintf("[userLnurlHandler] Failed to create QR code for LNURL: %s", err.Error()) + log.Errorln(errmsg) + return ctx, err + } + + bot.trySendMessage(m.Sender, Translate(ctx, "lnurlReceiveInfoText")) + // send the lnurl QR code + bot.trySendMessage(m.Sender, &tb.Photo{File: tb.File{FileReader: bytes.NewReader(qr)}, Caption: fmt.Sprintf("`%s`", lnurlEncode)}) + return ctx, nil +} + +// fiatjaf/go-lnurl 1.8.4 with proxy +func (bot *TipBot) 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 if strings.HasPrefix(rawlnurl, "lnurlp://") || + strings.HasPrefix(rawlnurl, "lnurlw://") || + strings.HasPrefix(rawlnurl, "lnurla://") || + strings.HasPrefix(rawlnurl, "keyauth://") { + + scheme := "https:" + if strings.Contains(rawurl, ".onion/") || strings.HasSuffix(rawurl, ".onion") { + scheme = "http:" + } + location := strings.SplitN(rawlnurl, ":", 2)[1] + rawurl = scheme + location + } else { + lnurl_str, ok := lnurl.FindLNURLInText(rawlnurl) + if !ok { + return "", nil, + fmt.Errorf("invalid bech32-encoded lnurl: " + rawlnurl) + } + rawurl, err = lnurl.LNURLDecodeStrict(lnurl_str) + if err != nil { + return "", nil, err + } + } + log.Debug("[HandleLNURL] rawurl: ", rawurl) + 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 + } + } + + // // original withouth proxy + // resp, err := http.Get(rawurl) + // if err != nil { + // return rawurl, nil, err + // } + + client, err := network.GetClientForScheme(parsed) + if err != nil { + return "", nil, err + } + resp, err := client.Get(rawurl) + if err != nil { + return rawurl, nil, err + } + if resp.StatusCode >= 300 { + return rawurl, nil, fmt.Errorf("HTTP error: " + resp.Status) + } + + 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(b) + return rawurl, value, err + case "payRequest": + value, err := lnurl.HandlePay(b) + return rawurl, value, err + // case "channelRequest": + // value, err := lnurl.HandleChannel(b) + // return rawurl, value, err + default: + return rawurl, nil, fmt.Errorf("unkown LNURL response") + } +} + +// DescriptionHash is the SHA256 hash of the metadata +func (bot *TipBot) DescriptionHash(metadata lnurl.Metadata, payerData string) (string, error) { + var hashString string + var hash [32]byte + if len(payerData) == 0 { + hash = sha256.Sum256([]byte(metadata.Encode())) + hashString = hex.EncodeToString(hash[:]) + } else { + hash = sha256.Sum256([]byte(metadata.Encode() + payerData)) + hashString = hex.EncodeToString(hash[:]) + } + return hashString, nil +} diff --git a/message.go b/internal/telegram/message.go similarity index 64% rename from message.go rename to internal/telegram/message.go index e27395aa..fac16bca 100644 --- a/message.go +++ b/internal/telegram/message.go @@ -1,12 +1,10 @@ -package main +package telegram import ( "strconv" "time" - log "github.com/sirupsen/logrus" - - tb "gopkg.in/tucnak/telebot.v2" + tb "gopkg.in/lightningtipbot/telebot.v3" ) type Message struct { @@ -16,10 +14,10 @@ type Message struct { type MessageOption func(m *Message) -func WithDuration(duration time.Duration, bot *tb.Bot) MessageOption { +func WithDuration(duration time.Duration, tipBot *TipBot) MessageOption { return func(m *Message) { m.duration = duration - go m.dispose(bot) + go m.dispose(tipBot) } } @@ -37,17 +35,13 @@ func (msg Message) Key() string { return strconv.Itoa(msg.Message.ID) } -func (msg Message) dispose(telegram *tb.Bot) { +func (msg Message) dispose(tipBot *TipBot) { // 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 - } + tipBot.tryDeleteMessage(msg.Message) }() } diff --git a/internal/telegram/nostr.go b/internal/telegram/nostr.go new file mode 100644 index 00000000..31649a62 --- /dev/null +++ b/internal/telegram/nostr.go @@ -0,0 +1,194 @@ +package telegram + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/LightningTipBot/LightningTipBot/internal" + "github.com/LightningTipBot/LightningTipBot/internal/telegram/intercept" + "github.com/nbd-wtf/go-nostr" + "github.com/nbd-wtf/go-nostr/nip19" + log "github.com/sirupsen/logrus" +) + +var ( + nosterRegisterMessage = "📖 Add your nostr pubkey for zap receipts" + nostrInfoMessage = "💜 *Your nostr information*\n\nYour pubkey: `%s`" + nostrInfoLNAddrMessage = "Your Lightning address: `%s`" + nostrHelpMessage = "⚙️ *Nostr commands:*\n`/nostr add ` ✅ Add your nostr pubkey.\n`/nostr help` 📖 Show help." + nostrAddedMessage = "✅ *Nostr pubkey added.*" + nostrPrivateKeyErrorMessage = "🚫 This is not your public key but your private key! Very dangerous! Try again with your npub..." + nostrPublicKeyErrorMessage = "🚫 There was an error decoding your public key." +) + +func uniqueSlice(slice []string) []string { + keys := make(map[string]bool) + list := []string{} + for _, entry := range slice { + if _, value := keys[entry]; !value { + keys[entry] = true + list = append(list, entry) + } + } + return list +} + +func cleanUrls(slice []string) []string { + list := []string{} + for _, entry := range slice { + if strings.HasSuffix(entry, "/") { + entry = entry[:len(entry)-1] + } + list = append(list, entry) + } + return list +} + +func (bot *TipBot) publishNostrEvent(ev nostr.Event, relays []string) { + pk := internal.Configuration.Nostr.PrivateKey + + // // BEGIN: testing + // pub, _ := nostr.GetPublicKey(pk) + // ev = nostr.Event{ + // PubKey: pub, + // CreatedAt: time.Now(), + // Kind: 1, + // Tags: nil, + // Content: "Hello World!", + // } + // // END: testing + + // calling Sign sets the event ID field and the event Sig field + ev.Sign(pk) + log.Debugf("[NOSTR] 🟣 publishing nostr event %s", ev.ID) + + // more relays + relays = append(relays, "wss://relay.nostr.ch", "wss://eden.nostr.land", "wss://nostr.btcmp.com", "wss://nostr.relayer.se", "wss://relay.current.fyi", "wss://nos.lol", "wss://nostr.mom", "wss://relay.nostr.info", "wss://nostr.zebedee.cloud", "wss://nostr-pub.wellorder.net", "wss://relay.snort.social/", "wss://relay.damus.io/", "wss://nostr.oxtr.dev/", "wss://nostr.fmt.wiz.biz/", "wss://brb.io") + + // remove trailing / + relays = cleanUrls(relays) + + // unique relays + relays = uniqueSlice(relays) + + // crop relays + var max_relays int = 50 + if len(relays) > max_relays { + relays = relays[:max_relays] + } + + // publish the event to relays + for _, url := range relays { + go func(url string) { + // remove trailing / + relay, e := nostr.RelayConnect(context.Background(), url) + if e != nil { + log.Errorf(e.Error()) + return + } + time.Sleep(3 * time.Second) + + status := relay.Publish(context.Background(), ev) + log.Debugf("[NOSTR] published to %s: %s", url, status) + + time.Sleep(3 * time.Second) + relay.Close() + }(url) + + } +} + +func (bot *TipBot) nostrHandler(ctx intercept.Context) (intercept.Context, error) { + m := ctx.Message() + splits := strings.Split(m.Text, " ") + if len(splits) == 1 { + return bot.getNostrHandler(ctx) + } else if len(splits) > 1 { + switch strings.ToLower(splits[1]) { + case "add": + return bot.addNostrPubkeyHandler(ctx) + case "help": + return bot.nostrHelpHandler(ctx) + } + } + return ctx, nil +} + +func (bot *TipBot) addNostrPubkeyHandler(ctx intercept.Context) (intercept.Context, error) { + m := ctx.Message() + splits := strings.Split(m.Text, " ") + splitlen := len(splits) + if splitlen < 3 { + return ctx, fmt.Errorf("not enough arguments") + } + nostrKeyInput := splits[2] + + // parse input + if strings.HasPrefix(nostrKeyInput, "nsec") { + bot.trySendMessage(ctx.Message().Sender, nostrPrivateKeyErrorMessage) + return ctx, fmt.Errorf("user entered nostr private key") + } + // conver to hex + if strings.HasPrefix(nostrKeyInput, "npub") { + prefix, pubkey, err := nip19.Decode(nostrKeyInput) + if err != nil { + bot.trySendMessage(ctx.Message().Sender, nostrPublicKeyErrorMessage) + return ctx, fmt.Errorf("shouldn't error: %s", err) + } + if prefix != "npub" { + bot.trySendMessage(ctx.Message().Sender, nostrPublicKeyErrorMessage) + return ctx, fmt.Errorf("returned invalid prefix") + } + nostrKeyInput = pubkey.(string) + } + + user, err := GetLnbitsUserWithSettings(m.Sender, *bot) + if err != nil { + return ctx, err + } + // save node in db + user.Settings.Nostr.PubKey = nostrKeyInput + err = UpdateUserRecord(user, *bot) + if err != nil { + log.Errorf("[registerNodeHandler] could not update record of user %s: %v", GetUserStr(user.Telegram), err) + return ctx, err + } + bot.trySendMessage(ctx.Message().Sender, nostrAddedMessage) + return ctx, nil +} + +func (bot *TipBot) nostrHelpHandler(ctx intercept.Context) (intercept.Context, error) { + bot.trySendMessage(ctx.Message().Sender, nosterRegisterMessage+"\n\n"+nostrHelpMessage) + return ctx, nil +} + +func (bot *TipBot) getNostrHandler(ctx intercept.Context) (intercept.Context, error) { + m := ctx.Message() + user, err := GetLnbitsUserWithSettings(m.Sender, *bot) + if err != nil { + log.Infof("Could not get user settings for user %s", GetUserStr(user.Telegram)) + return ctx, err + } + var dynamicHelpMessage string + dynamicHelpMessage += nostrHelpMessage + if user.Settings.Nostr.PubKey == "" { + bot.trySendMessage(m.Sender, dynamicHelpMessage) + return ctx, fmt.Errorf("no nostr pubkey registered") + } else if len(user.Settings.Nostr.PubKey) > 0 { + + pubkeyBech32, err := nip19.EncodePublicKey(user.Settings.Nostr.PubKey) + if err != nil { + log.Infof("Could not decode user nostr pubkey %s", GetUserStr(user.Telegram)) + return ctx, err + } + dynamicHelpMessage += "\n\n" + fmt.Sprintf(nostrInfoMessage, pubkeyBech32) + if lnaddr, _ := bot.UserGetLightningAddress(user); len(lnaddr) > 0 { + dynamicHelpMessage += "\n\n" + fmt.Sprintf(nostrInfoLNAddrMessage, lnaddr) + } + bot.trySendMessage(m.Sender, dynamicHelpMessage) + } + + return ctx, nil +} diff --git a/internal/telegram/pay.go b/internal/telegram/pay.go new file mode 100644 index 00000000..42b99c33 --- /dev/null +++ b/internal/telegram/pay.go @@ -0,0 +1,294 @@ +package telegram + +import ( + "context" + "fmt" + "strings" + + "github.com/LightningTipBot/LightningTipBot/internal/telegram/intercept" + + "github.com/LightningTipBot/LightningTipBot/internal/errors" + + "github.com/LightningTipBot/LightningTipBot/internal/runtime/mutex" + "github.com/LightningTipBot/LightningTipBot/internal/storage" + + "github.com/LightningTipBot/LightningTipBot/internal/i18n" + "github.com/LightningTipBot/LightningTipBot/internal/lnbits" + "github.com/LightningTipBot/LightningTipBot/internal/runtime" + "github.com/LightningTipBot/LightningTipBot/internal/thirdparty" + + "github.com/LightningTipBot/LightningTipBot/internal/str" + lnurl "github.com/fiatjaf/go-lnurl" + decodepay "github.com/fiatjaf/ln-decodepay" + log "github.com/sirupsen/logrus" + tb "gopkg.in/lightningtipbot/telebot.v3" +) + +var ( + paymentConfirmationMenu = &tb.ReplyMarkup{ResizeKeyboard: true} + btnCancelPay = paymentConfirmationMenu.Data("🚫 Cancel", "cancel_pay") + btnPay = paymentConfirmationMenu.Data("✅ Pay", "confirm_pay") +) + +func helpPayInvoiceUsage(ctx context.Context, errormsg string) string { + if len(errormsg) > 0 { + return fmt.Sprintf(Translate(ctx, "payHelpText"), fmt.Sprintf("%s", errormsg)) + } else { + return fmt.Sprintf(Translate(ctx, "payHelpText"), "") + } +} + +type PayData struct { + *storage.Base + From *lnbits.User `json:"from"` + Invoice string `json:"invoice"` + Hash string `json:"hash"` + Proof string `json:"proof"` + Memo string `json:"memo"` + Message string `json:"message"` + Amount int64 `json:"amount"` + LanguageCode string `json:"languagecode"` + SuccessAction *lnurl.SuccessAction `json:"successAction"` + TelegramMessage *tb.Message `json:"telegrammessage"` +} + +// payHandler invoked on "/pay lnbc..." command +func (bot *TipBot) payHandler(ctx intercept.Context) (intercept.Context, error) { + // check and print all commands + bot.anyTextHandler(ctx) + user := LoadUser(ctx) + if user.Wallet == nil { + return ctx, errors.Create(errors.UserNoWalletError) + } + if len(strings.Split(ctx.Message().Text, " ")) < 2 { + NewMessage(ctx.Message(), WithDuration(0, bot)) + bot.trySendMessage(ctx.Sender(), helpPayInvoiceUsage(ctx, "")) + return ctx, errors.Create(errors.InvalidSyntaxError) + } + userStr := GetUserStr(ctx.Sender()) + paymentRequest, err := getArgumentFromCommand(ctx.Message().Text, 1) + if err != nil { + NewMessage(ctx.Message(), WithDuration(0, bot)) + bot.trySendMessage(ctx.Sender(), helpPayInvoiceUsage(ctx, Translate(ctx, "invalidInvoiceHelpMessage"))) + errmsg := fmt.Sprintf("[/pay] Error: Could not getArgumentFromCommand: %s", err.Error()) + log.Errorln(errmsg) + return ctx, errors.New(errors.InvalidSyntaxError, err) + } + 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(ctx.Sender(), helpPayInvoiceUsage(ctx, Translate(ctx, "invalidInvoiceHelpMessage"))) + errmsg := fmt.Sprintf("[/pay] Error: Could not decode invoice: %s", err.Error()) + log.Errorln(errmsg) + return ctx, errors.New(errors.InvalidSyntaxError, err) + } + amount := int64(bolt11.MSatoshi / 1000) + + if amount <= 0 { + bot.trySendMessage(ctx.Sender(), Translate(ctx, "invoiceNoAmountMessage")) + errmsg := fmt.Sprint("[/pay] Error: invoice without amount") + log.Warnln(errmsg) + return ctx, errors.Create(errors.InvalidAmountError) + } + + // check user balance first + balance, err := bot.GetUserBalance(user) + if err != nil { + NewMessage(ctx.Message(), WithDuration(0, bot)) + errmsg := fmt.Sprintf("[/pay] Error: Could not get user balance: %s", err.Error()) + log.Errorln(errmsg) + bot.trySendMessage(ctx.Sender(), Translate(ctx, "errorTryLaterMessage")) + return ctx, errors.New(errors.GetBalanceError, err) + } + + if amount > balance { + NewMessage(ctx.Message(), WithDuration(0, bot)) + bot.trySendMessage(ctx.Sender(), fmt.Sprintf(Translate(ctx, "insufficientFundsMessage"), thirdparty.FormatSatsWithLKR(balance), thirdparty.FormatSatsWithLKR(amount))) + return ctx, errors.Create(errors.InvalidSyntaxError) + } + // send warning that the invoice might fail due to missing fee reserve + if float64(amount) > float64(balance)*0.98 { + bot.trySendMessage(ctx.Sender(), Translate(ctx, "feeReserveMessage")) + } + + confirmText := fmt.Sprintf(Translate(ctx, "confirmPayInvoiceMessage"), thirdparty.FormatSatsWithLKR(amount)) + if len(bolt11.Description) > 0 { + confirmText = confirmText + fmt.Sprintf(Translate(ctx, "confirmPayAppendMemo"), str.MarkdownEscape(bolt11.Description)) + } + + log.Infof("[/pay] Invoice entered. User: %s, amount: %d sat.", userStr, amount) + + // object that holds all information about the send payment + id := fmt.Sprintf("pay:%d-%d-%s", ctx.Sender().ID, amount, RandStringRunes(5)) + + // // // create inline buttons + payButton := paymentConfirmationMenu.Data(Translate(ctx, "payButtonMessage"), "confirm_pay", id) + cancelButton := paymentConfirmationMenu.Data(Translate(ctx, "cancelButtonMessage"), "cancel_pay", id) + + paymentConfirmationMenu.Inline( + paymentConfirmationMenu.Row( + payButton, + cancelButton), + ) + payMessage := bot.trySendMessageEditable(ctx.Chat(), confirmText, paymentConfirmationMenu) + // read successaction + sa, ok := ctx.Value("SuccessAction").(*lnurl.SuccessAction) + if !ok { + sa = &lnurl.SuccessAction{} + } + + payData := &PayData{ + Base: storage.New(storage.ID(id)), + From: user, + Invoice: paymentRequest, + Amount: int64(amount), + Memo: bolt11.Description, + Message: confirmText, + LanguageCode: ctx.Value("publicLanguageCode").(string), + SuccessAction: sa, + TelegramMessage: payMessage, + } + // add result to persistent struct + runtime.IgnoreError(payData.Set(payData, bot.Bunt)) + + SetUserState(user, bot, lnbits.UserStateConfirmPayment, paymentRequest) + return ctx, nil +} + +// confirmPayHandler when user clicked pay on payment confirmation +func (bot *TipBot) confirmPayHandler(ctx intercept.Context) (intercept.Context, error) { + tx := &PayData{Base: storage.New(storage.ID(ctx.Data()))} + mutex.LockWithContext(ctx, tx.ID) + defer mutex.UnlockWithContext(ctx, tx.ID) + sn, err := tx.Get(tx, bot.Bunt) + // immediatelly set intransaction to block duplicate calls + if err != nil { + log.Errorf("[confirmPayHandler] %s", err.Error()) + return ctx, err + } + payData := sn.(*PayData) + + // onnly the correct user can press + if payData.From.Telegram.ID != ctx.Sender().ID { + return ctx, errors.Create(errors.UnknownError) + } + if !payData.Active { + log.Errorf("[confirmPayHandler] send not active anymore") + bot.tryEditMessage(ctx.Message(), i18n.Translate(payData.LanguageCode, "errorTryLaterMessage"), &tb.ReplyMarkup{}) + bot.tryDeleteMessage(ctx.Message()) + return ctx, errors.Create(errors.NotActiveError) + } + defer payData.Set(payData, bot.Bunt) + + // remove buttons from confirmation message + // bot.tryEditMessage(handler.Message(), MarkdownEscape(payData.Message), &tb.ReplyMarkup{}) + + user := LoadUser(ctx) + if user.Wallet == nil { + bot.tryDeleteMessage(ctx.Message()) + return ctx, errors.Create(errors.UserNoWalletError) + } + + // reset state immediately + ResetUserState(user, bot) + + userStr := GetUserStr(ctx.Sender()) + + // update button text + bot.tryEditMessage( + ctx.Message(), + payData.Message, + &tb.ReplyMarkup{ + InlineKeyboard: [][]tb.InlineButton{ + {tb.InlineButton{Unique: "attempt_payment", Text: i18n.Translate(payData.LanguageCode, "lnurlGettingUserMessage")}}, + }, + }, + ) + + log.Infof("[/pay] Attempting %s's invoice %s (%d sat)", userStr, payData.ID, payData.Amount) + // pay invoice + invoice, err := user.Wallet.Pay(lnbits.PaymentParams{Out: true, Bolt11: payData.Invoice}, bot.Client) + if err != nil { + errmsg := fmt.Sprintf("[/pay] Could not pay invoice of %s: %s", userStr, err) + + // Enhanced error logging with detailed payment information + if bot.ErrorLogger != nil { + bot.ErrorLogger.LogPaymentError(err, payData.Amount, payData.Memo, payData.Invoice, user.Telegram) + } + + err = fmt.Errorf(i18n.Translate(payData.LanguageCode, "invoiceUndefinedErrorMessage")) + bot.tryEditMessage(ctx.Message(), fmt.Sprintf(i18n.Translate(payData.LanguageCode, "invoicePaymentFailedMessage"), err.Error()), &tb.ReplyMarkup{}) + // verbose error message, turned off for now + // if len(err.Error()) == 0 { + // err = fmt.Errorf(i18n.Translate(payData.LanguageCode, "invoiceUndefinedErrorMessage")) + // } + // bot.tryEditMessage(c.Message, fmt.Sprintf(i18n.Translate(payData.LanguageCode, "invoicePaymentFailedMessage"), str.MarkdownEscape(err.Error())), &tb.ReplyMarkup{}) + log.Errorln(errmsg) + return ctx, err + } + payData.Hash = invoice.PaymentHash + + // do balance check for keyboard update + _, err = bot.GetUserBalance(user) + if err != nil { + errmsg := fmt.Sprintf("could not get balance of user %s", userStr) + log.Errorln(errmsg) + } + + if ctx.Message().Private() { + // if the command was invoked in private chat + // the edit below was cool, but we need to pop up the keyboard again + // bot.tryEditMessage(c.Message, i18n.Translate(payData.LanguageCode, "invoicePaidMessage"), &tb.ReplyMarkup{}) + bot.tryDeleteMessage(ctx.Message()) + bot.trySendMessage(ctx.Sender(), i18n.Translate(payData.LanguageCode, "invoicePaidMessage")) + } else { + // if the command was invoked in group chat + bot.trySendMessage(ctx.Sender(), i18n.Translate(payData.LanguageCode, "invoicePaidMessage")) + bot.tryEditMessage(ctx.Message(), fmt.Sprintf(i18n.Translate(payData.LanguageCode, "invoicePublicPaidMessage"), userStr), &tb.ReplyMarkup{}) + } + + // display LNURL success action if present + sa := payData.SuccessAction + if sa.Tag == "message" && len(sa.Message) > 0 { + bot.trySendMessage(ctx.Sender(), fmt.Sprintf("✉️: `%s`", sa.Message)) + } else if sa.Tag == "url" && len(sa.URL) > 0 { + bot.trySendMessage(ctx.Sender(), fmt.Sprintf("🔗: %s", str.MarkdownEscape(sa.URL)), tb.NoPreview) + if len(sa.Description) > 0 { + bot.trySendMessage(ctx.Sender(), fmt.Sprintf("✉️: %s", sa.Description)) + } + } + + log.Infof("[⚡️ pay] User %s paid invoice %s (%d sat)", userStr, payData.ID, payData.Amount) + return ctx, nil +} + +// cancelPaymentHandler invoked when user clicked cancel on payment confirmation +func (bot *TipBot) cancelPaymentHandler(ctx intercept.Context) (intercept.Context, error) { + // reset state immediately + user := LoadUser(ctx) + ResetUserState(user, bot) + tx := &PayData{Base: storage.New(storage.ID(ctx.Data()))} + mutex.LockWithContext(ctx, tx.ID) + defer mutex.UnlockWithContext(ctx, tx.ID) + // immediatelly set intransaction to block duplicate calls + sn, err := tx.Get(tx, bot.Bunt) + if err != nil { + log.Errorf("[cancelPaymentHandler] %s", err.Error()) + return ctx, err + } + payData := sn.(*PayData) + // onnly the correct user can press + if payData.From.Telegram.ID != ctx.Callback().Sender.ID { + return ctx, errors.Create(errors.UnknownError) + } + // delete and send instead of edit for the keyboard to pop up after sending + bot.tryDeleteMessage(ctx.Message()) + bot.trySendMessage(ctx.Message().Chat, i18n.Translate(payData.LanguageCode, "paymentCancelledMessage")) + // bot.tryEditMessage(c.Message, i18n.Translate(payData.LanguageCode, "paymentCancelledMessage"), &tb.ReplyMarkup{}) + return ctx, payData.Inactivate(payData, bot.Bunt) + +} diff --git a/internal/telegram/photo.go b/internal/telegram/photo.go new file mode 100644 index 00000000..d10eb6c6 --- /dev/null +++ b/internal/telegram/photo.go @@ -0,0 +1,158 @@ +package telegram + +import ( + "bytes" + "encoding/json" + "fmt" + "image" + "image/jpeg" + "strings" + + "github.com/LightningTipBot/LightningTipBot/internal/telegram/intercept" + + "github.com/LightningTipBot/LightningTipBot/internal/errors" + + "github.com/LightningTipBot/LightningTipBot/pkg/lightning" + "github.com/makiuchi-d/gozxing" + "github.com/makiuchi-d/gozxing/qrcode" + "github.com/nfnt/resize" + + log "github.com/sirupsen/logrus" + tb "gopkg.in/lightningtipbot/telebot.v3" +) + +// TryRecognizeInvoiceFromQrCode will try to read an invoice string from a qr code and invoke the payment ctx. +func TryRecognizeQrCode(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) || lightning.IsLnurl(payload) { + // create payment command payload + // invoke payment confirmation ctx + return result, nil + } + return nil, fmt.Errorf("no codes found") +} + +// photoHandler is the handler function for every photo from a private chat that the bot receives +func (bot *TipBot) photoHandler(ctx intercept.Context) (intercept.Context, error) { + m := ctx.Message() + if m.Chat.Type != tb.ChatPrivate { + return ctx, errors.Create(errors.NoPrivateChatError) + } + if m.Photo == nil { + return ctx, errors.Create(errors.NoPhotoError) + } + user := LoadUser(ctx) + if c := stateCallbackMessage[user.StateKey]; c != nil { + ctx, err := c(ctx) + ResetUserState(user, bot) + return ctx, err + } + + // get file reader closer from Telegram api + reader, err := bot.Telegram.File(m.Photo.MediaFile()) + if err != nil { + log.Errorf("[photoHandler] getfile error: %v\n", err.Error()) + return ctx, err + } + // decode to jpeg image + img, err := jpeg.Decode(reader) + if err != nil { + log.Errorf("[photoHandler] image.Decode error: %v\n", err.Error()) + return ctx, err + } + data, err := TryRecognizeQrCode(img) + if err != nil { + log.Errorf("[photoHandler] tryRecognizeQrCodes error: %v\n", err.Error()) + bot.trySendMessage(m.Sender, Translate(ctx, "photoQrNotRecognizedMessage")) + return ctx, err + } + + bot.trySendMessage(m.Sender, fmt.Sprintf(Translate(ctx, "photoQrRecognizedMessage"), data.String())) + // invoke payment handler + if lightning.IsInvoice(data.String()) { + m.Text = fmt.Sprintf("/pay %s", data.String()) + return bot.payHandler(ctx) + } else if lightning.IsLnurl(data.String()) { + m.Text = fmt.Sprintf("/lnurl %s", data.String()) + return bot.lnurlHandler(ctx) + } + return ctx, nil +} + +// DownloadProfilePicture downloads a profile picture from Telegram. +// This is a public function because it is used in another package (lnurl) +func DownloadProfilePicture(telegram *tb.Bot, user *tb.User) ([]byte, error) { + photo, err := ProfilePhotosOf(telegram, user) + if err != nil { + log.Errorf("[DownloadProfilePicture] %v", err) + return nil, err + } + if len(photo) == 0 { + log.Error("[DownloadProfilePicture] No profile picture found") + return nil, err + } + buf := new(bytes.Buffer) + reader, err := telegram.File(&photo[0].File) + if err != nil { + log.Errorf("[DownloadProfilePicture] %v", err) + return nil, err + } + img, err := jpeg.Decode(reader) + if err != nil { + log.Errorf("[DownloadProfilePicture] %v", err) + return nil, err + } + + // resize image + img = resize.Thumbnail(160, 160, img, resize.Lanczos3) + + err = jpeg.Encode(buf, img, nil) + return buf.Bytes(), nil +} + +var BotProfilePicture []byte + +// downloadMyProfilePicture downloads the profile picture of the bot +// and saves it in `BotProfilePicture` +func (bot *TipBot) downloadMyProfilePicture() error { + picture, err := DownloadProfilePicture(bot.Telegram, bot.Telegram.Me) + if err != nil { + log.Errorf("[downloadMyProfilePicture] %v", err) + return err + } + BotProfilePicture = picture + return nil +} + +// ProfilePhotosOf returns list of profile pictures for a user. +func ProfilePhotosOf(bot *tb.Bot, user *tb.User) ([]tb.Photo, error) { + params := map[string]interface { + }{ + "user_id": user.Recipient(), + "limit": 1, + } + + data, err := bot.Raw("getUserProfilePhotos", params) + if err != nil { + return nil, err + } + + var resp struct { + Result struct { + Count int `json:"total_count"` + Photos []tb.Photo `json:"photos"` + } + } + if err := json.Unmarshal(data, &resp); err != nil { + return nil, err + } + return resp.Result.Photos, nil +} diff --git a/internal/telegram/pots.go b/internal/telegram/pots.go new file mode 100644 index 00000000..283ed807 --- /dev/null +++ b/internal/telegram/pots.go @@ -0,0 +1,374 @@ +package telegram + +import ( + "context" + "fmt" + "regexp" + "strings" + + "github.com/LightningTipBot/LightningTipBot/internal/errors" + "github.com/LightningTipBot/LightningTipBot/internal/lnbits" + "github.com/LightningTipBot/LightningTipBot/internal/telegram/intercept" + "github.com/LightningTipBot/LightningTipBot/internal/thirdparty" + "github.com/LightningTipBot/LightningTipBot/internal/utils" + log "github.com/sirupsen/logrus" + uuid "github.com/satori/go.uuid" + "gorm.io/gorm" +) + +const ( + MinPotNameLength = 3 + MaxPotNameLength = 50 + MaxPotsPerUser = 20 +) + +var potNameRegex = regexp.MustCompile(`^[a-zA-Z0-9_\-\s]+$`) + +func (bot *TipBot) CreatePot(user *lnbits.User, name string) (*lnbits.SavingsPot, error) { + name = strings.TrimSpace(name) + + if len(name) < MinPotNameLength { + return nil, fmt.Errorf("pot name too short (min %d characters)", MinPotNameLength) + } + + if len(name) > MaxPotNameLength { + return nil, fmt.Errorf("pot name too long (max %d characters)", MaxPotNameLength) + } + + if !potNameRegex.MatchString(name) { + return nil, fmt.Errorf("pot name can only contain letters, numbers, spaces, hyphens, and underscores") + } + + var pot *lnbits.SavingsPot + err := bot.DB.Users.Transaction(func(tx *gorm.DB) error { + var existingPot lnbits.SavingsPot + if err := tx.Where("user_id = ? AND name = ?", user.ID, name).First(&existingPot).Error; err == nil { + return fmt.Errorf("pot with name '%s' already exists", name) + } + + var potCount int64 + tx.Model(&lnbits.SavingsPot{}).Where("user_id = ?", user.ID).Count(&potCount) + if potCount >= MaxPotsPerUser { + return fmt.Errorf("maximum number of pots reached (%d)", MaxPotsPerUser) + } + + pot = &lnbits.SavingsPot{ + ID: uuid.NewV4().String(), + UserID: user.ID, + Name: name, + Balance: 0, + } + + if err := tx.Create(pot).Error; err != nil { + return fmt.Errorf("failed to create pot: %w", err) + } + + return nil + }) + + if err != nil { + return nil, err + } + + return pot, nil +} + +func (bot *TipBot) ListUserPots(user *lnbits.User) ([]lnbits.SavingsPot, error) { + var pots []lnbits.SavingsPot + err := bot.DB.Users.Where("user_id = ?", user.ID).Order("created_at ASC").Find(&pots).Error + return pots, err +} + +func (bot *TipBot) GetPot(user *lnbits.User, name string) (*lnbits.SavingsPot, error) { + name = strings.TrimSpace(name) + var pot lnbits.SavingsPot + err := bot.DB.Users.Where("user_id = ? AND name = ?", user.ID, name).First(&pot).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, fmt.Errorf("pot '%s' not found", name) + } + return nil, err + } + return &pot, nil +} + +func (bot *TipBot) TransferToPot(user *lnbits.User, potName string, amount int64) error { + if amount <= 0 { + return fmt.Errorf("amount must be positive") + } + + // Check sender's available balance (excluding money already in pots) + balance, err := bot.GetUserAvailableBalance(user) + if err != nil { + return fmt.Errorf("could not get user available balance: %w", err) + } + + if balance < amount { + return fmt.Errorf("insufficient available balance. Available: %d sats, Requested: %d sats", balance, amount) + } + + return bot.DB.Users.Transaction(func(tx *gorm.DB) error { + + // Verify the pot exists + var pot lnbits.SavingsPot + if err := tx.Where("user_id = ? AND name = ?", user.ID, strings.TrimSpace(potName)).First(&pot).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return fmt.Errorf("pot '%s' not found", potName) + } + return err + } + + // Atomically add to pot balance (no need to update wallet_balance as it's handled by LNbits) + if err := tx.Model(&lnbits.SavingsPot{}).Where("user_id = ? AND name = ?", user.ID, strings.TrimSpace(potName)).UpdateColumn("balance", gorm.Expr("balance + ?", amount)).Error; err != nil { + return fmt.Errorf("failed to update pot balance: %w", err) + } + + // Update in-memory user balance + user.Wallet.Balance = balance - amount + + return nil + }) +} + +func (bot *TipBot) WithdrawFromPot(user *lnbits.User, potName string, amount int64) error { + if amount <= 0 { + return fmt.Errorf("amount must be positive") + } + + // Pre-check that the pot exists and has sufficient balance + pot, err := bot.GetPot(user, potName) + if err != nil { + return err + } + + if pot.Balance < amount { + return fmt.Errorf("insufficient pot balance. Available: %d sats, Requested: %d sats", pot.Balance, amount) + } + + return bot.DB.Users.Transaction(func(tx *gorm.DB) error { + + // Atomically deduct from pot balance (no need to update wallet_balance as it's handled by LNbits) + result := tx.Model(&lnbits.SavingsPot{}).Where("user_id = ? AND name = ? AND balance >= ?", user.ID, strings.TrimSpace(potName), amount). + UpdateColumn("balance", gorm.Expr("balance - ?", amount)) + if result.Error != nil { + return fmt.Errorf("failed to update pot balance: %w", result.Error) + } + if result.RowsAffected == 0 { + return fmt.Errorf("insufficient pot balance or pot not found") + } + + return nil + }) +} + +func (bot *TipBot) DeletePot(user *lnbits.User, name string) error { + pot, err := bot.GetPot(user, name) + if err != nil { + return err + } + + if pot.Balance > 0 { + return fmt.Errorf("cannot delete pot with balance. Current balance: %d sats", pot.Balance) + } + + return bot.DB.Users.Delete(pot).Error +} + +func (bot *TipBot) GetUserTotalPotBalance(user *lnbits.User) (int64, error) { + var totalBalance int64 + err := bot.DB.Users.Model(&lnbits.SavingsPot{}). + Where("user_id = ?", user.ID). + Select("COALESCE(SUM(balance), 0)"). + Scan(&totalBalance).Error + return totalBalance, err +} + +func (bot *TipBot) createPotHandler(ctx intercept.Context) (intercept.Context, error) { + m := ctx.Message() + user := LoadUser(ctx) + + if user.Wallet == nil { + return ctx, errors.Create(errors.UserNoWalletError) + } + + arguments := strings.Split(m.Text, " ") + if len(arguments) < 2 { + bot.trySendMessage(ctx.Sender(), Translate(ctx, "createPotHelpText")) + return ctx, nil + } + + potName := strings.Join(arguments[1:], " ") + + pot, err := bot.CreatePot(user, potName) + if err != nil { + bot.trySendMessage(ctx.Sender(), fmt.Sprintf("❌ Failed to create pot: %s", err.Error())) + return ctx, err + } + + bot.trySendMessage(ctx.Sender(), fmt.Sprintf("✅ Created savings pot '%s'", pot.Name)) + return ctx, nil +} + +func (bot *TipBot) listPotsHandler(ctx intercept.Context) (intercept.Context, error) { + user := LoadUser(ctx) + + if user.Wallet == nil { + return ctx, errors.Create(errors.UserNoWalletError) + } + + pots, err := bot.ListUserPots(user) + if err != nil { + bot.trySendMessage(ctx.Sender(), "❌ Failed to fetch your pots") + return ctx, err + } + + if len(pots) == 0 { + bot.trySendMessage(ctx.Sender(), "📝 You have no savings pots yet. Use /createpot to create one!") + return ctx, nil + } + + LKRPerSat, USDPerSat, err := thirdparty.GetSatPrice() + if err != nil { + log.Infof("[/pots] error fetching price from coingecko\n") + } + + message := "🏺 Your Savings Pots:\n\n" + totalBalance := int64(0) + + for i, pot := range pots { + totalBalance += pot.Balance + if err == nil { + potUSDValue := USDPerSat * float64(pot.Balance) + potLKRValue := LKRPerSat * float64(pot.Balance) + message += fmt.Sprintf("%d. **%s**: %s (%s USD / රු. %s)\n", i+1, pot.Name, + utils.FormatSats(pot.Balance), + utils.FormatFloatWithCommas(potUSDValue), + utils.FormatFloatWithCommas(potLKRValue)) + } else { + message += fmt.Sprintf("%d. **%s**: %s\n", i+1, pot.Name, utils.FormatSats(pot.Balance)) + } + } + + if err == nil { + totalUSDValue := USDPerSat * float64(totalBalance) + totalLKRValue := LKRPerSat * float64(totalBalance) + message += fmt.Sprintf("\n💰 **Total in pots**: %s (%s USD / රු. %s)", + utils.FormatSats(totalBalance), + utils.FormatFloatWithCommas(totalUSDValue), + utils.FormatFloatWithCommas(totalLKRValue)) + } else { + message += fmt.Sprintf("\n💰 **Total in pots**: %s", utils.FormatSats(totalBalance)) + } + + bot.trySendMessage(ctx.Sender(), message) + return ctx, nil +} + +func (bot *TipBot) addToPotHandler(ctx intercept.Context) (intercept.Context, error) { + m := ctx.Message() + user := LoadUser(ctx) + + if user.Wallet == nil { + return ctx, errors.Create(errors.UserNoWalletError) + } + + arguments := strings.Fields(m.Text) + if len(arguments) < 3 { + bot.trySendMessage(ctx.Sender(), Translate(ctx, "addToPotHelpText")) + return ctx, nil + } + + // Last argument is amount, everything in between is pot name + amountStr := arguments[len(arguments)-1] + potName := strings.Join(arguments[1:len(arguments)-1], " ") + + amount, err := getAmount(ctx, amountStr) + if err != nil { + bot.trySendMessage(ctx.Sender(), fmt.Sprintf("❌ Invalid amount: %s", err.Error())) + return ctx, err + } + + if amount <= 0 { + bot.trySendMessage(ctx.Sender(), "❌ Amount must be positive") + return ctx, nil + } + + err = bot.TransferToPot(user, potName, amount) + if err != nil { + bot.trySendMessage(ctx.Sender(), fmt.Sprintf("❌ Transfer failed: %s", err.Error())) + return ctx, err + } + + bot.trySendMessage(ctx.Sender(), fmt.Sprintf("✅ Transferred %s to pot '%s'", utils.FormatSats(amount), potName)) + return ctx, nil +} + +func (bot *TipBot) withdrawFromPotHandler(ctx intercept.Context) (intercept.Context, error) { + m := ctx.Message() + user := LoadUser(ctx) + + if user.Wallet == nil { + return ctx, errors.Create(errors.UserNoWalletError) + } + + arguments := strings.Fields(m.Text) + if len(arguments) < 3 { + bot.trySendMessage(ctx.Sender(), Translate(ctx, "withdrawFromPotHelpText")) + return ctx, nil + } + + // Last argument is amount, everything in between is pot name + amountStr := arguments[len(arguments)-1] + potName := strings.Join(arguments[1:len(arguments)-1], " ") + + amount, err := getAmount(ctx, amountStr) + if err != nil { + bot.trySendMessage(ctx.Sender(), fmt.Sprintf("❌ Invalid amount: %s", err.Error())) + return ctx, err + } + + if amount <= 0 { + bot.trySendMessage(ctx.Sender(), "❌ Amount must be positive") + return ctx, nil + } + + err = bot.WithdrawFromPot(user, potName, amount) + if err != nil { + bot.trySendMessage(ctx.Sender(), fmt.Sprintf("❌ Withdrawal failed: %s", err.Error())) + return ctx, err + } + + bot.trySendMessage(ctx.Sender(), fmt.Sprintf("✅ Withdrew %s sats from pot '%s'", utils.FormatSats(amount), potName)) + return ctx, nil +} + +func (bot *TipBot) deletePotHandler(ctx intercept.Context) (intercept.Context, error) { + m := ctx.Message() + user := LoadUser(ctx) + + if user.Wallet == nil { + return ctx, errors.Create(errors.UserNoWalletError) + } + + arguments := strings.Fields(m.Text) + if len(arguments) < 2 { + bot.trySendMessage(ctx.Sender(), Translate(ctx, "deletePotHelpText")) + return ctx, nil + } + + // Everything after the command is the pot name + potName := strings.TrimSpace(strings.Join(arguments[1:], " ")) + + err := bot.DeletePot(user, potName) + if err != nil { + bot.trySendMessage(ctx.Sender(), fmt.Sprintf("❌ Failed to delete pot: %s", err.Error())) + return ctx, err + } + + bot.trySendMessage(ctx.Sender(), fmt.Sprintf("✅ Deleted pot '%s'", potName)) + return ctx, nil +} + +func getAmount(ctx context.Context, amountStr string) (int64, error) { + return GetAmount(amountStr) +} diff --git a/internal/telegram/satdress.go b/internal/telegram/satdress.go new file mode 100644 index 00000000..2f360e0e --- /dev/null +++ b/internal/telegram/satdress.go @@ -0,0 +1,556 @@ +package telegram + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/hex" + "encoding/pem" + "fmt" + "strings" + "time" + + "github.com/LightningTipBot/LightningTipBot/internal/runtime" + "github.com/LightningTipBot/LightningTipBot/internal/telegram/intercept" + tb "gopkg.in/lightningtipbot/telebot.v3" + + "github.com/LightningTipBot/LightningTipBot/internal/lnbits" + "github.com/LightningTipBot/LightningTipBot/internal/satdress" + "github.com/LightningTipBot/LightningTipBot/internal/storage" + "github.com/eko/gocache/store" + log "github.com/sirupsen/logrus" + "github.com/skip2/go-qrcode" +) + +var ( + registerNodeMessage = "📖 Connect your Lightning node with your wallet.\n\nCurrently supported backends: `lnd` and `lnbits`\nTo register a node, type: `/node add `\n\n*LND (REST):* `/node add lnd `\n*LNbits:* `/node add lnbits `\n\nℹ️ Always use `https://` for the ``, even if you use a Tor node. Certificates and macaroons need to be in base64 format.\n\n⚠️ For security reasons, you should *only use an invoice macaroon* for LND and an *invoice key* for LNbits." + nodeHelpMessage = "⚙️ *Commands:*\n`/node add ` ✅ Add your node.\n`/node invoice ` ⤵️ Fetch an invoice from your node.\n`/node proxy ` 🔀 Proxy a payment to your node (privacy feature).\n`/node help` 📖 Show help." + checkingInvoiceMessage = "⏳ Checking invoice on your node..." + invoiceNotSettledMessage = "❌ Invoice has not settled yet." + checkInvoiceButtonMessage = "🔄 Check invoice" + routingInvoiceMessage = "🔄 Getting invoice from your node..." + checkingNodeMessage = "🔄 Checking your node..." + errorCouldNotAddNodeMessage = "❌ Could not add node. Please check your node details." + gettingInvoiceOnlyErrorMessage = "❌ Error getting invoice from your node." + gettingInvoiceErrorMessage = "❌ Error getting invoice from your node. Your funds are still available." + payingInvoiceErrorMessage = "❌ Could not route payment. Your funds are still available." + invoiceRoutedMessage = "✅ *Payment routed to your node.*" + invoiceSettledMessage = "✅ *Invoice settled.*" + nodeAddedMessage = "✅ *Node added.*" + satdressCheckInvoicenMenu = &tb.ReplyMarkup{ResizeKeyboard: true} + btnSatdressCheckInvoice = satdressCheckInvoicenMenu.Data(checkInvoiceButtonMessage, "satdress_check_invoice") +) + +// todo -- rename to something better like parse node settings or something +func parseUserSettingInput(ctx intercept.Context, m *tb.Message) (satdress.BackendParams, error) { + // input is "/node add " + params := satdress.LNDParams{} + splits := strings.Split(m.Text, " ") + splitlen := len(splits) + if splitlen < 4 { + return params, fmt.Errorf("not enough arguments") + } + switch strings.ToLower(splits[2]) { + case "lnd": + if splitlen < 5 || splitlen > 7 { + return params, fmt.Errorf("wrong format. Use []") + } + host := splits[3] + macaroon := splits[4] + var pem []byte + if splitlen == 6 { + cert := splits[5] + pem := parseCertificateToPem(cert) + if len(pem) < 1 { + return params, fmt.Errorf("certificate has invalid format") + } + } + + hostsplit := strings.Split(host, ".") + if len(hostsplit) == 0 { + return params, fmt.Errorf("host has wrong format") + } + + return satdress.LNDParams{ + Cert: pem, + Host: host, + Macaroon: macaroon, + CertString: string(pem), + }, nil + case "lnbits": + if splitlen < 5 || splitlen > 6 { + return params, fmt.Errorf("wrong format. Use ") + } + host := splits[3] + key := splits[4] + + host = strings.TrimSuffix(host, "/") + hostsplit := strings.Split(host, ".") + if len(hostsplit) == 0 { + return params, fmt.Errorf("host has wrong format") + } + return satdress.LNBitsParams{ + Host: host, + Key: key, + }, nil + default: + return params, fmt.Errorf("unknown backend type. Supported types: `lnd`, `lnbits`") + } +} + +func nodeInfoString(node *lnbits.NodeSettings) (string, error) { + if len(node.NodeType) == 0 { + return "", fmt.Errorf("node type is empty") + } + var node_info_str_filled string + var node_info_str string + switch strings.ToLower(node.NodeType) { + case "lnd": + node_info_str = "*Type:* `%s`\n\n*Host:*\n\n`%s`\n\n*Macaroon:*\n\n`%s`\n\n*Cert:*\n\n`%s`" + node_info_str_filled = fmt.Sprintf(node_info_str, node.NodeType, node.LNDParams.Host, node.LNDParams.Macaroon, node.LNDParams.CertString) + case "lnbits": + node_info_str = "*Type:* `%s`\n\n*Host:*\n\n`%s`\n\n*Key:*\n\n`%s`" + node_info_str_filled = fmt.Sprintf(node_info_str, node.NodeType, node.LNbitsParams.Host, node.LNbitsParams.Key) + default: + return "", fmt.Errorf("unknown node type") + } + return fmt.Sprintf("ℹ️ *Your node information.*\n\n%s", node_info_str_filled), nil +} + +func (bot *TipBot) nodeHelpHandler(ctx intercept.Context) (intercept.Context, error) { + bot.trySendMessage(ctx.Message().Sender, registerNodeMessage+"\n\n"+nodeHelpMessage) + return ctx, nil +} + +func (bot *TipBot) getNodeHandler(ctx intercept.Context) (intercept.Context, error) { + m := ctx.Message() + user, err := GetLnbitsUserWithSettings(m.Sender, *bot) + if err != nil { + log.Infof("Could not get user settings for user %s", GetUserStr(user.Telegram)) + return ctx, err + } + + if user.Settings == nil { + bot.trySendMessage(m.Sender, registerNodeMessage+"\n\n"+nodeHelpMessage) + return ctx, fmt.Errorf("no node registered") + } + + node_info_str, err := nodeInfoString(&user.Settings.Node) + if err != nil { + log.Infof("Could not get node info for user %s", GetUserStr(user.Telegram)) + bot.trySendMessage(m.Sender, registerNodeMessage+"\n\n"+nodeHelpMessage) + return ctx, err + } + bot.trySendMessage(m.Sender, node_info_str) + + return ctx, nil +} + +func (bot *TipBot) nodeHandler(ctx intercept.Context) (intercept.Context, error) { + m := ctx.Message() + splits := strings.Split(m.Text, " ") + if len(splits) == 1 { + return bot.getNodeHandler(ctx) + } else if len(splits) > 1 { + switch strings.ToLower(splits[1]) { + case "invoice": + return bot.invHandler(ctx) + case "add": + return bot.registerNodeHandler(ctx) + case "check": + return bot.satdressCheckInvoiceHandler(ctx) + case "proxy": + return bot.satdressProxyHandler(ctx) + case "help": + return bot.nodeHelpHandler(ctx) + } + } + return ctx, nil +} + +func (bot *TipBot) registerNodeHandler(ctx intercept.Context) (intercept.Context, error) { + m := ctx.Message() + user, err := GetLnbitsUserWithSettings(m.Sender, *bot) + if err != nil { + return ctx, err + } + check_message := bot.trySendMessageEditable(user.Telegram, checkingNodeMessage) + + backendParams, err := parseUserSettingInput(ctx, m) + if err != nil { + bot.tryEditMessage(check_message, fmt.Sprintf(Translate(ctx, "errorReasonMessage"), err.Error())) + return ctx, err + } + + switch backend := backendParams.(type) { + case satdress.LNDParams: + // get test invoice from user's node + getInvoiceParams, err := satdress.MakeInvoice( + satdress.Params{ + Backend: backend, + Msatoshi: 1000, + Description: "Test invoice", + }, + ) + if err != nil { + log.Errorf("[registerNodeHandler] Could not add user %s's %s node: %s", GetUserStr(user.Telegram), getInvoiceParams.Status, err.Error()) + bot.tryEditMessage(check_message, errorCouldNotAddNodeMessage) + return ctx, err + } + + // save node in db + user.Settings.Node.LNDParams = &backend + user.Settings.Node.NodeType = "lnd" + case satdress.LNBitsParams: + // get test invoice from user's node + getInvoiceParams, err := satdress.MakeInvoice( + satdress.Params{ + Backend: backend, + Msatoshi: 1000, + Description: "Test invoice", + }, + ) + if err != nil { + log.Errorf("[registerNodeHandler] Could not add user %s's %s node: %s", GetUserStr(user.Telegram), getInvoiceParams.Status, err.Error()) + bot.tryEditMessage(check_message, errorCouldNotAddNodeMessage) + return ctx, err + } + // save node in db + user.Settings.Node.LNbitsParams = &backend + user.Settings.Node.NodeType = "lnbits" + + } + err = UpdateUserRecord(user, *bot) + if err != nil { + log.Errorf("[registerNodeHandler] could not update record of user %s: %v", GetUserStr(user.Telegram), err) + return ctx, err + } + node_info_str, err := nodeInfoString(&user.Settings.Node) + if err != nil { + log.Infof("Could not get node info for user %s", GetUserStr(user.Telegram)) + bot.trySendMessage(m.Sender, registerNodeMessage+"\n\n"+nodeHelpMessage) + return ctx, err + } + bot.tryEditMessage(check_message, fmt.Sprintf("%s\n\n%s", node_info_str, nodeAddedMessage)) + + log.Infof("[node:add] Added node of user %s backend %s", GetUserStr(user.Telegram), user.Settings.Node.NodeType) + return ctx, nil +} + +func (bot *TipBot) invHandler(ctx intercept.Context) (intercept.Context, error) { + m := ctx.Message() + user, err := GetLnbitsUserWithSettings(m.Sender, *bot) + if err != nil { + return ctx, err + } + if user.Settings == nil || user.Settings.Node.NodeType == "" { + bot.trySendMessage(m.Sender, "You did not register a node yet.") + return ctx, fmt.Errorf("node of user %s not registered", GetUserStr(user.Telegram)) + } + + var amount int64 + if amount_str, err := getArgumentFromCommand(m.Text, 2); err == nil { + amount, err = GetAmount(amount_str) + if err != nil { + return ctx, err + } + } + + log.Infof("[node:invoice] Getting invoice for user %s backend %s", GetUserStr(user.Telegram), user.Settings.Node.NodeType) + + check_message := bot.trySendMessageEditable(user.Telegram, routingInvoiceMessage) + var getInvoiceParams satdress.CheckInvoiceParams + + switch user.Settings.Node.NodeType { + case "lnd": + // get invoice from user's node + getInvoiceParams, err = satdress.MakeInvoice( + satdress.Params{ + Backend: satdress.LNDParams{ + Cert: []byte(user.Settings.Node.LNDParams.CertString), + Host: user.Settings.Node.LNDParams.Host, + Macaroon: user.Settings.Node.LNDParams.Macaroon, + }, + Msatoshi: amount * 1000, + Description: fmt.Sprintf("Invoice by %s", GetUserStr(bot.Telegram.Me)), + }, + ) + case "lnbits": + // get invoice from user's node + getInvoiceParams, err = satdress.MakeInvoice( + satdress.Params{ + Backend: satdress.LNBitsParams{ + Key: user.Settings.Node.LNbitsParams.Key, + Host: user.Settings.Node.LNbitsParams.Host, + }, + Msatoshi: amount * 1000, + Description: fmt.Sprintf("Invoice by %s", GetUserStr(bot.Telegram.Me)), + }, + ) + default: + return ctx, fmt.Errorf("unknown node type %s", user.Settings.Node.NodeType) + } + if err != nil { + log.Errorln(err.Error()) + bot.tryEditMessage(check_message, gettingInvoiceOnlyErrorMessage) + return ctx, err + } + + // bot.trySendMessage(m.Sender, fmt.Sprintf("PR: `%s`\n\nHash: `%s`\n\nStatus: `%s`", getInvoiceParams.PR, string(getInvoiceParams.Hash), getInvoiceParams.Status)) + + // create qr code + qr, err := qrcode.Encode(getInvoiceParams.PR, qrcode.Medium, 256) + if err != nil { + errmsg := fmt.Sprintf("[/invoice] Failed to create QR code for invoice: %s", err.Error()) + bot.trySendMessage(user.Telegram, Translate(ctx, "errorTryLaterMessage")) + log.Errorln(errmsg) + return ctx, err + } + bot.trySendMessage(m.Sender, &tb.Photo{File: tb.File{FileReader: bytes.NewReader(qr)}, Caption: fmt.Sprintf("`%s`", getInvoiceParams.PR)}) + + // add the getInvoiceParams to cache to check it later + bot.Cache.Set(fmt.Sprintf("invoice:%d", user.Telegram.ID), getInvoiceParams, &store.Options{Expiration: 24 * time.Hour}) + + log.Infof("[node:invoice] Invoice created for user %s backend %s", GetUserStr(user.Telegram), user.Settings.Node.NodeType) + + // check if invoice settles + return bot.satdressCheckInvoiceHandler(ctx) +} + +func (bot *TipBot) satdressCheckInvoiceHandler(ctx intercept.Context) (intercept.Context, error) { + tgUser := LoadUser(ctx).Telegram + user, err := GetLnbitsUserWithSettings(tgUser, *bot) + if err != nil { + return ctx, err + } + + // get the getInvoiceParams from cache + log.Debugf("[Cache] Getting key: %s", fmt.Sprintf("invoice:%d", user.Telegram.ID)) + getInvoiceParamsInterface, err := bot.Cache.Get(fmt.Sprintf("invoice:%d", user.Telegram.ID)) + if err != nil { + log.Errorf("[satdressCheckInvoiceHandler] UserID: %d, %s", user.Telegram.ID, err.Error()) + return ctx, err + } + getInvoiceParams := getInvoiceParamsInterface.(satdress.CheckInvoiceParams) + + // check the invoice + + // check if there is an invoice check message in cache already + check_message_interface, err := bot.Cache.Get(fmt.Sprintf("invoice:msg:%s", getInvoiceParams.Hash)) + var check_message *tb.Message + if err != nil { + // send a new message if there isn't one in the cache + check_message = bot.trySendMessageEditable(tgUser, checkingInvoiceMessage) + } else { + check_message = check_message_interface.(*tb.Message) + check_message, err = bot.tryEditMessage(check_message, checkingInvoiceMessage) + if err != nil { + log.Errorf("[satdressCheckInvoiceHandler] UserID: %d, %s", user.Telegram.ID, err.Error()) + } + } + + // save it in the cache for another call later + bot.Cache.Set(fmt.Sprintf("invoice:msg:%s", getInvoiceParams.Hash), check_message, &store.Options{Expiration: 24 * time.Hour}) + + deadLineCtx, cancel := context.WithDeadline(ctx, time.Now().Add(time.Second*60)) + runtime.NewRetryTicker(deadLineCtx, "node_invoice_check", runtime.WithRetryDuration(5*time.Second)).Do(func() { + // get invoice from user's node + log.Debugf("[satdressCheckInvoiceHandler] Checking invoice: %s", getInvoiceParams.Hash) + getInvoiceParams, err = satdress.CheckInvoice(getInvoiceParams) + if err != nil { + log.Errorln(err.Error()) + return + } + if getInvoiceParams.Status == "SETTLED" { + log.Debugf("[satdressCheckInvoiceHandler] Invoice settled: %s", getInvoiceParams.Hash) + bot.tryEditMessage(check_message, invoiceSettledMessage) + cancel() + } + + }, func() { + // cancel + }, + func() { + // deadline + log.Debugf("[satdressCheckInvoiceHandler] Invoice check expired: %s", getInvoiceParams.Hash) + bot.tryEditMessage(check_message, invoiceNotSettledMessage, + &tb.ReplyMarkup{ + InlineKeyboard: [][]tb.InlineButton{ + {tb.InlineButton{Text: checkInvoiceButtonMessage, Unique: "satdress_check_invoice"}}, + }, + }) + }, + ) + + return ctx, nil +} + +func parseCertificateToPem(cert string) []byte { + block, _ := pem.Decode([]byte(cert)) + if block != nil { + // already PEM + return []byte(cert) + } else { + var dec []byte + + dec, err := hex.DecodeString(cert) + if err != nil { + // not HEX + dec, err = base64.StdEncoding.DecodeString(cert) + if err != nil { + // not base54, we have a problem huston + return nil + } + } + if block, _ := pem.Decode(dec); block != nil { + return dec + } + // decoding went wrong + return nil + } +} + +func (bot *TipBot) satdressProxyHandler(ctx intercept.Context) (intercept.Context, error) { + m := ctx.Message() + user, err := GetLnbitsUserWithSettings(m.Sender, *bot) + if err != nil { + return ctx, err + } + if user.Settings == nil || user.Settings.Node.LNDParams == nil { + bot.trySendMessage(user.Telegram, "You did not register a node yet.") + log.Errorf("node of user %s not registered", GetUserStr(user.Telegram)) + return ctx, fmt.Errorf("no node settings.") + } + + var amount int64 + if amount_str, err := getArgumentFromCommand(m.Text, 2); err == nil { + amount, err = GetAmount(amount_str) + if err != nil { + return ctx, err + } + } + + memo := "🔀 Payment proxy in." + invoice, err := bot.createInvoiceWithEvent(ctx, user, amount, memo, "", InvoiceCallbackSatdressProxy, "") + if err != nil { + errmsg := fmt.Sprintf("[/invoice] Could not create an invoice: %s", err.Error()) + bot.trySendMessage(user.Telegram, Translate(ctx, "errorTryLaterMessage")) + log.Errorln(errmsg) + return ctx, err + } + + // 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.Error()) + bot.trySendMessage(user.Telegram, Translate(ctx, "errorTryLaterMessage")) + log.Errorln(errmsg) + return ctx, err + } + bot.trySendMessage(m.Sender, &tb.Photo{File: tb.File{FileReader: bytes.NewReader(qr)}, Caption: fmt.Sprintf("`%s`", invoice.PaymentRequest)}) + + log.Infof("[node] Proxy payment for user %s backend %s", GetUserStr(user.Telegram), user.Settings.Node.NodeType) + return ctx, nil +} + +func (bot *TipBot) satdressProxyRelayPaymentHandler(event Event) { + invoiceEvent := event.(*InvoiceEvent) + user := invoiceEvent.User + if user.Settings == nil || user.Settings.Node.LNDParams == nil { + bot.trySendMessage(user.Telegram, "You did not register a node yet.") + log.Errorf("node of user %s not registered", GetUserStr(user.Telegram)) + return + } + + log.Infof("[node:proxy] Relaying payment for user %s backend %s", GetUserStr(user.Telegram), user.Settings.Node.NodeType) + + bot.notifyInvoiceReceivedEvent(invoiceEvent) + + // now relay the payment to the user's node + var amount int64 = invoiceEvent.Amount + + check_message := bot.trySendMessageEditable(user.Telegram, routingInvoiceMessage) + var getInvoiceParams satdress.CheckInvoiceParams + var err error + if user.Settings.Node.NodeType == "lnd" { + // get invoice from user's node + getInvoiceParams, err = satdress.MakeInvoice( + satdress.Params{ + Backend: satdress.LNDParams{ + Cert: []byte(user.Settings.Node.LNDParams.CertString), + Host: user.Settings.Node.LNDParams.Host, + Macaroon: user.Settings.Node.LNDParams.Macaroon, + }, + Msatoshi: amount * 1000, + Description: fmt.Sprintf("🔀 Payment proxy out from %s.", GetUserStr(bot.Telegram.Me)), + }, + ) + } else if user.Settings.Node.NodeType == "lnbits" { + // get invoice from user's node + getInvoiceParams, err = satdress.MakeInvoice( + satdress.Params{ + Backend: satdress.LNBitsParams{ + Key: user.Settings.Node.LNbitsParams.Key, + Host: user.Settings.Node.LNbitsParams.Host, + }, + Msatoshi: amount * 1000, + Description: fmt.Sprintf("🔀 Payment proxy out from %s.", GetUserStr(bot.Telegram.Me)), + }, + ) + } + if err != nil { + log.Errorln(err.Error()) + bot.tryEditMessage(check_message, gettingInvoiceErrorMessage) + return + } + + // bot.trySendMessage(user.Telegram, fmt.Sprintf("PR: `%s`\n\nHash: `%s`\n\nStatus: `%s`", getInvoiceParams.PR, string(getInvoiceParams.Hash), getInvoiceParams.Status)) + + log.Infof("[node:proxy] Retrieved invoice for payment of user %s backend %s. Paying...", GetUserStr(user.Telegram), user.Settings.Node.NodeType) + + // pay invoice + invoice, err := user.Wallet.Pay(lnbits.PaymentParams{Out: true, Bolt11: getInvoiceParams.PR}, bot.Client) + if err != nil { + errmsg := fmt.Sprintf("[/pay] Could not pay invoice of %s: %s", GetUserStr(user.Telegram), err) + // err = fmt.Errorf(i18n.Translate(payData.LanguageCode, "invoiceUndefinedErrorMessage")) + // bot.tryEditMessage(c.Message, fmt.Sprintf(i18n.Translate(payData.LanguageCode, "invoicePaymentFailedMessage"), err.Error()), &tb.ReplyMarkup{}) + // verbose error message, turned off for now + // if len(err.Error()) == 0 { + // err = fmt.Errorf(i18n.Translate(payData.LanguageCode, "invoiceUndefinedErrorMessage")) + // } + // bot.tryEditMessage(c.Message, fmt.Sprintf(i18n.Translate(payData.LanguageCode, "invoicePaymentFailedMessage"), str.MarkdownEscape(err.Error())), &tb.ReplyMarkup{}) + log.Errorln(errmsg) + bot.tryEditMessage(check_message, payingInvoiceErrorMessage) + return + } + + // object that holds all information about the send payment + id := fmt.Sprintf("proxypay:%d:%d:%s", user.Telegram.ID, amount, RandStringRunes(8)) + + payData := &PayData{ + Base: storage.New(storage.ID(id)), + From: user, + Invoice: invoice.PaymentRequest, + Hash: invoice.PaymentHash, + Amount: amount, + } + // add result to persistent struct + runtime.IgnoreError(payData.Set(payData, bot.Bunt)) + + // add the getInvoiceParams to cache to check it later + bot.Cache.Set(fmt.Sprintf("invoice:%d", user.Telegram.ID), getInvoiceParams, &store.Options{Expiration: 24 * time.Hour}) + + time.Sleep(time.Second) + + getInvoiceParams, err = satdress.CheckInvoice(getInvoiceParams) + if err != nil { + log.Errorln(err.Error()) + return + } + bot.tryEditMessage(check_message, invoiceRoutedMessage) + + log.Infof("[node:proxy] Proxy paid for user %s backend %s.", GetUserStr(user.Telegram), user.Settings.Node.NodeType) + // bot.trySendMessage(user.Telegram, fmt.Sprintf("PR: `%s`\n\nHash: `%s`\n\nStatus: `%s`", getInvoiceParams.PR, string(getInvoiceParams.Hash), getInvoiceParams.Status)) + + return +} diff --git a/internal/telegram/send.go b/internal/telegram/send.go new file mode 100644 index 00000000..f9695f4f --- /dev/null +++ b/internal/telegram/send.go @@ -0,0 +1,392 @@ +package telegram + +import ( + "context" + "encoding/json" + "fmt" + "regexp" + "strings" + + "github.com/LightningTipBot/LightningTipBot/internal/telegram/intercept" + + "github.com/LightningTipBot/LightningTipBot/internal/errors" + + "github.com/LightningTipBot/LightningTipBot/internal/runtime/mutex" + "github.com/LightningTipBot/LightningTipBot/internal/storage" + + "github.com/LightningTipBot/LightningTipBot/internal/i18n" + "github.com/LightningTipBot/LightningTipBot/internal/lnbits" + "github.com/LightningTipBot/LightningTipBot/internal/runtime" + "github.com/LightningTipBot/LightningTipBot/internal/thirdparty" + + "github.com/LightningTipBot/LightningTipBot/internal/str" + "github.com/LightningTipBot/LightningTipBot/pkg/lightning" + log "github.com/sirupsen/logrus" + tb "gopkg.in/lightningtipbot/telebot.v3" +) + +var ( + sendConfirmationMenu = &tb.ReplyMarkup{ResizeKeyboard: true} + btnCancelSend = sendConfirmationMenu.Data("🚫 Cancel", "cancel_send") + btnSend = sendConfirmationMenu.Data("✅ Send", "confirm_send") +) + +func helpSendUsage(ctx context.Context, errormsg string) string { + if len(errormsg) > 0 { + return fmt.Sprintf(Translate(ctx, "sendHelpText"), fmt.Sprintf("%s", errormsg)) + } else { + return fmt.Sprintf(Translate(ctx, "sendHelpText"), "") + } +} + +func (bot *TipBot) SendCheckSyntax(ctx context.Context, m *tb.Message) (bool, string) { + arguments := strings.Split(m.Text, " ") + if len(arguments) < 2 { + return false, fmt.Sprintf(Translate(ctx, "sendSyntaxErrorMessage"), GetUserStrMd(bot.Telegram.Me)) + } + return true, "" +} + +type SendData struct { + *storage.Base + From *lnbits.User `json:"from"` + ToTelegramId int64 `json:"to_telegram_id"` + ToTelegramUser string `json:"to_telegram_user"` + Memo string `json:"memo"` + Message string `json:"message"` + Amount int64 `json:"amount"` + LanguageCode string `json:"languagecode"` +} + +// sendHandler invoked on "/send 123 @user" command +func (bot *TipBot) sendHandler(ctx intercept.Context) (intercept.Context, error) { + bot.anyTextHandler(ctx) + user := LoadUser(ctx) + if user.Wallet == nil { + return ctx, errors.Create(errors.UserNoWalletError) + } + + // reset state immediately + ResetUserState(user, bot) + + // check and print all commands + + // If the send is a reply, then trigger /tip handler + if ctx.Message().IsReply() && ctx.Message().Chat.Type != tb.ChatPrivate { + return bot.tipHandler(ctx) + + } + + if ok, errstr := bot.SendCheckSyntax(ctx, ctx.Message()); !ok { + bot.trySendMessage(ctx.Message().Sender, helpSendUsage(ctx, errstr)) + NewMessage(ctx.Message(), WithDuration(0, bot)) + return ctx, errors.Create(errors.InvalidSyntaxError) + } + + // get send amount, returns 0 if no amount is given + amount, err := decodeAmountFromCommand(ctx.Text()) + // info: /send 10 DEMANDS an amount, while /send also works without + // todo: /send should also invoke amount input dialog if no amount is given + if err != nil { + return ctx, err + } + // CHECK whether first or second argument is a LIGHTNING ADDRESS + arg := "" + space := regexp.MustCompile(`\s+`) + ctx.Message().Text = space.ReplaceAllString(ctx.Message().Text, " ") + if len(strings.Split(ctx.Message().Text, " ")) > 2 { + arg, err = getArgumentFromCommand(ctx.Message().Text, 2) + if err != nil { + return ctx, err + } + } else if len(strings.Split(ctx.Message().Text, " ")) == 2 { + arg, err = getArgumentFromCommand(ctx.Message().Text, 1) + if err != nil { + return ctx, err + } + } + if err == nil { + if lightning.IsLightningAddress(arg) { + // lightning address, send to that address + ctx, err = bot.sendToLightningAddress(ctx, arg, amount) + if err != nil { + log.Errorln(err.Error()) + return ctx, err + } + return ctx, err + } + } + + // is a user given? + arg, err = getArgumentFromCommand(ctx.Message().Text, 1) + if err != nil && ctx.Message().Chat.Type == tb.ChatPrivate { + _, err = bot.askForUser(ctx, "", "CreateSendState", ctx.Message().Text) + return ctx, err + } + + // is an amount given? + amount, err = decodeAmountFromCommand(ctx.Message().Text) + if (err != nil || amount < 1) && ctx.Message().Chat.Type == tb.ChatPrivate { + _, err = bot.askForAmount(ctx, "", "CreateSendState", 0, 0, ctx.Message().Text) + return ctx, err + } + + // ASSUME INTERNAL SEND TO TELEGRAM USER + if err != nil || amount < 1 { + errmsg := fmt.Sprintf("[/send] Error: Send amount not valid.") + log.Warnln(errmsg) + // immediately delete if the amount is bullshit + NewMessage(ctx.Message(), WithDuration(0, bot)) + bot.trySendMessage(ctx.Sender(), helpSendUsage(ctx, Translate(ctx, "sendValidAmountMessage"))) + return ctx, err + } + + // SEND COMMAND IS VALID + // check for memo in command + sendMemo := GetMemoFromCommand(ctx.Message().Text, 3) + + toUserStrMention := "" + toUserStrWithoutAt := "" + + // check for user in command, accepts user mention or plain username without @ + if len(ctx.Message().Entities) > 1 && ctx.Message().Entities[1].Type == "mention" { + toUserStrMention = ctx.Message().Text[ctx.Message().Entities[1].Offset : ctx.Message().Entities[1].Offset+ctx.Message().Entities[1].Length] + toUserStrWithoutAt = strings.TrimPrefix(toUserStrMention, "@") + } else { + toUserStrWithoutAt, err = getArgumentFromCommand(ctx.Message().Text, 2) + if err != nil { + log.Errorln(err.Error()) + return ctx, err + } + toUserStrWithoutAt = strings.TrimPrefix(toUserStrWithoutAt, "@") + toUserStrMention = "@" + toUserStrWithoutAt + } + + err = bot.parseCmdDonHandler(ctx) + if err == nil { + return ctx, errors.Create(errors.InvalidSyntaxError) + } + + toUserDb, err := GetUserByTelegramUsername(toUserStrWithoutAt, *bot) + if err != nil { + NewMessage(ctx.Message(), WithDuration(0, bot)) + // cut username if it's too long + if len(toUserStrMention) > 100 { + toUserStrMention = toUserStrMention[:100] + } + bot.trySendMessage(ctx.Message().Sender, fmt.Sprintf(Translate(ctx, "sendUserHasNoWalletMessage"), str.MarkdownEscape(toUserStrMention))) + return ctx, err + } + + if user.ID == toUserDb.ID { + bot.trySendMessage(ctx.Message().Sender, Translate(ctx, "sendYourselfMessage")) + return ctx, errors.Create(errors.SelfPaymentError) + } + + // entire text of the inline object + confirmText := fmt.Sprintf(Translate(ctx, "confirmSendMessage"), str.MarkdownEscape(toUserStrMention), thirdparty.FormatSatsWithLKR(amount)) + if len(sendMemo) > 0 { + confirmText = confirmText + fmt.Sprintf(Translate(ctx, "confirmSendAppendMemo"), str.MarkdownEscape(sendMemo)) + } + // object that holds all information about the send payment + id := fmt.Sprintf("send-%d-%d-%s", ctx.Message().Sender.ID, amount, RandStringRunes(5)) + sendData := &SendData{ + From: user, + Base: storage.New(storage.ID(id)), + Amount: int64(amount), + ToTelegramId: toUserDb.Telegram.ID, + ToTelegramUser: toUserStrWithoutAt, + Memo: sendMemo, + Message: confirmText, + LanguageCode: ctx.Value("publicLanguageCode").(string), + } + // save persistent struct + runtime.IgnoreError(sendData.Set(sendData, bot.Bunt)) + + sendDataJson, err := json.Marshal(sendData) + if err != nil { + NewMessage(ctx.Message(), WithDuration(0, bot)) + log.Printf("[/send] Error: %s\n", err.Error()) + bot.trySendMessage(ctx.Message().Sender, fmt.Sprint(Translate(ctx, "errorTryLaterMessage"))) + return ctx, err + } + // save the send data to the Database + // log.Debug(sendData) + SetUserState(user, bot, lnbits.UserStateConfirmSend, string(sendDataJson)) + sendButton := sendConfirmationMenu.Data(Translate(ctx, "sendButtonMessage"), "confirm_send") + cancelButton := sendConfirmationMenu.Data(Translate(ctx, "cancelButtonMessage"), "cancel_send") + sendButton.Data = id + cancelButton.Data = id + + sendConfirmationMenu.Inline( + sendConfirmationMenu.Row( + sendButton, + cancelButton), + ) + if ctx.Message().Private() { + bot.trySendMessage(ctx.Chat(), confirmText, sendConfirmationMenu) + } else { + bot.tryReplyMessage(ctx.Message(), confirmText, sendConfirmationMenu) + } + return ctx, nil +} + +// keyboardSendHandler will be called when the user presses the Send button on the keyboard +// it will pop up a new keyboard with the last interacted contacts to send funds to +// then, the flow is handled as if the user entered /send (then ask for contacts from keyboard or entry, +// then ask for an amount). +func (bot *TipBot) keyboardSendHandler(ctx intercept.Context) (intercept.Context, error) { + user := LoadUser(ctx) + if user.Wallet == nil { + return ctx, errors.Create(errors.UserNoWalletError) + } + enterUserStateData := &EnterUserStateData{ + ID: "id", + Type: "CreateSendState", + OiringalCommand: "/send", + } + // set LNURLPayParams in the state of the user + stateDataJson, err := json.Marshal(enterUserStateData) + if err != nil { + log.Errorln(err) + return ctx, err + } + SetUserState(user, bot, lnbits.UserEnterUser, string(stateDataJson)) + sendToButtons = bot.makeContactsButtons(ctx) + + // if no contact is found (one entry will always be inside, it's the enter user button) + // immediatelly go to the send handler + if len(sendToButtons) == 1 { + ctx.Message().Text = "/send" + return bot.sendHandler(ctx) + } + + // Attention! We need to ues the original Telegram.Send command here! + // bot.trySendMessage will replace the keyboard with the default one and we want to send a different keyboard here + // this is suboptimal because Telegram.Send is not rate limited etc. but it's the only way to send a custom keyboard for now + _, err = bot.Telegram.Send(user.Telegram, Translate(ctx, "enterUserMessage"), sendToMenu) + if err != nil { + log.Errorln(err.Error()) + } + return ctx, nil +} + +// sendHandler invoked when user clicked send on payment confirmation +func (bot *TipBot) confirmSendHandler(ctx intercept.Context) (intercept.Context, error) { + tx := &SendData{Base: storage.New(storage.ID(ctx.Data()))} + mutex.LockWithContext(ctx, tx.ID) + defer mutex.UnlockWithContext(ctx, tx.ID) + sn, err := tx.Get(tx, bot.Bunt) + if err != nil { + log.Errorf("[acceptSendHandler] %s", err.Error()) + return ctx, err + } + sendData := sn.(*SendData) + // onnly the correct user can press + if sendData.From.Telegram.ID != ctx.Callback().Sender.ID { + return ctx, errors.Create(errors.UnknownError) + } + if !sendData.Active { + log.Errorf("[acceptSendHandler] send not active anymore") + // bot.tryDeleteMessage(c.Message) + return ctx, errors.Create(errors.NotActiveError) + } + defer sendData.Set(sendData, bot.Bunt) + + // // remove buttons from confirmation message + // bot.tryEditMessage(c.Message, MarkdownEscape(sendData.Message), &tb.ReplyMarkup{}) + + // decode callback data + // log.Debug("[send] Callback: %s", c.Data) + from := LoadUser(ctx) + ResetUserState(from, bot) // we don't need to check the statekey anymore like we did earlier + + // information about the send + toId := sendData.ToTelegramId + toUserStrWithoutAt := sendData.ToTelegramUser + amount := sendData.Amount + sendMemo := sendData.Memo + + // we can now get the wallets of both users + to, err := GetLnbitsUser(&tb.User{ID: toId, Username: toUserStrWithoutAt}, *bot) + if err != nil { + log.Errorln(err.Error()) + if bot.ErrorLogger != nil { + bot.ErrorLogger.LogError(err, "Failed to get recipient user for send", from.Telegram) + } + bot.tryDeleteMessage(ctx.Callback().Message) + return ctx, err + } + toUserStrMd := GetUserStrMd(to.Telegram) + fromUserStrMd := GetUserStrMd(from.Telegram) + toUserStr := GetUserStr(to.Telegram) + fromUserStr := GetUserStr(from.Telegram) + + transactionMemo := fmt.Sprintf("💸 Send from %s to %s.", fromUserStr, toUserStr) + t := NewTransaction(bot, from, to, amount, TransactionType("send")) + t.Memo = transactionMemo + + success, err := t.Send() + if !success || err != nil { + // Enhanced error logging for send transaction failures + errmsg := fmt.Sprintf("[/send] Error: Transaction failed. %s", err.Error()) + log.Errorln(errmsg) + if bot.ErrorLogger != nil { + // Log detailed send transaction error with sender/receiver info + bot.ErrorLogger.LogTransactionError(err, "send", amount, from.Telegram, to.Telegram) + } + bot.tryEditMessage(ctx.Callback().Message, i18n.Translate(sendData.LanguageCode, "sendErrorMessage"), &tb.ReplyMarkup{}) + return ctx, errors.Create(errors.UnknownError) + } + sendData.Inactivate(sendData, bot.Bunt) + + log.Infof("[💸 send] Send from %s to %s (%d sat).", fromUserStr, toUserStr, amount) + + // notify to user + bot.trySendMessage(to.Telegram, fmt.Sprintf(i18n.Translate(to.Telegram.LanguageCode, "sendReceivedMessage"), fromUserStrMd, thirdparty.FormatSatsWithLKR(amount))) + // bot.trySendMessage(from.Telegram, fmt.Sprintf(Translate(ctx, "sendSentMessage"), amount, toUserStrMd)) + if ctx.Callback().Message.Private() { + // if the command was invoked in private chat + // the edit below was cool, but we need to get rid of the replymarkup inline keyboard thingy for the main menu to pop up + // bot.tryEditMessage(c.Message, fmt.Sprintf(i18n.Translate(sendData.LanguageCode, "sendSentMessage"), amount, toUserStrMd), &tb.ReplyMarkup{}) + bot.tryDeleteMessage(ctx.Callback().Message) + bot.trySendMessage(ctx.Callback().Sender, fmt.Sprintf(i18n.Translate(sendData.LanguageCode, "sendSentMessage"), thirdparty.FormatSatsWithLKR(amount), toUserStrMd)) + } else { + // if the command was invoked in group chat + bot.trySendMessage(ctx.Callback().Sender, fmt.Sprintf(i18n.Translate(from.Telegram.LanguageCode, "sendSentMessage"), thirdparty.FormatSatsWithLKR(amount), toUserStrMd)) + bot.tryEditMessage(ctx.Callback().Message, fmt.Sprintf(i18n.Translate(sendData.LanguageCode, "sendPublicSentMessage"), thirdparty.FormatSatsWithLKR(amount), fromUserStrMd, toUserStrMd), &tb.ReplyMarkup{}) + } + // send memo if it was present + if len(sendMemo) > 0 { + bot.trySendMessage(to.Telegram, fmt.Sprintf("✉️ %s", str.MarkdownEscape(sendMemo))) + } + + return ctx, nil +} + +// cancelPaymentHandler invoked when user clicked cancel on payment confirmation +func (bot *TipBot) cancelSendHandler(ctx intercept.Context) (intercept.Context, error) { + // reset state immediately + c := ctx.Callback() + user := LoadUser(ctx) + ResetUserState(user, bot) + tx := &SendData{Base: storage.New(storage.ID(c.Data))} + mutex.LockWithContext(ctx, tx.ID) + defer mutex.UnlockWithContext(ctx, tx.ID) + sn, err := tx.Get(tx, bot.Bunt) + if err != nil { + log.Errorf("[acceptSendHandler] %s", err.Error()) + return ctx, err + } + + sendData := sn.(*SendData) + // onnly the correct user can press + if sendData.From.Telegram.ID != c.Sender.ID { + return ctx, errors.Create(errors.UnknownError) + } + // delete and send instead of edit for the keyboard to pop up after sending + bot.tryDeleteMessage(c) + bot.trySendMessage(c.Message.Chat, i18n.Translate(sendData.LanguageCode, "sendCancelledMessage")) + sendData.Inactivate(sendData, bot.Bunt) + return ctx, nil +} diff --git a/internal/telegram/setting.go b/internal/telegram/setting.go new file mode 100644 index 00000000..9e1fc998 --- /dev/null +++ b/internal/telegram/setting.go @@ -0,0 +1,68 @@ +package telegram + +import ( + "fmt" + "strings" + + "github.com/LightningTipBot/LightningTipBot/internal/telegram/intercept" + log "github.com/sirupsen/logrus" +) + +var ( + settingsHelpMessage = "📖 Change user settings\n\n`/set unit ` 💶 Change your default currency." +) + +func (bot *TipBot) settingHandler(ctx intercept.Context) (intercept.Context, error) { + m := ctx.Message() + splits := strings.Split(m.Text, " ") + if len(splits) == 1 { + bot.trySendMessage(m.Sender, settingsHelpMessage) + } else if len(splits) > 1 { + switch strings.ToLower(splits[1]) { + case "unit": + return bot.addFiatCurrency(ctx) + case "help": + return bot.nostrHelpHandler(ctx) + } + } + return ctx, nil +} + +func (bot *TipBot) addFiatCurrency(ctx intercept.Context) (intercept.Context, error) { + m := ctx.Message() + user, err := GetLnbitsUserWithSettings(m.Sender, *bot) + if err != nil { + return ctx, err + } + + splits := strings.Split(m.Text, " ") + splitlen := len(splits) + if splitlen < 3 { + // display users current fiat currency + currentCurrency := strings.ToUpper(user.Settings.Display.DisplayCurrency) + if currentCurrency == "" { + currentCurrency = "BTC" + } + bot.trySendMessage(ctx.Message().Sender, fmt.Sprintf("🌍 Your current default currency is `%s`", currentCurrency)) + return ctx, nil + } + currencyInput := splits[2] + // convert to lowercase and check if in [usd, eur, gbp, btc, sat] + currencyInput = strings.ToLower(currencyInput) + if currencyInput != "usd" && currencyInput != "eur" && currencyInput != "gbp" && currencyInput != "btc" && currencyInput != "sat" { + bot.trySendMessage(ctx.Message().Sender, "🚫 Invalid currency. Please use one of the following: `BTC`, `USD`, `EUR`") + return ctx, fmt.Errorf("invalid currency") + } + if currencyInput == "sat" { + currencyInput = "BTC" + } + // save node in db + user.Settings.Display.DisplayCurrency = currencyInput + err = UpdateUserRecord(user, *bot) + if err != nil { + log.Errorf("[registerNodeHandler] could not update record of user %s: %v", GetUserStr(user.Telegram), err) + return ctx, err + } + bot.trySendMessage(ctx.Message().Sender, "✅ Your default currency has been updated.") + return ctx, nil +} diff --git a/internal/telegram/shop.go b/internal/telegram/shop.go new file mode 100644 index 00000000..f418595c --- /dev/null +++ b/internal/telegram/shop.go @@ -0,0 +1,1469 @@ +package telegram + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/LightningTipBot/LightningTipBot/internal/telegram/intercept" + + "github.com/LightningTipBot/LightningTipBot/internal/errors" + "github.com/LightningTipBot/LightningTipBot/internal/utils" + + "github.com/LightningTipBot/LightningTipBot/internal/i18n" + "github.com/LightningTipBot/LightningTipBot/internal/lnbits" + "github.com/LightningTipBot/LightningTipBot/internal/runtime" + "github.com/LightningTipBot/LightningTipBot/internal/storage" + "github.com/LightningTipBot/LightningTipBot/internal/str" + "github.com/eko/gocache/store" + log "github.com/sirupsen/logrus" + tb "gopkg.in/lightningtipbot/telebot.v3" +) + +type ShopView struct { + ID string + ShopID string + ShopOwner *lnbits.User + Page int + Message *tb.Message + StatusMessages []*tb.Message +} + +type ShopItem struct { + ID string `json:"ID"` // ID of the tx object in bunt db + ShopID string `json:"shopID"` // ID of the shop + Owner *lnbits.User `json:"owner"` // Owner of the item + Type string `json:"Type"` // Type of the tx object in bunt db + FileIDs []string `json:"fileIDs"` // Telegram fileID of the item files + FileTypes []string `json:"fileTypes"` // Telegram file type of the item files + Title string `json:"title"` // Title of the item + Description string `json:"description"` // Description of the item + Price int64 `json:"price"` // price of the item + NSold int `json:"nSold"` // number of times item was sold + TbPhoto *tb.Photo `json:"tbPhoto"` // Telegram photo object + LanguageCode string `json:"languagecode"` + MaxFiles int `json:"maxFiles"` +} + +type Shop struct { + *storage.Base + Owner *lnbits.User `json:"owner"` // owner of the shop + Type string `json:"Type"` // type of the shop + Title string `json:"title"` // Title of the item + Description string `json:"description"` // Description of the item + ItemIds []string `json:"ItemsIDs"` // + Items map[string]ShopItem `json:"Items"` // + LanguageCode string `json:"languagecode"` + ShopsID string `json:"shopsID"` + MaxItems int `json:"maxItems"` +} + +type Shops struct { + *storage.Base + Owner *lnbits.User `json:"owner"` // owner of the shop + Shops []string `json:"shop"` // + MaxShops int `json:"maxShops"` + Description string `json:"description"` +} + +const ( + MAX_SHOPS = 10 + MAX_ITEMS_PER_SHOP = 20 + MAX_FILES_PER_ITEM = 200 + SHOP_TITLE_MAX_LENGTH = 50 + ITEM_TITLE_MAX_LENGTH = 1500 + SHOPS_DESCRIPTION_MAX_LENGTH = 1500 +) + +func (shop *Shop) getItem(itemId string) (item ShopItem, ok bool) { + item, ok = shop.Items[itemId] + return +} + +var ( + shopKeyboard = &tb.ReplyMarkup{ResizeKeyboard: false} + browseShopButton = shopKeyboard.Data("Browse shops", "shops_browse") + shopNewShopButton = shopKeyboard.Data("New Shop", "shops_newshop") + shopDeleteShopButton = shopKeyboard.Data("Delete Shops", "shops_deleteshop") + shopLinkShopButton = shopKeyboard.Data("Shop links", "shops_linkshop") + shopRenameShopButton = shopKeyboard.Data("Rename shop", "shops_renameshop") + shopResetShopAskButton = shopKeyboard.Data("Delete all shops", "shops_reset_ask") + shopResetShopButton = shopKeyboard.Data("Delete all shops", "shops_reset") + shopDescriptionShopButton = shopKeyboard.Data("Shop description", "shops_description") + shopSettingsButton = shopKeyboard.Data("Settings", "shops_settings") + shopShopsButton = shopKeyboard.Data("Back", "shops_shops") + + shopAddItemButton = shopKeyboard.Data("New item", "shop_additem") + shopNextitemButton = shopKeyboard.Data(">", "shop_nextitem") + shopPrevitemButton = shopKeyboard.Data("<", "shop_previtem") + shopBuyitemButton = shopKeyboard.Data("Buy", "shop_buyitem") + + shopSelectButton = shopKeyboard.Data("SHOP SELECTOR", "select_shop") // shop slectino buttons + shopDeleteSelectButton = shopKeyboard.Data("DELETE SHOP SELECTOR", "delete_shop") // shop slectino buttons + shopLinkSelectButton = shopKeyboard.Data("LINK SHOP SELECTOR", "link_shop") // shop slectino buttons + shopRenameSelectButton = shopKeyboard.Data("RENAME SHOP SELECTOR", "rename_shop") // shop slectino buttons + shopItemPriceButton = shopKeyboard.Data("Price", "shop_itemprice") + shopItemDeleteButton = shopKeyboard.Data("Delete", "shop_itemdelete") + shopItemTitleButton = shopKeyboard.Data("Set title", "shop_itemtitle") + shopItemAddFileButton = shopKeyboard.Data("Add file", "shop_itemaddfile") + shopItemSettingsButton = shopKeyboard.Data("Item settings", "shop_itemsettings") + shopItemSettingsBackButton = shopKeyboard.Data("Back", "shop_itemsettingsback") + + shopItemBuyButton = shopKeyboard.Data("Buy", "shop_itembuy") + shopItemCancelBuyButton = shopKeyboard.Data("Cancel", "shop_itemcancelbuy") +) + +// shopItemPriceHandler is invoked when the user presses the item settings button to set a price +func (bot *TipBot) shopItemPriceHandler(ctx intercept.Context) (intercept.Context, error) { + c := ctx.Callback() + log.Debugf("[shopItemPriceHandler] %s", c.Data) + user := LoadUser(ctx) + shopView, err := bot.getUserShopview(ctx, user) + if err != nil { + return ctx, err + } + shop, err := bot.getShop(ctx, shopView.ShopID) + if shop.Owner.Telegram.ID != c.Sender.ID { + return ctx, errors.Create(errors.UnknownError) + } + item := shop.Items[shop.ItemIds[shopView.Page]] + // sanity check + if item.ID != c.Data { + log.Error("[shopItemPriceHandler] item id mismatch") + return ctx, errors.Create(errors.ItemIdMismatchError) + } + // We need to save the pay state in the user state so we can load the payment in the next handler + SetUserState(user, bot, lnbits.UserStateShopItemSendPrice, item.ID) + bot.sendStatusMessage(ctx, c.Sender, fmt.Sprintf("💯 Enter a price."), tb.ForceReply) + return ctx, nil +} + +// enterShopItemPriceHandler is invoked when the user enters a price amount +func (bot *TipBot) enterShopItemPriceHandler(ctx intercept.Context) (intercept.Context, error) { + m := ctx.Message() + log.Debugf("[enterShopItemPriceHandler] %s", m.Text) + user := LoadUser(ctx) + shopView, err := bot.getUserShopview(ctx, user) + if err != nil { + return ctx, err + } + shop, err := bot.getShop(ctx, shopView.ShopID) + if err != nil { + return ctx, err + } + if shop.Owner.Telegram.ID != m.Sender.ID { + return ctx, errors.Create(errors.NotShopOwnerError) + } + item := shop.Items[shop.ItemIds[shopView.Page]] + // sanity check + if item.ID != user.StateData { + log.Error("[shopItemPriceHandler] item id mismatch") + return ctx, fmt.Errorf("item id mismatch") + } + + var amount int64 + if m.Text == "0" { + amount = 0 + } else { + amount, err = GetAmount(m.Text) + if err != nil { + log.Warnf("[enterShopItemPriceHandler] %s", err.Error()) + bot.trySendMessage(m.Sender, Translate(ctx, "lnurlInvalidAmountMessage")) + ResetUserState(user, bot) + return ctx, err + } + } + + item.Price = amount + shop.Items[item.ID] = item + runtime.IgnoreError(shop.Set(shop, bot.ShopBunt)) + bot.tryDeleteMessage(m) + bot.sendStatusMessageAndDelete(ctx, m.Sender, fmt.Sprintf("✅ Price set.")) + ResetUserState(user, bot) + // go func() { + // time.Sleep(time.Duration(5) * time.Second) + // bot.shopViewDeleteAllStatusMsgs(ctx, user) + // }() + bot.displayShopItem(ctx, shopView.Message, shop) + return ctx, nil +} + +// shopItemPriceHandler is invoked when the user presses the item settings button to set a item title +func (bot *TipBot) shopItemTitleHandler(ctx intercept.Context) (intercept.Context, error) { + c := ctx.Callback() + log.Debugf("[shopItemTitleHandler] %s", c.Data) + user := LoadUser(ctx) + shopView, err := bot.getUserShopview(ctx, user) + if err != nil { + return ctx, err + } + shop, err := bot.getShop(ctx, shopView.ShopID) + if shop.Owner.Telegram.ID != c.Sender.ID { + return ctx, errors.Create(errors.UnknownError) + } + item := shop.Items[shop.ItemIds[shopView.Page]] + // sanity check + if item.ID != c.Data { + log.Error("[shopItemTitleHandler] item id mismatch") + return ctx, errors.Create(errors.ItemIdMismatchError) + } + // We need to save the pay state in the user state so we can load the payment in the next handler + SetUserState(user, bot, lnbits.UserStateShopItemSendTitle, item.ID) + bot.sendStatusMessage(ctx, c.Sender, fmt.Sprintf("⌨️ Enter item title."), tb.ForceReply) + return ctx, nil +} + +// enterShopItemTitleHandler is invoked when the user enters a title of the item +func (bot *TipBot) enterShopItemTitleHandler(ctx intercept.Context) (intercept.Context, error) { + m := ctx.Message() + log.Debugf("[enterShopItemTitleHandler] %s", m.Text) + user := LoadUser(ctx) + shopView, err := bot.getUserShopview(ctx, user) + if err != nil { + return ctx, err + } + shop, err := bot.getShop(ctx, shopView.ShopID) + if err != nil { + return ctx, err + } + if shop.Owner.Telegram.ID != m.Sender.ID { + return ctx, errors.Create(errors.NotShopOwnerError) + } + item := shop.Items[shop.ItemIds[shopView.Page]] + // sanity check + if item.ID != user.StateData { + log.Error("[enterShopItemTitleHandler] item id mismatch") + return ctx, errors.Create(errors.ItemIdMismatchError) + } + if len(m.Text) == 0 { + ResetUserState(user, bot) + bot.sendStatusMessageAndDelete(ctx, m.Sender, "🚫 Action cancelled.") + go func() { + time.Sleep(time.Duration(5) * time.Second) + bot.shopViewDeleteAllStatusMsgs(ctx, user) + }() + return ctx, errors.Create(errors.InvalidSyntaxError) + } + // crop item title + if len(m.Text) > ITEM_TITLE_MAX_LENGTH { + m.Text = m.Text[:ITEM_TITLE_MAX_LENGTH] + } + item.Title = m.Text + item.TbPhoto.Caption = m.Text + shop.Items[item.ID] = item + runtime.IgnoreError(shop.Set(shop, bot.ShopBunt)) + bot.tryDeleteMessage(m) + bot.sendStatusMessageAndDelete(ctx, m.Sender, fmt.Sprintf("✅ Title set.")) + ResetUserState(user, bot) + // go func() { + // time.Sleep(time.Duration(5) * time.Second) + // bot.shopViewDeleteAllStatusMsgs(ctx, user) + // }() + bot.displayShopItem(ctx, shopView.Message, shop) + return ctx, nil +} + +// shopItemSettingsHandler is invoked when the user presses the item settings button +func (bot *TipBot) shopItemSettingsHandler(ctx intercept.Context) (intercept.Context, error) { + c := ctx.Callback() + log.Debugf("[shopItemSettingsHandler] %s", c.Data) + user := LoadUser(ctx) + shopView, err := bot.getUserShopview(ctx, user) + if err != nil { + return ctx, err + } + shop, err := bot.getShop(ctx, shopView.ShopID) + item := shop.Items[shop.ItemIds[shopView.Page]] + // sanity check + if item.ID != c.Data { + log.Error("[shopItemSettingsHandler] item id mismatch") + return ctx, errors.Create(errors.ItemIdMismatchError) + } + if item.TbPhoto != nil { + item.TbPhoto.Caption = bot.getItemTitle(ctx, &item) + } + _, err = bot.tryEditMessage(shopView.Message, item.TbPhoto, bot.shopItemSettingsMenu(ctx, shop, &item)) + return ctx, err +} + +// shopItemPriceHandler is invoked when the user presses the item settings button to set a item title +func (bot *TipBot) shopItemDeleteHandler(ctx intercept.Context) (intercept.Context, error) { + c := ctx.Callback() + log.Debugf("[shopItemDeleteHandler] %s", c.Data) + user := LoadUser(ctx) + shopView, err := bot.getUserShopview(ctx, user) + if err != nil { + return ctx, err + } + shop, err := bot.getShop(ctx, shopView.ShopID) + if err != nil { + return ctx, err + } + if shop.Owner.Telegram.ID != c.Sender.ID { + return ctx, errors.Create(errors.UnknownError) + } + item := shop.Items[shop.ItemIds[shopView.Page]] + if shop.Owner.Telegram.ID != c.Sender.ID { + return ctx, errors.Create(errors.UnknownError) + } + + // delete ItemID of item + for i, itemId := range shop.ItemIds { + if itemId == item.ID { + if len(shop.ItemIds) == 1 { + shop.ItemIds = []string{} + } else { + shop.ItemIds = append(shop.ItemIds[:i], shop.ItemIds[i+1:]...) + } + break + } + } + // delete item itself + delete(shop.Items, item.ID) + runtime.IgnoreError(shop.Set(shop, bot.ShopBunt)) + + ResetUserState(user, bot) + bot.sendStatusMessageAndDelete(ctx, c.Message.Chat, fmt.Sprintf("✅ Item deleted.")) + // go func() { + // time.Sleep(time.Duration(5) * time.Second) + // bot.shopViewDeleteAllStatusMsgs(ctx, user) + // }() + if shopView.Page > 0 { + shopView.Page-- + } + bot.Cache.Set(shopView.ID, shopView, &store.Options{Expiration: 24 * time.Hour}) + bot.displayShopItem(ctx, shopView.Message, shop) + return ctx, nil +} + +// displayShopItemHandler is invoked when the user presses the back button in the item settings +func (bot *TipBot) displayShopItemHandler(ctx intercept.Context) (intercept.Context, error) { + c := ctx.Callback() + log.Debugf("[displayShopItemHandler] c.Data: %s", c.Data) + user := LoadUser(ctx) + shopView, err := bot.getUserShopview(ctx, user) + if err != nil { + return ctx, err + } + shop, err := bot.getShop(ctx, shopView.ShopID) + if err != nil { + return ctx, err + } + // item := shop.Items[shop.ItemIds[shopView.Page]] + // // sanity check + // if item.ID != c.Data { + // log.Error("[shopItemSettingsHandler] item id mismatch") + // return + // } + bot.displayShopItem(ctx, c.Message, shop) + return ctx, nil +} + +// shopNextItemHandler is invoked when the user presses the next item button +func (bot *TipBot) shopNextItemButtonHandler(ctx intercept.Context) (intercept.Context, error) { + c := ctx.Callback() + log.Debugf("[shopNextItemButtonHandler] c.Data: %s", c.Data) + user := LoadUser(ctx) + // shopView, err := bot.Cache.Get(fmt.Sprintf("shopview-%d", user.Telegram.ID)) + shopView, err := bot.getUserShopview(ctx, user) + if err != nil { + return ctx, err + } + shop, err := bot.getShop(ctx, shopView.ShopID) + if shopView.Page < len(shop.Items)-1 { + shopView.Page++ + bot.Cache.Set(shopView.ID, shopView, &store.Options{Expiration: 24 * time.Hour}) + shop, err = bot.getShop(ctx, shopView.ShopID) + if err != nil { + return ctx, err + } + bot.displayShopItem(ctx, c.Message, shop) + } + return ctx, nil +} + +// shopPrevItemButtonHandler is invoked when the user presses the previous item button +func (bot *TipBot) shopPrevItemButtonHandler(ctx intercept.Context) (intercept.Context, error) { + c := ctx.Callback() + log.Debugf("[shopPrevItemButtonHandler] %s", c.Data) + user := LoadUser(ctx) + shopView, err := bot.getUserShopview(ctx, user) + if err != nil { + return ctx, err + } + if shopView.Page == 0 { + c.Message.Text = "/shops " + shopView.ShopOwner.Telegram.Username + return bot.shopsHandler(ctx) + + } + if shopView.Page > 0 { + shopView.Page-- + } + bot.Cache.Set(shopView.ID, shopView, &store.Options{Expiration: 24 * time.Hour}) + shop, err := bot.getShop(ctx, shopView.ShopID) + bot.displayShopItem(ctx, c.Message, shop) + return ctx, nil +} + +func (bot *TipBot) getItemTitle(ctx context.Context, item *ShopItem) string { + caption := "" + if len(item.Title) > 0 { + caption = fmt.Sprintf("%s", item.Title) + } + if len(item.FileIDs) > 0 { + if len(caption) > 0 { + caption += " " + } + caption += fmt.Sprintf("(%d Files)", len(item.FileIDs)) + } + if item.Price > 0 { + caption += fmt.Sprintf("\n\n💸 Price: %s", utils.FormatSats(item.Price)) + } + // item.TbPhoto.Caption = caption + return caption +} + +// displayShopItem renders the current item in the shopView +// requires that the shopview page is already set accordingly +// m is the message that will be edited +func (bot *TipBot) displayShopItem(ctx intercept.Context, m *tb.Message, shop *Shop) *tb.Message { + user := LoadUser(ctx) + log.Debugf("[displayShopItem] User: %s shop: %s", GetUserStr(user.Telegram), shop.ID) + shopView, err := bot.getUserShopview(ctx, user) + if err != nil { + log.Errorf("[displayShopItem] %s", err.Error()) + return nil + } + // failsafe: if the page is out of bounds, reset it + if len(shop.Items) > 0 && shopView.Page >= len(shop.Items) { + shopView.Page = len(shop.Items) - 1 + } else if len(shop.Items) == 0 { + shopView.Page = 0 + } + + log.Debugf("[displayShopItem] shop: %s page: %d items: %d", shop.ID, shopView.Page, len(shop.Items)) + if len(shop.Items) == 0 { + no_items_message := "There are no items in this shop yet." + if shopView.Message != nil && len(shopView.Message.Text) > 0 { + shopView.Message, _ = bot.tryEditMessage(shopView.Message, no_items_message, bot.shopMenu(ctx, shop, &ShopItem{})) + } else { + if shopView.Message != nil { + bot.tryDeleteMessage(shopView.Message) + } + shopView.Message = bot.trySendMessage(shopView.Message.Chat, no_items_message, bot.shopMenu(ctx, shop, &ShopItem{})) + } + shopView.Page = 0 + return shopView.Message + } + + item := shop.Items[shop.ItemIds[shopView.Page]] + if item.TbPhoto != nil { + item.TbPhoto.Caption = bot.getItemTitle(ctx, &item) + } + + // var msg *tb.Message + if shopView.Message != nil { + if item.TbPhoto != nil { + if shopView.Message.Photo != nil { + // can only edit photo messages with another photo + shopView.Message, _ = bot.tryEditMessage(shopView.Message, item.TbPhoto, bot.shopMenu(ctx, shop, &item)) + } else { + // if editing failes + bot.tryDeleteMessage(shopView.Message) + shopView.Message = bot.trySendMessage(shopView.Message.Chat, item.TbPhoto, bot.shopMenu(ctx, shop, &item)) + } + } else if item.Title != "" { + shopView.Message, _ = bot.tryEditMessage(shopView.Message, item.Title, bot.shopMenu(ctx, shop, &item)) + if shopView.Message == nil { + shopView.Message = bot.trySendMessage(shopView.Message.Chat, item.Title, bot.shopMenu(ctx, shop, &item)) + } + } + } else { + if m != nil && m.Chat != nil { + shopView.Message = bot.trySendMessage(m.Chat, item.TbPhoto, bot.shopMenu(ctx, shop, &item)) + } else { + shopView.Message = bot.trySendMessage(user.Telegram, item.TbPhoto, bot.shopMenu(ctx, shop, &item)) + } + // shopView.Page = 0 + } + // shopView.Message = msg + bot.Cache.Set(shopView.ID, shopView, &store.Options{Expiration: 24 * time.Hour}) + return shopView.Message +} + +// shopHandler is invoked when the user enters /shop +func (bot *TipBot) shopHandler(ctx intercept.Context) (intercept.Context, error) { + m := ctx.Message() + log.Debugf("[shopHandler] %s", m.Text) + if !m.Private() { + return ctx, errors.Create(errors.NoPrivateChatError) + } + user := LoadUser(ctx) + shopOwner := user + + // when no argument is given, i.e. command is only /shop, load /shops + shop := &Shop{} + if len(strings.Split(m.Text, " ")) < 2 || !strings.HasPrefix(strings.Split(m.Text, " ")[1], "shop-") { + return bot.shopsHandler(ctx) + } else { + // else: get shop by shop ID + shopID := strings.Split(m.Text, " ")[1] + var err error + shop, err = bot.getShop(ctx, shopID) + if err != nil { + log.Errorf("[shopHandler] %s", err.Error()) + return ctx, err + } + } + shopOwner = shop.Owner + shopView := ShopView{ + ID: fmt.Sprintf("shopview-%d", user.Telegram.ID), + ShopID: shop.ID, + Page: 0, + ShopOwner: shopOwner, + } + bot.Cache.Set(shopView.ID, shopView, &store.Options{Expiration: 24 * time.Hour}) + shopView.Message = bot.displayShopItem(ctx, m, shop) + // shopMessage := &tb.Message{Chat: m.Chat} + // if len(shop.ItemIds) > 0 { + // // item := shop.Items[shop.ItemIds[shopView.Page]] + // // shopMessage = bot.trySendMessage(m.Chat, item.TbPhoto, bot.shopMenu(ctx, shop, &item)) + // shopMessage = bot.displayShopItem(ctx, m, shop) + // } else { + // shopMessage = bot.trySendMessage(m.Chat, "No items in shop.", bot.shopMenu(ctx, shop, &ShopItem{})) + // } + // shopView.Message = shopMessage + bot.Cache.Set(shopView.ID, shopView, &store.Options{Expiration: 24 * time.Hour}) + return ctx, nil +} + +// shopNewItemHandler is invoked when the user presses the new item button +func (bot *TipBot) shopNewItemHandler(ctx intercept.Context) (intercept.Context, error) { + c := ctx.Callback() + log.Debugf("[shopNewItemHandler] %s", c.Data) + user := LoadUser(ctx) + shop, err := bot.getShop(ctx, c.Data) + if err != nil { + log.Errorf("[shopNewItemHandler] %s", err.Error()) + return ctx, err + } + if shop.Owner.Telegram.ID != c.Sender.ID { + return ctx, errors.Create(errors.UnknownError) + } + if len(shop.Items) >= shop.MaxItems { + bot.trySendMessage(c.Sender, fmt.Sprintf("🚫 You can only have %d items in this shop. Delete an item to add a new one.", shop.MaxItems)) + return ctx, errors.Create(errors.MaxReachedError) + } + + // We need to save the pay state in the user state so we can load the payment in the next handler + paramsJson, err := json.Marshal(shop) + if err != nil { + log.Errorf("[lnurlWithdrawHandler] Error: %s", err.Error()) + // bot.trySendMessage(m.Sender, err.Error()) + return ctx, err + } + SetUserState(user, bot, lnbits.UserStateShopItemSendPhoto, string(paramsJson)) + bot.sendStatusMessage(ctx, c.Sender, fmt.Sprintf("🌄 Send me a cover image.")) + return ctx, nil +} + +// addShopItem is a helper function for creating a shop item in the database +func (bot *TipBot) addShopItem(ctx intercept.Context, shopId string) (*Shop, ShopItem, error) { + log.Debugf("[addShopItem] shopId: %s", shopId) + shop, err := bot.getShop(ctx, shopId) + if err != nil { + log.Errorf("[addShopItem] %s", err.Error()) + return shop, ShopItem{}, err + } + user := LoadUser(ctx) + // onnly the correct user can press + if shop.Owner.Telegram.ID != user.Telegram.ID { + return shop, ShopItem{}, fmt.Errorf("not owner") + } + // err = shop.Lock(shop, bot.ShopBunt) + // defer shop.Release(shop, bot.ShopBunt) + + itemId := fmt.Sprintf("item-%s-%s", shop.ID, RandStringRunes(8)) + item := ShopItem{ + ID: itemId, + ShopID: shop.ID, + Owner: user, + Type: "photo", + LanguageCode: shop.LanguageCode, + MaxFiles: MAX_FILES_PER_ITEM, + } + shop.Items[itemId] = item + shop.ItemIds = append(shop.ItemIds, itemId) + runtime.IgnoreError(shop.Set(shop, bot.ShopBunt)) + return shop, shop.Items[itemId], nil +} + +// addShopItemPhoto is invoked when the users sends a photo as a new item +func (bot *TipBot) addShopItemPhoto(ctx intercept.Context) (intercept.Context, error) { + m := ctx.Message() + log.Debugf("[addShopItemPhoto] ") + user := LoadUser(ctx) + if user.Wallet == nil { + return ctx, errors.Create(errors.UserNoWalletError) + } + + // read item from user.StateData + var state_shop Shop + err := json.Unmarshal([]byte(user.StateData), &state_shop) + if err != nil { + log.Errorf("[lnurlWithdrawHandlerWithdraw] Error: %s", err.Error()) + bot.trySendMessage(m.Sender, Translate(ctx, "errorTryLaterMessage"), Translate(ctx, "errorTryLaterMessage")) + return ctx, err + } + if state_shop.Owner.Telegram.ID != m.Sender.ID { + return ctx, errors.Create(errors.NotShopOwnerError) + } + if m.Photo == nil { + bot.sendStatusMessageAndDelete(ctx, m.Sender, fmt.Sprintf("🚫 That didn't work. You need to send an image (not a file).")) + ResetUserState(user, bot) + return ctx, errors.Create(errors.NoPhotoError) + } + + shop, item, err := bot.addShopItem(ctx, state_shop.ID) + // err = shop.Lock(shop, bot.ShopBunt) + // defer shop.Release(shop, bot.ShopBunt) + item.TbPhoto = m.Photo + item.Title = m.Caption + shop.Items[item.ID] = item + runtime.IgnoreError(shop.Set(shop, bot.ShopBunt)) + + bot.tryDeleteMessage(m) + bot.sendStatusMessageAndDelete(ctx, m.Sender, fmt.Sprintf("✅ Image added. You can now add files to this item. Don't forget to set a title and a price.")) + ResetUserState(user, bot) + // go func() { + // time.Sleep(time.Duration(5) * time.Second) + // bot.shopViewDeleteAllStatusMsgs(ctx, user) + // }() + + shopView, err := bot.getUserShopview(ctx, user) + shopView.Page = len(shop.Items) - 1 + bot.Cache.Set(shopView.ID, shopView, &store.Options{Expiration: 24 * time.Hour}) + bot.displayShopItem(ctx, shopView.Message, shop) + + log.Infof("[🛍 shop] %s added an item %s:%s.", GetUserStr(user.Telegram), shop.ID, item.ID) + return ctx, nil +} + +// ------------------- item files ---------- +// shopItemAddItemHandler is invoked when the user presses the new item button +func (bot *TipBot) shopItemAddItemHandler(ctx intercept.Context) (intercept.Context, error) { + c := ctx.Callback() + log.Debugf("[shopItemAddItemHandler] %s", c.Data) + user := LoadUser(ctx) + if user.Wallet == nil { + return ctx, errors.Create(errors.UserNoWalletError) + } + shopView, err := bot.getUserShopview(ctx, user) + if err != nil { + log.Errorf("[shopItemAddItemHandler] %s", err.Error()) + return ctx, err + } + + shop, err := bot.getShop(ctx, shopView.ShopID) + if err != nil { + log.Errorf("[shopItemAddItemHandler] %s", err.Error()) + return ctx, err + } + + itemID := c.Data + + item := shop.Items[itemID] + + if len(item.FileIDs) >= item.MaxFiles { + bot.trySendMessage(c.Sender, fmt.Sprintf("🚫 You can only have %d files in this item.", item.MaxFiles)) + return ctx, errors.Create(errors.NoFileFoundError) + } + SetUserState(user, bot, lnbits.UserStateShopItemSendItemFile, c.Data) + bot.sendStatusMessage(ctx, c.Sender, fmt.Sprintf("💾 Send me one or more files.")) + return ctx, err +} + +// addItemFileHandler is invoked when the users sends a new file for the item +func (bot *TipBot) addItemFileHandler(ctx intercept.Context) (intercept.Context, error) { + m := ctx.Message() + log.Debugf("[addItemFileHandler] ") + user := LoadUser(ctx) + if user.Wallet == nil { + return ctx, errors.Create(errors.UserNoWalletError) + } + shopView, err := bot.getUserShopview(ctx, user) + if err != nil { + log.Errorf("[addItemFileHandler] %s", err.Error()) + return ctx, err + } + + shop, err := bot.getShop(ctx, shopView.ShopID) + if err != nil { + log.Errorf("[shopNewItemHandler] %s", err.Error()) + return ctx, err + } + + itemID := user.StateData + + item := shop.Items[itemID] + if m.Photo != nil { + item.FileIDs = append(item.FileIDs, m.Photo.FileID) + item.FileTypes = append(item.FileTypes, "photo") + bot.sendStatusMessageAndDelete(ctx, m.Sender, fmt.Sprintf("ℹ️ To send more than one photo at a time, send them as files.")) + } else if m.Document != nil { + item.FileIDs = append(item.FileIDs, m.Document.FileID) + item.FileTypes = append(item.FileTypes, "document") + } else if m.Audio != nil { + item.FileIDs = append(item.FileIDs, m.Audio.FileID) + item.FileTypes = append(item.FileTypes, "audio") + } else if m.Video != nil { + item.FileIDs = append(item.FileIDs, m.Video.FileID) + item.FileTypes = append(item.FileTypes, "video") + } else if m.Voice != nil { + item.FileIDs = append(item.FileIDs, m.Voice.FileID) + item.FileTypes = append(item.FileTypes, "voice") + } else if m.VideoNote != nil { + item.FileIDs = append(item.FileIDs, m.VideoNote.FileID) + item.FileTypes = append(item.FileTypes, "videonote") + } else if m.Sticker != nil { + item.FileIDs = append(item.FileIDs, m.Sticker.FileID) + item.FileTypes = append(item.FileTypes, "sticker") + } else { + log.Errorf("[addItemFileHandler] no file found") + return ctx, errors.Create(errors.NoFileFoundError) + } + shop.Items[item.ID] = item + + runtime.IgnoreError(shop.Set(shop, bot.ShopBunt)) + bot.tryDeleteMessage(m) + bot.sendStatusMessageAndDelete(ctx, m.Sender, fmt.Sprintf("✅ File added.")) + + // ticker := runtime.GetTicker(shop.ID, runtime.WithDuration(5*time.Second)) + // if !ticker.Started { + // ticker.Do(func() { + // bot.shopViewDeleteAllStatusMsgs(ctx, user) + // // removing ticker asap done + // runtime.RemoveTicker(shop.ID) + // }) + // } else { + // ticker.ResetChan <- struct{}{} + // } + + // // start a ticker to check if the user has sent more files + // if t, ok := fileStateResetTicker.Get(shop.ID); ok { + // // state reset ticker found. resetting ticker. + // t.(*runtime.ResettableFunction).ResetChan <- struct{}{} + // } else { + // // state reset ticker not found. creating new one. + // ticker := runtime.NewResettableFunctionTicker(runtime.WithDuration(time.Second * 5)) + // // storing reset ticker in mem + // fileStateResetTicker.Set(shop.ID, ticker) + // go func() { + // // starting ticker + // ticker.Do(func() { + // // time.Sleep(time.Duration(5) * time.Second) + // bot.shopViewDeleteAllStatusMsgs(ctx, user) + // // removing ticker asap done + // fileStateResetTicker.Remove(shop.ID) + // }) + // }() + // } + + // go func() { + // time.Sleep(time.Duration(5) * time.Second) + // bot.shopViewDeleteAllStatusMsgs(ctx, user) + // }() + bot.displayShopItem(ctx, shopView.Message, shop) + log.Infof("[🛍 shop] %s added a file to shop:item %s:%s.", GetUserStr(user.Telegram), shop.ID, item.ID) + return ctx, nil +} + +func (bot *TipBot) shopGetItemFilesHandler(ctx intercept.Context) (intercept.Context, error) { + c := ctx.Callback() + log.Debugf("[shopGetItemFilesHandler] %s", c.Data) + user := LoadUser(ctx) + if user.Wallet == nil { + return ctx, errors.Create(errors.UserNoWalletError) + } + shopView, err := bot.getUserShopview(ctx, user) + if err != nil { + log.Errorf("[addItemFileHandler] %s", err.Error()) + return ctx, err + } + shop, err := bot.getShop(ctx, shopView.ShopID) + if err != nil { + log.Errorf("[shopGetItemFilesHandler] %s", err.Error()) + return ctx, err + } + itemID := c.Data + item := shop.Items[itemID] + + if item.Price <= 0 { + bot.shopSendItemFilesToUser(ctx, user, itemID) + } else { + if item.TbPhoto != nil { + item.TbPhoto.Caption = bot.getItemTitle(ctx, &item) + } + bot.tryEditMessage(shopView.Message, item.TbPhoto, bot.shopItemConfirmBuyMenu(ctx, shop, &item)) + } + + // // send the cover image + // bot.sendFileByID(ctx, c.Sender, item.TbPhoto.FileID, "photo") + // // and all other files + // for i, fileID := range item.FileIDs { + // bot.sendFileByID(ctx, c.Sender, fileID, item.FileTypes[i]) + // } + // log.Infof("[🛍 shop] %s got %d items from %s's item %s (for %d sat).", GetUserStr(user.Telegram), len(item.FileIDs), GetUserStr(shop.Owner.Telegram), item.ID, item.Price) + return ctx, nil +} + +// shopConfirmBuyHandler is invoked when the user has confirmed to pay for an item +func (bot *TipBot) shopConfirmBuyHandler(ctx intercept.Context) (intercept.Context, error) { + c := ctx.Callback() + log.Debugf("[shopConfirmBuyHandler] %s", c.Data) + user := LoadUser(ctx) + if user.Wallet == nil { + return ctx, errors.Create(errors.UserNoWalletError) + } + shopView, err := bot.getUserShopview(ctx, user) + if err != nil { + log.Errorf("[shopConfirmBuyHandler] %s", err.Error()) + return ctx, err + } + shop, err := bot.getShop(ctx, shopView.ShopID) + if err != nil { + log.Errorf("[shopConfirmBuyHandler] %s", err.Error()) + return ctx, err + } + itemID := c.Data + item := shop.Items[itemID] + if item.Owner.ID != shop.Owner.ID { + log.Errorf("[shopConfirmBuyHandler] Owners do not match.") + return ctx, errors.Create(errors.NotShopOwnerError) + } + from := user + to := shop.Owner + + // fromUserStr := GetUserStr(from.Telegram) + // fromUserStrMd := GetUserStrMd(from.Telegram) + toUserStr := GetUserStr(to.Telegram) + toUserStrMd := GetUserStrMd(to.Telegram) + amount := item.Price + if amount <= 0 { + log.Errorf("[shopConfirmBuyHandler] item has no price.") + return ctx, errors.Create(errors.InvalidAmountError) + } + transactionMemo := fmt.Sprintf("🛍 Shop from %s.", toUserStr) + t := NewTransaction(bot, from, to, amount, TransactionType("shop")) + t.Memo = transactionMemo + + success, err := t.Send() + if !success || err != nil { + // bot.trySendMessage(c.Sender, sendErrorMessage) + errmsg := fmt.Sprintf("[shop] Error: Transaction failed. %s", err.Error()) + log.Errorln(errmsg) + if bot.ErrorLogger != nil { + bot.ErrorLogger.LogTransactionError(err, "shop purchase", amount, from.Telegram, to.Telegram) + } + ctx.Context = context.WithValue(ctx, "callback_response", i18n.Translate(user.Telegram.LanguageCode, "sendErrorMessage")) + // bot.trySendMessage(user.Telegram, i18n.Translate(user.Telegram.LanguageCode, "sendErrorMessage"), &tb.ReplyMarkup{}) + return ctx, errors.New(errors.UnknownError, err) + } + // bot.trySendMessage(user.Telegram, fmt.Sprintf("🛍 %d sat sent to %s.", amount, toUserStrMd), &tb.ReplyMarkup{}) + shopItemTitle := "an item" + if len(item.Title) > 0 { + shopItemTitle = fmt.Sprintf("%s", item.Title) + } + ctx.Context = context.WithValue(ctx, "callback_response", "🛍 Purchase successful.") + bot.trySendMessage(to.Telegram, fmt.Sprintf("🛍 Someone bought `%s` from your shop `%s` for `%s`.", str.MarkdownEscape(shopItemTitle), str.MarkdownEscape(shop.Title), utils.FormatSats(amount))) + bot.trySendMessage(from.Telegram, fmt.Sprintf("🛍 You bought `%s` from %s's shop `%s` for `%s`.", str.MarkdownEscape(shopItemTitle), toUserStrMd, str.MarkdownEscape(shop.Title), utils.FormatSats(amount))) + log.Infof("[🛍 shop] %s bought from %s shop: %s item: %s for %s.", toUserStr, GetUserStr(to.Telegram), shop.Title, shopItemTitle, utils.FormatSats(amount)) + bot.shopSendItemFilesToUser(ctx, user, itemID) + return ctx, nil +} + +// shopSendItemFilesToUser is a handler function to send itemID's files to the user +func (bot *TipBot) shopSendItemFilesToUser(ctx intercept.Context, toUser *lnbits.User, itemID string) { + log.Debugf("[shopSendItemFilesToUser] %s -> %s", GetUserStr(toUser.Telegram), itemID) + user := LoadUser(ctx) + if user.Wallet == nil { + return // errors.New("user has no wallet"), 0 + } + shopView, err := bot.getUserShopview(ctx, user) + if err != nil { + log.Errorf("[shopGetItemFilesHandler] %s", err.Error()) + return + } + shop, err := bot.getShop(ctx, shopView.ShopID) + if err != nil { + log.Errorf("[addItemFileHandler] %s", err.Error()) + return + } + item := shop.Items[itemID] + // send the cover image + bot.sendFileByID(ctx, toUser.Telegram, item.TbPhoto.FileID, "photo") + // and all other files + for i, fileID := range item.FileIDs { + bot.sendFileByID(ctx, toUser.Telegram, fileID, item.FileTypes[i]) + } + log.Infof("[🛍 shop] %s got %d items from %s's item %s (for %d sat).", GetUserStr(user.Telegram), len(item.FileIDs), GetUserStr(shop.Owner.Telegram), item.ID, item.Price) + + // delete old shop and show again below the files + if shopView.Message != nil { + bot.tryDeleteMessage(shopView.Message) + } + shopView.Message = nil + bot.Cache.Set(shopView.ID, shopView, &store.Options{Expiration: 24 * time.Hour}) + bot.displayShopItem(ctx, &tb.Message{}, shop) +} + +func (bot *TipBot) sendFileByID(ctx context.Context, to tb.Recipient, fileId string, fileType string) { + switch fileType { + case "photo": + sendable := &tb.Photo{File: tb.File{FileID: fileId}} + bot.trySendMessage(to, sendable) + case "document": + sendable := &tb.Document{File: tb.File{FileID: fileId}} + bot.trySendMessage(to, sendable) + case "audio": + sendable := &tb.Audio{File: tb.File{FileID: fileId}} + bot.trySendMessage(to, sendable) + case "video": + sendable := &tb.Video{File: tb.File{FileID: fileId}} + bot.trySendMessage(to, sendable) + case "voice": + sendable := &tb.Voice{File: tb.File{FileID: fileId}} + bot.trySendMessage(to, sendable) + case "videonote": + sendable := &tb.VideoNote{File: tb.File{FileID: fileId}} + bot.trySendMessage(to, sendable) + case "sticker": + sendable := &tb.Sticker{File: tb.File{FileID: fileId}} + bot.trySendMessage(to, sendable) + } + return +} + +// -------------- shops handler -------------- +// var ShopsText = "*Welcome to %s shop.*\n%s\nThere are %d shops here.\n%s" +var ShopsText = "" +var ShopsTextWelcome = "*You are browsing %s shop.*" +var ShopsTextShopCount = "*Browse %d shops:*" +var ShopsNoShopsText = "*There are no shops here yet.*" + +// shopsHandlerCallback is a warpper for shopsHandler for callbacks +func (bot *TipBot) shopsHandlerCallback(ctx intercept.Context) (intercept.Context, error) { + return bot.shopsHandler(ctx) +} + +// shopsHandler is invoked when the user enters /shops +func (bot *TipBot) shopsHandler(ctx intercept.Context) (intercept.Context, error) { + m := ctx.Message() + log.Debugf("[shopsHandler] %s", GetUserStr(m.Sender)) + if !m.Private() { + return ctx, errors.Create(errors.NoPrivateChatError) + } + user := LoadUser(ctx) + shopOwner := user + + // if the user in the command, i.e. /shops @user + if len(strings.Split(m.Text, " ")) > 1 && strings.HasPrefix(strings.Split(m.Text, " ")[0], "/shop") { + toUserStrMention := "" + toUserStrWithoutAt := "" + + // check for user in command, accepts user mention or plain username without @ + if len(m.Entities) > 1 && m.Entities[1].Type == "mention" { + toUserStrMention = m.Text[m.Entities[1].Offset : m.Entities[1].Offset+m.Entities[1].Length] + toUserStrWithoutAt = strings.TrimPrefix(toUserStrMention, "@") + } else { + var err error + toUserStrWithoutAt, err = getArgumentFromCommand(m.Text, 1) + if err != nil { + log.Errorln(err.Error()) + return ctx, err + } + toUserStrWithoutAt = strings.TrimPrefix(toUserStrWithoutAt, "@") + toUserStrMention = "@" + toUserStrWithoutAt + } + + toUserDb, err := GetUserByTelegramUsername(toUserStrWithoutAt, *bot) + if err != nil { + NewMessage(m, WithDuration(0, bot)) + // cut username if it's too long + if len(toUserStrMention) > 100 { + toUserStrMention = toUserStrMention[:100] + } + bot.trySendMessage(m.Sender, fmt.Sprintf(Translate(ctx, "sendUserHasNoWalletMessage"), str.MarkdownEscape(toUserStrMention))) + return ctx, err + } + // overwrite user with the one from db + shopOwner = toUserDb + } else if !strings.HasPrefix(strings.Split(m.Text, " ")[0], "/shop") { + // otherwise, the user is returning to a shops view from a back button callback + shopView, err := bot.getUserShopview(ctx, user) + if err == nil { + shopOwner = shopView.ShopOwner + } + } + + if shopOwner == nil { + log.Error("[shopsHandler] shopOwner is nil") + return ctx, errors.Create(errors.ShopNoOwnerError) + } + shops, err := bot.getUserShops(ctx, shopOwner) + if err != nil && user.Telegram.ID == shopOwner.Telegram.ID { + shops, err = bot.initUserShops(ctx, user) + if err != nil { + log.Errorf("[shopsHandler] %s", err.Error()) + return ctx, err + } + } + + if len(shops.Shops) == 0 && user.Telegram.ID != shopOwner.Telegram.ID { + bot.trySendMessage(m.Chat, fmt.Sprintf("This user has no shops yet.")) + return ctx, errors.Create(errors.NoShopError) + } + + // build shop list + shopTitles := "" + for _, shopId := range shops.Shops { + shop, err := bot.getShop(ctx, shopId) + if err != nil { + log.Errorf("[shopsHandler] %s", err.Error()) + return ctx, err + } + shopTitles += fmt.Sprintf("\n· %s (%d items)", str.MarkdownEscape(shop.Title), len(shop.Items)) + + } + + // build shop text + + // shows "your shop" or "@other's shop" + shopOwnerText := "your" + if shopOwner.Telegram.ID != user.Telegram.ID { + shopOwnerText = fmt.Sprintf("%s's", GetUserStr(shopOwner.Telegram)) + } + ShopsText = fmt.Sprintf(ShopsTextWelcome, shopOwnerText) + if len(shops.Description) > 0 { + ShopsText += fmt.Sprintf("\n\n%s\n", shops.Description) + } else { + ShopsText += "\n" + } + if len(shops.Shops) > 0 { + ShopsText += fmt.Sprintf("\n%s\n", fmt.Sprintf(ShopsTextShopCount, len(shops.Shops))) + } else { + ShopsText += fmt.Sprintf("\n%s\n", ShopsNoShopsText) + } + + if len(shops.Shops) > 0 { + ShopsText += fmt.Sprintf("%s\n", shopTitles) + } + + // fmt.Sprintf(ShopsText, shopOwnerText, len(shops.Shops), shopTitles) + + // if the user used the command /shops, we will send a new message + // if the user clicked a button and has a shopview set, we will edit an old message + shopView, err := bot.getUserShopview(ctx, user) + var shopsMsg *tb.Message + if err == nil && !strings.HasPrefix(strings.Split(m.Text, " ")[0], "/shop") { + // the user is returning to a shops view from a back button callback + if shopView.Message != nil && shopView.Message.Photo == nil { + shopsMsg, _ = bot.tryEditMessage(shopView.Message, ShopsText, bot.shopsMainMenu(ctx, shops)) + } + if shopsMsg == nil { + // if editing has failed, we will send a new message + if shopView.Message != nil { + bot.tryDeleteMessage(shopView.Message) + } + shopsMsg = bot.trySendMessage(m.Chat, ShopsText, bot.shopsMainMenu(ctx, shops)) + + } + } else { + // the user has entered /shops or + // the user has no shopview set, so we will send a new message + if shopView.Message != nil { + // delete any old shop message + bot.tryDeleteMessage(shopView.Message) + } + shopsMsg = bot.trySendMessage(m.Chat, ShopsText, bot.shopsMainMenu(ctx, shops)) + } + shopViewNew := ShopView{ + ID: fmt.Sprintf("shopview-%d", user.Telegram.ID), + Message: shopsMsg, + ShopOwner: shopOwner, + StatusMessages: shopView.StatusMessages, // keep the old status messages + } + bot.Cache.Set(shopViewNew.ID, shopViewNew, &store.Options{Expiration: 24 * time.Hour}) + return ctx, nil +} + +// shopsDeleteShopBrowser is invoked when the user clicks on "delete shops" and makes a list of all shops +func (bot *TipBot) shopsDeleteShopBrowser(ctx intercept.Context) (intercept.Context, error) { + c := ctx.Callback() + log.Debugf("[shopsDeleteShopBrowser] %s", c.Data) + user := LoadUser(ctx) + shops, err := bot.getUserShops(ctx, user) + if err != nil { + return ctx, err + } + var s []*Shop + for _, shopId := range shops.Shops { + shop, _ := bot.getShop(ctx, shopId) + if shop.Owner.Telegram.ID != c.Sender.ID { + return ctx, errors.Create(errors.UnknownError) + } + s = append(s, shop) + } + shopShopsButton := shopKeyboard.Data("⬅️ Back", "shops_shops", shops.ID) + + shopResetShopAskButton = shopKeyboard.Data("⚠️ Delete all shops", "shops_reset_ask", shops.ID) + shopKeyboard.Inline(buttonWrapper(append(bot.makseShopSelectionButtons(s, "delete_shop"), shopResetShopAskButton, shopShopsButton), shopKeyboard, 1)...) + _, err = bot.tryEditMessage(c.Message, "Which shop do you want to delete?", shopKeyboard) + return ctx, err +} + +func (bot *TipBot) shopsAskDeleteAllShopsHandler(ctx intercept.Context) (intercept.Context, error) { + c := ctx.Callback() + log.Debugf("[shopsAskDeleteAllShopsHandler] %s", c.Data) + shopResetShopButton := shopKeyboard.Data("⚠️ Delete all shops", "shops_reset", c.Data) + buttons := []tb.Row{ + shopKeyboard.Row(shopResetShopButton), + shopKeyboard.Row(shopShopsButton), + } + shopKeyboard.Inline( + buttons..., + ) + bot.tryEditMessage(c.Message, "Are you sure you want to delete all shops?\nYou will lose all items as well.", shopKeyboard) + return ctx, nil +} + +// shopsLinkShopBrowser is invoked when the user clicks on "shop links" and makes a list of all shops +func (bot *TipBot) shopsLinkShopBrowser(ctx intercept.Context) (intercept.Context, error) { + c := ctx.Callback() + log.Debugf("[shopsLinkShopBrowser] %s", c.Data) + user := LoadUser(ctx) + shops, err := bot.getUserShops(ctx, user) + if err != nil { + return ctx, err + } + var s []*Shop + for _, shopId := range shops.Shops { + shop, _ := bot.getShop(ctx, shopId) + if shop.Owner.Telegram.ID != c.Sender.ID { + return ctx, errors.Create(errors.UnknownError) + } + s = append(s, shop) + } + shopShopsButton := shopKeyboard.Data("⬅️ Back", "shops_shops", shops.ID) + shopKeyboard.Inline(buttonWrapper(append(bot.makseShopSelectionButtons(s, "link_shop"), shopShopsButton), shopKeyboard, 1)...) + _, err = bot.tryEditMessage(c.Message, "Select the shop you want to get the link of.", shopKeyboard) + return ctx, err +} + +// shopSelectLink is invoked when the user has chosen a shop to get the link of +func (bot *TipBot) shopSelectLink(ctx intercept.Context) (intercept.Context, error) { + c := ctx.Callback() + log.Debugf("[shopSelectLink] %s", c.Data) + shop, _ := bot.getShop(ctx, c.Data) + if shop.Owner.Telegram.ID != c.Sender.ID { + return ctx, errors.Create(errors.UnknownError) + } + bot.trySendMessage(c.Sender, fmt.Sprintf("*%s*: `/shop %s`", shop.Title, shop.ID)) + return ctx, nil +} + +// shopsLinkShopBrowser is invoked when the user clicks on "shop links" and makes a list of all shops +func (bot *TipBot) shopsRenameShopBrowser(ctx intercept.Context) (intercept.Context, error) { + c := ctx.Callback() + log.Debugf("[shopsRenameShopBrowser] %s", c.Data) + user := LoadUser(ctx) + shops, err := bot.getUserShops(ctx, user) + if err != nil { + return ctx, err + } + var s []*Shop + for _, shopId := range shops.Shops { + shop, _ := bot.getShop(ctx, shopId) + if shop.Owner.Telegram.ID != c.Sender.ID { + return ctx, errors.Create(errors.UnknownError) + } + s = append(s, shop) + } + shopShopsButton := shopKeyboard.Data("⬅️ Back", "shops_shops", shops.ID) + shopKeyboard.Inline(buttonWrapper(append(bot.makseShopSelectionButtons(s, "rename_shop"), shopShopsButton), shopKeyboard, 1)...) + _, err = bot.tryEditMessage(c.Message, "Select the shop you want to rename.", shopKeyboard) + return ctx, err +} + +// shopSelectLink is invoked when the user has chosen a shop to get the link of +func (bot *TipBot) shopSelectRename(ctx intercept.Context) (intercept.Context, error) { + c := ctx.Callback() + log.Debugf("[shopSelectRename] %s", c.Data) + user := LoadUser(ctx) + shop, _ := bot.getShop(ctx, c.Data) + if shop.Owner.Telegram.ID != c.Sender.ID { + return ctx, errors.Create(errors.UnknownError) + } + // We need to save the pay state in the user state so we can load the payment in the next handler + SetUserState(user, bot, lnbits.UserEnterShopTitle, shop.ID) + bot.sendStatusMessage(ctx, c.Sender, fmt.Sprintf("⌨️ Enter the name of your shop."), tb.ForceReply) + return ctx, nil +} + +// shopsDescriptionHandler is invoked when the user clicks on "description" to set a shop description +func (bot *TipBot) shopsDescriptionHandler(ctx intercept.Context) (intercept.Context, error) { + c := ctx.Callback() + log.Debugf("[shopsDescriptionHandler] %s", c.Data) + user := LoadUser(ctx) + shops, err := bot.getUserShops(ctx, user) + if err != nil { + log.Errorf("[shopsDescriptionHandler] %s", err.Error()) + return ctx, err + } + SetUserState(user, bot, lnbits.UserEnterShopsDescription, shops.ID) + bot.sendStatusMessage(ctx, c.Sender, fmt.Sprintf("⌨️ Enter a description."), tb.ForceReply) + return ctx, nil +} + +// enterShopsDescriptionHandler is invoked when the user enters the shop title +func (bot *TipBot) enterShopsDescriptionHandler(ctx intercept.Context) (intercept.Context, error) { + m := ctx.Message() + log.Debugf("[enterShopsDescriptionHandler] %s", m.Text) + user := LoadUser(ctx) + shops, err := bot.getUserShops(ctx, user) + if err != nil { + log.Errorf("[enterShopsDescriptionHandler] %s", err.Error()) + return ctx, err + } + if shops.Owner.Telegram.ID != m.Sender.ID { + return ctx, errors.Create(errors.NotShopOwnerError) + } + if len(m.Text) == 0 { + ResetUserState(user, bot) + bot.sendStatusMessageAndDelete(ctx, m.Sender, "🚫 Action cancelled.") + go func() { + time.Sleep(time.Duration(5) * time.Second) + bot.shopViewDeleteAllStatusMsgs(ctx, user) + }() + return ctx, errors.Create(errors.InvalidSyntaxError) + } + + // crop shop title + if len(m.Text) > SHOPS_DESCRIPTION_MAX_LENGTH { + m.Text = m.Text[:SHOPS_DESCRIPTION_MAX_LENGTH] + } + shops.Description = m.Text + runtime.IgnoreError(shops.Set(shops, bot.ShopBunt)) + bot.sendStatusMessageAndDelete(ctx, m.Sender, fmt.Sprintf("✅ Description set.")) + ResetUserState(user, bot) + // go func() { + // time.Sleep(time.Duration(5) * time.Second) + // bot.shopViewDeleteAllStatusMsgs(ctx, user) + // }() + bot.shopsHandler(ctx) + bot.tryDeleteMessage(m) + return ctx, nil +} + +// shopsResetHandler is invoked when the user clicks button to reset shops completely +func (bot *TipBot) shopsResetHandler(ctx intercept.Context) (intercept.Context, error) { + c := ctx.Callback() + log.Debugf("[shopsResetHandler] %s", c.Data) + user := LoadUser(ctx) + shops, err := bot.getUserShops(ctx, user) + if err != nil { + log.Errorf("[shopsResetHandler] %s", err.Error()) + return ctx, err + } + if shops.Owner.Telegram.ID != c.Sender.ID { + return ctx, errors.Create(errors.UnknownError) + } + runtime.IgnoreError(shops.Delete(shops, bot.ShopBunt)) + bot.sendStatusMessageAndDelete(ctx, c.Sender, fmt.Sprintf("✅ Shops reset.")) + // go func() { + // time.Sleep(time.Duration(5) * time.Second) + // bot.shopViewDeleteAllStatusMsgs(ctx, user) + // }() + return bot.shopsHandlerCallback(ctx) +} + +// shopSelect is invoked when the user has selected a shop to browse +func (bot *TipBot) shopSelect(ctx intercept.Context) (intercept.Context, error) { + c := ctx.Callback() + log.Debugf("[shopSelect] %s", c.Data) + shop, _ := bot.getShop(ctx, c.Data) + user := LoadUser(ctx) + shopView, err := bot.getUserShopview(ctx, user) + if err != nil { + shopView = ShopView{ + ID: fmt.Sprintf("shopview-%d", c.Sender.ID), + ShopID: shop.ID, + Page: 0, + } + return ctx, bot.Cache.Set(shopView.ID, shopView, &store.Options{Expiration: 24 * time.Hour}) + } + shopView.Page = 0 + shopView.ShopID = shop.ID + + // var shopMessage *tb.Message + shopMessage := bot.displayShopItem(ctx, c.Message, shop) + // if len(shop.ItemIds) > 0 { + // bot.tryDeleteMessage(c.Message) + // shopMessage = bot.displayShopItem(ctx, c.Message, shop) + // } else { + // shopMessage = bot.tryEditMessage(c.Message, "There are no items in this shop yet.", bot.shopMenu(ctx, shop, &ShopItem{})) + // } + shopView.Message = shopMessage + log.Infof("[🛍 shop] %s erntering shop %s.", GetUserStr(user.Telegram), shop.ID) + ctx.Context = context.WithValue(ctx, "callback_response", fmt.Sprintf("🛍 You are browsing %s", shop.Title)) + return ctx, bot.Cache.Set(shopView.ID, shopView, &store.Options{Expiration: 24 * time.Hour}) +} + +// shopSelectDelete is invoked when the user has chosen a shop to delete +func (bot *TipBot) shopSelectDelete(ctx intercept.Context) (intercept.Context, error) { + c := ctx.Callback() + log.Debugf("[shopSelectDelete] %s", c.Data) + shop, _ := bot.getShop(ctx, c.Data) + user := LoadUser(ctx) + shops, err := bot.getUserShops(ctx, user) + if err != nil { + return ctx, err + } + // first, delete from Shops + for i, shopId := range shops.Shops { + if shopId == shop.ID { + if i == len(shops.Shops)-1 { + shops.Shops = shops.Shops[:i] + } else { + shops.Shops = append(shops.Shops[:i], shops.Shops[i+1:]...) + } + break + } + } + runtime.IgnoreError(shops.Set(shops, bot.ShopBunt)) + + // then, delete shop + runtime.IgnoreError(shop.Delete(shop, bot.ShopBunt)) + + log.Infof("[🛍 shop] %s deleted shop %s.", GetUserStr(user.Telegram), shop.ID) + // then update buttons + return bot.shopsDeleteShopBrowser(ctx) +} + +// shopsBrowser makes a button list of all shops the user can browse +func (bot *TipBot) shopsBrowser(ctx intercept.Context) (intercept.Context, error) { + c := ctx.Callback() + log.Debugf("[shopsBrowser] %s", c.Data) + user := LoadUser(ctx) + shopView, err := bot.getUserShopview(ctx, user) + if err != nil { + return ctx, err + } + shops, err := bot.getUserShops(ctx, shopView.ShopOwner) + if err != nil { + return ctx, err + } + var s []*Shop + for _, shopId := range shops.Shops { + shop, _ := bot.getShop(ctx, shopId) + s = append(s, shop) + } + shopShopsButton := shopKeyboard.Data("⬅️ Back", "shops_shops", shops.ID) + shopKeyboard.Inline(buttonWrapper(append(bot.makseShopSelectionButtons(s, "select_shop"), shopShopsButton), shopKeyboard, 1)...) + shopMessage, _ := bot.tryEditMessage(c.Message, "Select a shop you want to browse.", shopKeyboard) + shopView, err = bot.getUserShopview(ctx, user) + if err != nil { + shopView.Message = shopMessage + // todo -- check if this is possible (me like) + return ctx, fmt.Errorf("%v:%v", err, bot.Cache.Set(shopView.ID, shopView, &store.Options{Expiration: 24 * time.Hour})) + } + return ctx, nil +} + +// shopItemSettingsHandler is invoked when the user presses the shop settings button +func (bot *TipBot) shopSettingsHandler(ctx intercept.Context) (intercept.Context, error) { + c := ctx.Callback() + log.Debugf("[shopSettingsHandler] %s", c.Data) + user := LoadUser(ctx) + shopView, err := bot.getUserShopview(ctx, user) + if err != nil { + return ctx, err + } + shops, err := bot.getUserShops(ctx, user) + if err != nil { + return ctx, err + } + if shops.ID != c.Data || shops.Owner.Telegram.ID != user.Telegram.ID { + log.Error("[shopSettingsHandler] item id mismatch") + return ctx, errors.Create(errors.ItemIdMismatchError) + } + _, err = bot.tryEditMessage(shopView.Message, shopView.Message.Text, bot.shopsSettingsMenu(ctx, shops)) + return ctx, err +} + +// shopNewShopHandler is invoked when the user presses the new shop button +func (bot *TipBot) shopNewShopHandler(ctx intercept.Context) (intercept.Context, error) { + c := ctx.Callback() + log.Debugf("[shopNewShopHandler] %s", c.Data) + user := LoadUser(ctx) + shops, err := bot.getUserShops(ctx, user) + if err != nil { + log.Errorf("[shopNewShopHandler] %s", err.Error()) + return ctx, err + } + if len(shops.Shops) >= shops.MaxShops { + bot.trySendMessage(c.Sender, fmt.Sprintf("🚫 You can only have %d shops. Delete a shop to create a new one.", shops.MaxShops)) + return ctx, errors.Create(errors.MaxReachedError) + } + shop, err := bot.addUserShop(ctx, user) + // We need to save the pay state in the user state so we can load the payment in the next handler + SetUserState(user, bot, lnbits.UserEnterShopTitle, shop.ID) + bot.sendStatusMessage(ctx, c.Sender, fmt.Sprintf("⌨️ Enter the name of your shop."), tb.ForceReply) + return ctx, nil +} + +// enterShopTitleHandler is invoked when the user enters the shop title +func (bot *TipBot) enterShopTitleHandler(ctx intercept.Context) (intercept.Context, error) { + m := ctx.Message() + log.Debugf("[enterShopTitleHandler] %s", m.Text) + user := LoadUser(ctx) + // read item from user.StateData + shop, err := bot.getShop(ctx, user.StateData) + if err != nil { + return ctx, errors.Create(errors.NoShopError) + } + if shop.Owner.Telegram.ID != m.Sender.ID { + return ctx, errors.Create(errors.ShopNoOwnerError) + } + if len(m.Text) == 0 { + ResetUserState(user, bot) + bot.sendStatusMessageAndDelete(ctx, m.Sender, "🚫 Action cancelled.") + go func() { + time.Sleep(time.Duration(5) * time.Second) + bot.shopViewDeleteAllStatusMsgs(ctx, user) + }() + return ctx, errors.Create(errors.InvalidSyntaxError) + } + // crop shop title + m.Text = strings.Replace(m.Text, "\n", " ", -1) + if len(m.Text) > SHOP_TITLE_MAX_LENGTH { + m.Text = m.Text[:SHOP_TITLE_MAX_LENGTH] + } + shop.Title = m.Text + runtime.IgnoreError(shop.Set(shop, bot.ShopBunt)) + bot.sendStatusMessageAndDelete(ctx, m.Sender, fmt.Sprintf("✅ Shop added.")) + ResetUserState(user, bot) + // go func() { + // time.Sleep(time.Duration(5) * time.Second) + // bot.shopViewDeleteAllStatusMsgs(ctx, user) + // }() + ctx, err = bot.shopsHandler(ctx) + if err != nil { + log.Errorf("[shop] failed shopshandler") + } + bot.tryDeleteMessage(m) + log.Infof("[🛍 shop] %s added new shop %s.", GetUserStr(user.Telegram), shop.ID) + return ctx, nil +} diff --git a/internal/telegram/shop_helpers.go b/internal/telegram/shop_helpers.go new file mode 100644 index 00000000..18fccf3e --- /dev/null +++ b/internal/telegram/shop_helpers.go @@ -0,0 +1,285 @@ +package telegram + +import ( + "fmt" + "time" + + "github.com/LightningTipBot/LightningTipBot/internal/lnbits" + "github.com/LightningTipBot/LightningTipBot/internal/runtime" + "github.com/LightningTipBot/LightningTipBot/internal/runtime/mutex" + "github.com/LightningTipBot/LightningTipBot/internal/storage" + "github.com/LightningTipBot/LightningTipBot/internal/telegram/intercept" + "github.com/LightningTipBot/LightningTipBot/internal/utils" + "github.com/eko/gocache/store" + log "github.com/sirupsen/logrus" + tb "gopkg.in/lightningtipbot/telebot.v3" +) + +func (bot TipBot) shopsMainMenu(ctx intercept.Context, shops *Shops) *tb.ReplyMarkup { + browseShopButton := shopKeyboard.Data("🛍 Browse shops", "shops_browse", shops.ID) + shopNewShopButton := shopKeyboard.Data("✅ New Shop", "shops_newshop", shops.ID) + shopSettingsButton := shopKeyboard.Data("⚙️ Settings", "shops_settings", shops.ID) + user := LoadUser(ctx) + + buttons := []tb.Row{} + if len(shops.Shops) > 0 { + buttons = append(buttons, shopKeyboard.Row(browseShopButton)) + } + if user.Telegram.ID == shops.Owner.Telegram.ID { + buttons = append(buttons, shopKeyboard.Row(shopNewShopButton, shopSettingsButton)) + } + shopKeyboard.Inline( + buttons..., + ) + return shopKeyboard +} + +func (bot TipBot) shopsSettingsMenu(ctx intercept.Context, shops *Shops) *tb.ReplyMarkup { + shopShopsButton := shopKeyboard.Data("⬅️ Back", "shops_shops", shops.ID) + shopLinkShopButton := shopKeyboard.Data("🔗 Shop links", "shops_linkshop", shops.ID) + shopRenameShopButton := shopKeyboard.Data("⌨️ Rename a shop", "shops_renameshop", shops.ID) + shopDeleteShopButton := shopKeyboard.Data("🚫 Delete shops", "shops_deleteshop", shops.ID) + shopDescriptionShopButton := shopKeyboard.Data("💬 Description", "shops_description", shops.ID) + // // shopResetShopButton := shopKeyboard.Data("⚠️ Delete all shops", "shops_reset", shops.ID) + // buttons := []tb.Row{ + // shopKeyboard.Row(shopLinkShopButton), + // shopKeyboard.Row(shopDescriptionShopButton), + // shopKeyboard.Row(shopRenameShopButton), + // shopKeyboard.Row(shopDeleteShopButton), + // // shopKeyboard.Row(shopResetShopButton), + // shopKeyboard.Row(shopShopsButton), + // } + // shopKeyboard.Inline( + // buttons..., + // ) + + button := []tb.Btn{ + shopLinkShopButton, + shopDescriptionShopButton, + shopRenameShopButton, + shopDeleteShopButton, + shopShopsButton, + } + shopKeyboard.Inline(buttonWrapper(button, shopKeyboard, 2)...) + return shopKeyboard +} + +// shopItemSettingsMenu builds the buttons of the item settings +func (bot TipBot) shopItemSettingsMenu(ctx intercept.Context, shop *Shop, item *ShopItem) *tb.ReplyMarkup { + shopItemPriceButton = shopKeyboard.Data("💯 Set price", "shop_itemprice", item.ID) + shopItemDeleteButton = shopKeyboard.Data("🚫 Delete item", "shop_itemdelete", item.ID) + shopItemTitleButton = shopKeyboard.Data("⌨️ Set title", "shop_itemtitle", item.ID) + shopItemAddFileButton = shopKeyboard.Data("💾 Add files ...", "shop_itemaddfile", item.ID) + shopItemSettingsBackButton = shopKeyboard.Data("⬅️ Back", "shop_itemsettingsback", item.ID) + user := LoadUser(ctx) + buttons := []tb.Row{} + if user.Telegram.ID == shop.Owner.Telegram.ID { + buttons = append(buttons, shopKeyboard.Row(shopItemDeleteButton, shopItemSettingsBackButton)) + buttons = append(buttons, shopKeyboard.Row(shopItemTitleButton, shopItemPriceButton)) + buttons = append(buttons, shopKeyboard.Row(shopItemAddFileButton)) + } + shopKeyboard.Inline( + buttons..., + ) + return shopKeyboard +} + +// shopItemConfirmBuyMenu builds the buttons to confirm a purchase +func (bot TipBot) shopItemConfirmBuyMenu(ctx intercept.Context, shop *Shop, item *ShopItem) *tb.ReplyMarkup { + shopItemBuyButton = shopKeyboard.Data(fmt.Sprintf("💸 Pay %s", utils.FormatSats(item.Price)), "shop_itembuy", item.ID) + shopItemCancelBuyButton = shopKeyboard.Data("⬅️ Back", "shop_itemcancelbuy", item.ID) + buttons := []tb.Row{} + buttons = append(buttons, shopKeyboard.Row(shopItemBuyButton)) + buttons = append(buttons, shopKeyboard.Row(shopItemCancelBuyButton)) + shopKeyboard.Inline( + buttons..., + ) + return shopKeyboard +} + +// shopMenu builds the buttons in the item browser +func (bot TipBot) shopMenu(ctx intercept.Context, shop *Shop, item *ShopItem) *tb.ReplyMarkup { + user := LoadUser(ctx) + shopView, err := bot.getUserShopview(ctx, user) + if err != nil { + return nil + } + + shopShopsButton := shopKeyboard.Data("⬅️ Back", "shops_shops", shop.ShopsID) + shopAddItemButton = shopKeyboard.Data("✅ New item", "shop_additem", shop.ID) + shopItemSettingsButton = shopKeyboard.Data("⚙️ Settings", "shop_itemsettings", item.ID) + shopNextitemButton = shopKeyboard.Data(">", "shop_nextitem", shop.ID) + shopPrevitemButton = shopKeyboard.Data("<", "shop_previtem", shop.ID) + buyButtonText := "📩 Get" + if item.Price > 0 { + buyButtonText = fmt.Sprintf("Buy (%s)", utils.FormatSats(item.Price)) + } + shopBuyitemButton = shopKeyboard.Data(buyButtonText, "shop_buyitem", item.ID) + + buttons := []tb.Row{} + if user.Telegram.ID == shop.Owner.Telegram.ID { + if len(shop.Items) == 0 { + buttons = append(buttons, shopKeyboard.Row(shopAddItemButton)) + } else { + buttons = append(buttons, shopKeyboard.Row(shopAddItemButton, shopItemSettingsButton)) + } + } + // publicButtons := []tb.Row{} + if len(shop.Items) > 0 { + if shopView.Page == len(shop.Items)-1 { + // last page + shopNextitemButton = shopKeyboard.Data("x", "shop_nextitem", shop.ID) + } + buttons = append(buttons, shopKeyboard.Row(shopPrevitemButton, shopBuyitemButton, shopNextitemButton)) + } + buttons = append(buttons, shopKeyboard.Row(shopShopsButton)) + shopKeyboard.Inline( + buttons..., + ) + return shopKeyboard +} + +// makseShopSelectionButtons produces a list of all buttons with a uniqueString ID +func (bot *TipBot) makseShopSelectionButtons(shops []*Shop, uniqueString string) []tb.Btn { + var buttons []tb.Btn + for _, shop := range shops { + buttons = append(buttons, shopKeyboard.Data(shop.Title, uniqueString, shop.ID)) + } + return buttons +} + +// -------------- ShopView -------------- + +// getUserShopview returns ShopView object from cache that holds information about the user's current browsing view +func (bot *TipBot) getUserShopview(ctx intercept.Context, user *lnbits.User) (shopView ShopView, err error) { + sv, err := bot.Cache.Get(fmt.Sprintf("shopview-%d", user.Telegram.ID)) + if err != nil { + return + } + shopView = sv.(ShopView) + return +} +func (bot *TipBot) shopViewDeleteAllStatusMsgs(ctx intercept.Context, user *lnbits.User) (shopView ShopView, err error) { + mutex.Lock(fmt.Sprintf("shopview-delete-%d", user.Telegram.ID)) + defer mutex.Unlock(fmt.Sprintf("shopview-delete-%d", user.Telegram.ID)) + shopView, err = bot.getUserShopview(ctx, user) + if err != nil { + return + } + deleteStatusMessages(shopView.StatusMessages, bot) + shopView.StatusMessages = make([]*tb.Message, 0) + bot.Cache.Set(shopView.ID, shopView, &store.Options{Expiration: 24 * time.Hour}) + return +} + +func deleteStatusMessages(messages []*tb.Message, bot *TipBot) { + // delete all status messages from telegram + for _, msg := range messages { + bot.tryDeleteMessage(msg) + } +} + +// sendStatusMessage adds a status message to the shopVoew.statusMessages +// slide and sends a status message to the user. +func (bot *TipBot) sendStatusMessage(ctx intercept.Context, to tb.Recipient, what interface{}, options ...interface{}) (msg *tb.Message) { + user := LoadUser(ctx) + id := fmt.Sprintf("shopview-delete-%d", user.Telegram.ID) + + // write into cache + mutex.Lock(id) + defer mutex.Unlock(id) + shopView, err := bot.getUserShopview(ctx, user) + if err != nil { + return nil + } + statusMsg := bot.trySendMessage(to, what, options...) + shopView.StatusMessages = append(shopView.StatusMessages, statusMsg) + bot.Cache.Set(shopView.ID, shopView, &store.Options{Expiration: 24 * time.Hour}) + return statusMsg +} + +// sendStatusMessageAndDelete invokes sendStatusMessage and creates +// a ticker to delete all status messages after 5 seconds. +func (bot *TipBot) sendStatusMessageAndDelete(ctx intercept.Context, to tb.Recipient, what interface{}, options ...interface{}) (msg *tb.Message) { + user := LoadUser(ctx) + id := fmt.Sprintf("shopview-delete-%d", user.Telegram.ID) + statusMsg := bot.sendStatusMessage(ctx, to, what, options...) + // kick off ticker to remove all messages + ticker := runtime.GetFunction(id, runtime.WithTicker(time.NewTicker(5*time.Second)), runtime.WithDuration(5*time.Second)) + if !ticker.Started { + ticker.Do(func() { + bot.shopViewDeleteAllStatusMsgs(ctx, user) + // removing ticker asap done + runtime.RemoveTicker(id) + }) + } else { + ticker.ResetChan <- struct{}{} + } + return statusMsg +} + +// --------------- Shop --------------- + +// initUserShops is a helper function for creating a Shops for the user in the database +func (bot *TipBot) initUserShops(ctx intercept.Context, user *lnbits.User) (*Shops, error) { + id := fmt.Sprintf("shops-%d", user.Telegram.ID) + shops := &Shops{ + Base: storage.New(storage.ID(id)), + Owner: user, + Shops: []string{}, + MaxShops: MAX_SHOPS, + } + runtime.IgnoreError(shops.Set(shops, bot.ShopBunt)) + return shops, nil +} + +// getUserShops returns the Shops for the user +func (bot *TipBot) getUserShops(ctx intercept.Context, user *lnbits.User) (*Shops, error) { + tx := &Shops{Base: storage.New(storage.ID(fmt.Sprintf("shops-%d", user.Telegram.ID)))} + sn, err := tx.Get(tx, bot.ShopBunt) + if err != nil { + log.Errorf("[getUserShops] User: %s (%d): %s", GetUserStr(user.Telegram), user.Telegram.ID, err) + return &Shops{}, err + } + shops := sn.(*Shops) + return shops, nil +} + +// addUserShop adds a new Shop to the Shops of a user +func (bot *TipBot) addUserShop(ctx intercept.Context, user *lnbits.User) (*Shop, error) { + shops, err := bot.getUserShops(ctx, user) + if err != nil { + return &Shop{}, err + } + shopId := fmt.Sprintf("shop-%s", RandStringRunes(10)) + shop := &Shop{ + Base: storage.New(storage.ID(shopId)), + Title: fmt.Sprintf("Shop %d (%s)", len(shops.Shops)+1, shopId), + Owner: user, + Type: "photo", + Items: make(map[string]ShopItem), + LanguageCode: ctx.Value("publicLanguageCode").(string), + ShopsID: shops.ID, + MaxItems: MAX_ITEMS_PER_SHOP, + } + runtime.IgnoreError(shop.Set(shop, bot.ShopBunt)) + shops.Shops = append(shops.Shops, shopId) + runtime.IgnoreError(shops.Set(shops, bot.ShopBunt)) + return shop, nil +} + +// getShop returns the Shop of a given ID +func (bot *TipBot) getShop(ctx intercept.Context, shopId string) (*Shop, error) { + tx := &Shop{Base: storage.New(storage.ID(shopId))} + // immediatelly set intransaction to block duplicate calls + sn, err := tx.Get(tx, bot.ShopBunt) + if err != nil { + log.Errorf("[getShop] %s", err.Error()) + return &Shop{}, err + } + shop := sn.(*Shop) + if shop.Owner == nil { + return &Shop{}, fmt.Errorf("shop has no owner") + } + return shop, nil +} diff --git a/internal/telegram/standing_order_scheduler.go b/internal/telegram/standing_order_scheduler.go new file mode 100644 index 00000000..2380c6b9 --- /dev/null +++ b/internal/telegram/standing_order_scheduler.go @@ -0,0 +1,214 @@ +package telegram + +import ( + "context" + "fmt" + "time" + + "github.com/LightningTipBot/LightningTipBot/internal/lnbits" + "github.com/LightningTipBot/LightningTipBot/internal/utils" + log "github.com/sirupsen/logrus" +) + +// maxConsecutiveFailures is the number of consecutive monthly failures after which +// a standing order is automatically deactivated and the user is notified. +// This prevents indefinite failure spam when a pot is deleted or renamed. +const maxConsecutiveFailures = 3 + +// StandingOrderScheduler runs hourly and executes standing orders on the +// configured day of month, clamping days 29–31 to the last day of short months. +type StandingOrderScheduler struct { + bot *TipBot + CheckInterval time.Duration +} + +// NewStandingOrderScheduler creates a new scheduler instance attached to the given bot. +func NewStandingOrderScheduler(bot *TipBot) *StandingOrderScheduler { + return &StandingOrderScheduler{ + bot: bot, + CheckInterval: 1 * time.Hour, + } +} + +// Start launches the scheduler in a background goroutine. +// The provided context should be cancelled when the bot is shutting down +// so the scheduler exits cleanly without waiting for the next tick. +func (s *StandingOrderScheduler) Start(ctx context.Context) { + go s.run(ctx) +} + +// run is the main scheduler loop. It processes due orders immediately on start, +// then repeats every CheckInterval. It exits when ctx is cancelled. +func (s *StandingOrderScheduler) run(ctx context.Context) { + ticker := time.NewTicker(s.CheckInterval) + defer ticker.Stop() + + // Run immediately on start so orders due today are not delayed by one interval + s.processDueOrders() + + for { + select { + case <-ctx.Done(): + // Bot is shutting down — exit the goroutine cleanly + log.Infof("[StandingOrderScheduler] Shutting down.") + return + case <-ticker.C: + s.processDueOrders() + } + } +} + +// effectiveDayForMonth returns the day the order should fire in the given month. +// If the configured day exceeds the month's last day (e.g. day 31 in April), +// it clamps to the last day so salary-day users are never skipped. +func effectiveDayForMonth(configuredDay int, t time.Time) int { + // time.Date with day=0 of next month gives last day of current month + lastDay := time.Date(t.Year(), t.Month()+1, 0, 0, 0, 0, 0, t.Location()).Day() + if configuredDay > lastDay { + return lastDay + } + return configuredDay +} + +// shouldExecuteToday returns true if the order has not already run today. +// Protects against double-execution when the bot restarts mid-day. +func shouldExecuteToday(order lnbits.StandingOrder, now time.Time) bool { + if order.LastExecutedAt == nil { + return true + } + last := *order.LastExecutedAt + return last.Year() != now.Year() || last.Month() != now.Month() || last.Day() != now.Day() +} + +// processDueOrders fetches all active standing orders, filters to those due today +// (accounting for month-end clamping), and executes each one. +func (s *StandingOrderScheduler) processDueOrders() { + now := time.Now() + today := now.Day() + + var orders []lnbits.StandingOrder + if err := s.bot.DB.Users.Where("active = true").Find(&orders).Error; err != nil { + log.Errorf("[StandingOrderScheduler] Failed to fetch orders: %v", err) + return + } + + for _, order := range orders { + if effectiveDayForMonth(order.DayOfMonth, now) != today { + continue + } + if !shouldExecuteToday(order, now) { + continue + } + + // Load the user + var user lnbits.User + if err := s.bot.DB.Users.Where("id = ?", order.UserID).First(&user).Error; err != nil { + log.Errorf("[StandingOrderScheduler] User not found for order %s: %v", order.ID, err) + continue + } + + // Skip banned or wallet-less users silently + if user.Banned || user.Wallet == nil { + continue + } + + if err := s.executeOrder(&order, &user); err != nil { + // Increment consecutive failure count and deactivate if threshold is reached + order.ConsecutiveFailures++ + if order.ConsecutiveFailures >= maxConsecutiveFailures { + order.Active = false + log.Warnf("[StandingOrderScheduler] Deactivating order %s after %d consecutive failures", order.ID, order.ConsecutiveFailures) + if saveErr := s.bot.DB.Users.Save(&order).Error; saveErr != nil { + log.Errorf("[StandingOrderScheduler] Failed to deactivate order %s: %v", order.ID, saveErr) + } + s.notifyDeactivated(&user, order) + } else { + if saveErr := s.bot.DB.Users.Save(&order).Error; saveErr != nil { + log.Errorf("[StandingOrderScheduler] Failed to update failure count for order %s: %v", order.ID, saveErr) + } + s.notifyFailure(&user, order, err) + } + } else { + // Reset consecutive failure count on success + if order.ConsecutiveFailures > 0 { + order.ConsecutiveFailures = 0 + if saveErr := s.bot.DB.Users.Save(&order).Error; saveErr != nil { + log.Errorf("[StandingOrderScheduler] Failed to reset failure count for order %s: %v", order.ID, saveErr) + } + } + s.notifySuccess(&user, order) + } + } +} + +// executeOrder transfers the standing order amount to the target pot. +// +// LastExecutedAt is saved BEFORE the transfer so that if the transfer succeeds +// but the subsequent DB write fails, the order is not executed again on the next +// tick (double-execution). If the transfer itself fails, LastExecutedAt is reset +// to its previous value so the order can be retried next month. +// +// Worst case of this approach: a failed transfer + a failed reset means the order +// is skipped for this month — which is far safer than a double transfer. +func (s *StandingOrderScheduler) executeOrder(order *lnbits.StandingOrder, user *lnbits.User) error { + now := time.Now() + previousExecutedAt := order.LastExecutedAt + + // Mark as executed before the transfer to prevent double-execution + order.LastExecutedAt = &now + if err := s.bot.DB.Users.Save(order).Error; err != nil { + return fmt.Errorf("failed to mark order as executed: %w", err) + } + + // Execute the transfer + if err := s.bot.TransferToPot(user, order.PotName, order.Amount); err != nil { + // Transfer failed — reset LastExecutedAt so the order can be retried next month + order.LastExecutedAt = previousExecutedAt + if resetErr := s.bot.DB.Users.Save(order).Error; resetErr != nil { + log.Errorf("[StandingOrderScheduler] Failed to reset LastExecutedAt for order %s after transfer failure: %v", order.ID, resetErr) + } + return err + } + + return nil +} + +// notifySuccess logs and sends a Telegram message to the user after a successful execution. +func (s *StandingOrderScheduler) notifySuccess(user *lnbits.User, order lnbits.StandingOrder) { + log.Infof("[StandingOrderScheduler] Executed order %s for user %s: %s → pot '%s'", + order.ID, user.Name, utils.FormatSats(order.Amount), order.PotName) + msg := fmt.Sprintf( + "✅ *Standing Order Executed*\n\n📅 Day %d of month\n💰 *%s* transferred to pot *'%s'*", + order.DayOfMonth, utils.FormatSats(order.Amount), order.PotName, + ) + s.bot.trySendMessage(user.Telegram, msg) +} + +// notifyDeactivated informs the user that their standing order has been +// automatically deactivated after too many consecutive failures. +func (s *StandingOrderScheduler) notifyDeactivated(user *lnbits.User, order lnbits.StandingOrder) { + log.Warnf("[StandingOrderScheduler] Order %s for user %s deactivated after %d failures", order.ID, user.Name, order.ConsecutiveFailures) + msg := fmt.Sprintf( + "🚫 *Standing Order Deactivated*\n\n📅 Day %d of month\n💰 %s → pot *'%s'*\n\n"+ + "This order has failed %d months in a row and has been automatically deactivated.\n\n"+ + "Please check that the pot *'%s'* still exists and recreate the order with `/so create`.", + order.DayOfMonth, utils.FormatSats(order.Amount), order.PotName, + order.ConsecutiveFailures, order.PotName, + ) + s.bot.trySendMessage(user.Telegram, msg) +} + +// notifyFailure logs the full error internally and sends a sanitized message to +// the user. Raw error details are kept out of the Telegram message to avoid +// leaking internal implementation details. +func (s *StandingOrderScheduler) notifyFailure(user *lnbits.User, order lnbits.StandingOrder, err error) { + log.Errorf("[StandingOrderScheduler] Failed to execute order %s for user %s: %v", order.ID, user.Name, err) + remaining := maxConsecutiveFailures - order.ConsecutiveFailures + msg := fmt.Sprintf( + "⚠️ *Standing Order Failed*\n\n📅 Day %d of month\n💰 %s → pot *'%s'*\n\n"+ + "🚫 The transfer could not be completed. Please check your available balance and that the pot still exists.\n\n"+ + "_%d more failure(s) and this order will be automatically deactivated._", + order.DayOfMonth, utils.FormatSats(order.Amount), order.PotName, remaining, + ) + s.bot.trySendMessage(user.Telegram, msg) +} diff --git a/internal/telegram/standing_orders.go b/internal/telegram/standing_orders.go new file mode 100644 index 00000000..dc85d60c --- /dev/null +++ b/internal/telegram/standing_orders.go @@ -0,0 +1,234 @@ +package telegram + +import ( + "fmt" + "strconv" + "strings" + + "github.com/LightningTipBot/LightningTipBot/internal/errors" + "github.com/LightningTipBot/LightningTipBot/internal/lnbits" + "github.com/LightningTipBot/LightningTipBot/internal/telegram/intercept" + "github.com/LightningTipBot/LightningTipBot/internal/utils" + log "github.com/sirupsen/logrus" + uuid "github.com/satori/go.uuid" +) + +const ( + MaxStandingOrdersPerUser = 10 + MinDayOfMonth = 1 + MaxDayOfMonth = 31 +) + +// CreateStandingOrder creates a new standing order for a user. +// It validates the day, amount, and that the target pot exists before saving. +func (bot *TipBot) CreateStandingOrder(user *lnbits.User, dayOfMonth int, amount int64, potName string) (*lnbits.StandingOrder, error) { + if dayOfMonth < MinDayOfMonth || dayOfMonth > MaxDayOfMonth { + return nil, fmt.Errorf("day must be between %d and %d", MinDayOfMonth, MaxDayOfMonth) + } + if amount <= 0 { + return nil, fmt.Errorf("amount must be positive") + } + + // Verify the target pot exists before creating the order + potName = strings.TrimSpace(potName) + if _, err := bot.GetPot(user, potName); err != nil { + return nil, fmt.Errorf("pot '%s' not found — create it first with /createpot", potName) + } + + // Enforce per-user standing order limit + var orderCount int64 + bot.DB.Users.Model(&lnbits.StandingOrder{}).Where("user_id = ? AND active = true", user.ID).Count(&orderCount) + if orderCount >= MaxStandingOrdersPerUser { + return nil, fmt.Errorf("maximum number of standing orders reached (%d)", MaxStandingOrdersPerUser) + } + + order := &lnbits.StandingOrder{ + ID: uuid.NewV4().String(), + UserID: user.ID, + PotName: potName, + DayOfMonth: dayOfMonth, + Amount: amount, + Active: true, + } + + if err := bot.DB.Users.Create(order).Error; err != nil { + return nil, fmt.Errorf("failed to create standing order: %w", err) + } + + return order, nil +} + +// ListStandingOrders returns all active standing orders for a user, sorted by day of month. +func (bot *TipBot) ListStandingOrders(user *lnbits.User) ([]lnbits.StandingOrder, error) { + var orders []lnbits.StandingOrder + err := bot.DB.Users.Where("user_id = ? AND active = true", user.ID).Order("day_of_month ASC").Find(&orders).Error + return orders, err +} + +// GetStandingOrderByID retrieves a single standing order by ID, scoped to the user +// to prevent cross-user access. +func (bot *TipBot) GetStandingOrderByID(user *lnbits.User, orderID string) (*lnbits.StandingOrder, error) { + var order lnbits.StandingOrder + err := bot.DB.Users.Where("id = ? AND user_id = ?", orderID, user.ID).First(&order).Error + if err != nil { + return nil, fmt.Errorf("standing order not found") + } + return &order, nil +} + +// DeleteStandingOrder permanently removes a standing order by ID, scoped to the user. +func (bot *TipBot) DeleteStandingOrder(user *lnbits.User, orderID string) error { + result := bot.DB.Users.Where("id = ? AND user_id = ?", orderID, user.ID).Delete(&lnbits.StandingOrder{}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return fmt.Errorf("standing order not found") + } + return nil +} + +// ─── Telegram Handlers ─────────────────────────────────────────────────────── + +// soHelpText is shown when /so is called without arguments or with an unknown sub-command. +const soHelpText = "📅 *Standing Orders (/so)*\n\n" + + "`/so create ` — create a standing order\n" + + "`/so list` — list your standing orders\n" + + "`/so delete ` — delete by list number\n\n" + + "*Example:* `/so create 25 1000 Savings`\n" + + "_Day 29–31 fires on the last day of shorter months._" + +// soHandler is the single entry point for all /so sub-commands. +// It dispatches to soCreateHandler, soListHandler, or soDeleteHandler based on +// the first argument. +func (bot *TipBot) soHandler(ctx intercept.Context) (intercept.Context, error) { + m := ctx.Message() + user := LoadUser(ctx) + + if user.Wallet == nil { + return ctx, errors.Create(errors.UserNoWalletError) + } + + arguments := strings.Fields(m.Text) + if len(arguments) < 2 { + bot.trySendMessage(ctx.Sender(), Translate(ctx, "standingOrderHelpText")) + return ctx, nil + } + + switch strings.ToLower(arguments[1]) { + case "create": + return bot.soCreateHandler(ctx, user, arguments) + case "list": + return bot.soListHandler(ctx, user) + case "delete": + return bot.soDeleteHandler(ctx, user, arguments) + default: + bot.trySendMessage(ctx.Sender(), Translate(ctx, "standingOrderHelpText")) + } + return ctx, nil +} + +// soCreateHandler handles /so create . +// Parses and validates arguments then calls CreateStandingOrder. +func (bot *TipBot) soCreateHandler(ctx intercept.Context, user *lnbits.User, arguments []string) (intercept.Context, error) { + // /so create + if len(arguments) < 5 { + bot.trySendMessage(ctx.Sender(), "📅 *Usage:* `/so create `\n\nExample: `/so create 25 1000 Savings`") + return ctx, nil + } + + dayOfMonth, err := strconv.Atoi(arguments[2]) + if err != nil { + bot.trySendMessage(ctx.Sender(), "❌ Invalid day — must be a number between 1 and 31.") + return ctx, nil + } + + amount, err := getAmount(ctx, arguments[3]) + if err != nil { + bot.trySendMessage(ctx.Sender(), fmt.Sprintf("❌ Invalid amount: %s", err.Error())) + return ctx, nil + } + + // Everything after the amount is the pot name (supports spaces in pot names) + potName := strings.Join(arguments[4:], " ") + + order, err := bot.CreateStandingOrder(user, dayOfMonth, amount, potName) + if err != nil { + log.Errorf("[/so create] Failed to create standing order for user %s: %v", user.Name, err) + bot.trySendMessage(ctx.Sender(), fmt.Sprintf("❌ %s", err.Error())) + return ctx, nil + } + + bot.trySendMessage(ctx.Sender(), fmt.Sprintf( + "✅ *Standing Order Created*\n\n📅 Day *%d* of each month\n💰 *%s* → pot *'%s'*\n\nYour balance will be checked automatically on the scheduled day.", + order.DayOfMonth, utils.FormatSats(order.Amount), order.PotName, + )) + return ctx, nil +} + +// soListHandler handles /so list. +// Displays all active standing orders for the user as a numbered list. +func (bot *TipBot) soListHandler(ctx intercept.Context, user *lnbits.User) (intercept.Context, error) { + orders, err := bot.ListStandingOrders(user) + if err != nil { + bot.trySendMessage(ctx.Sender(), "❌ Failed to fetch your standing orders.") + return ctx, err + } + + if len(orders) == 0 { + bot.trySendMessage(ctx.Sender(), "📅 You have no standing orders yet.\n\nUse `/so create ` to create one.") + return ctx, nil + } + + message := "📅 *Your Standing Orders:*\n\n" + for i, order := range orders { + // Show last execution date or "never run" if the order hasn't fired yet + lastRun := "never run" + if order.LastExecutedAt != nil { + lastRun = fmt.Sprintf("last run: %s", order.LastExecutedAt.Format("2006-01-02")) + } + message += fmt.Sprintf("%d. Day *%d* → *%s* to pot *'%s'* [%s]\n", + i+1, order.DayOfMonth, utils.FormatSats(order.Amount), order.PotName, lastRun) + } + message += "\nUse `/so delete ` to remove one." + + bot.trySendMessage(ctx.Sender(), message) + return ctx, nil +} + +// soDeleteHandler handles /so delete . +// Fetches the user's order list and deletes the entry at the given 1-based index. +func (bot *TipBot) soDeleteHandler(ctx intercept.Context, user *lnbits.User, arguments []string) (intercept.Context, error) { + if len(arguments) < 3 { + bot.trySendMessage(ctx.Sender(), "📅 *Usage:* `/so delete `\n\nUse `/so list` to see your list.") + return ctx, nil + } + + index, err := strconv.Atoi(arguments[2]) + if err != nil || index < 1 { + bot.trySendMessage(ctx.Sender(), "❌ Invalid number. Use `/so list` to see the list numbers.") + return ctx, nil + } + + // Re-fetch the list so the index is always accurate + orders, err := bot.ListStandingOrders(user) + if err != nil { + bot.trySendMessage(ctx.Sender(), "❌ Failed to fetch your standing orders.") + return ctx, err + } + + if index > len(orders) { + bot.trySendMessage(ctx.Sender(), fmt.Sprintf("❌ No standing order at position %d. You have %d order(s).", index, len(orders))) + return ctx, nil + } + + order := orders[index-1] + if err := bot.DeleteStandingOrder(user, order.ID); err != nil { + log.Errorf("[/so delete] Failed to delete standing order %s for user %s: %v", order.ID, user.Name, err) + bot.trySendMessage(ctx.Sender(), "❌ Failed to delete standing order. Please try again.") + return ctx, err + } + + bot.trySendMessage(ctx.Sender(), fmt.Sprintf("🗑️ Deleted: Day %d → %s to pot '%s'", order.DayOfMonth, utils.FormatSats(order.Amount), order.PotName)) + return ctx, nil +} diff --git a/internal/telegram/start.go b/internal/telegram/start.go new file mode 100644 index 00000000..526686df --- /dev/null +++ b/internal/telegram/start.go @@ -0,0 +1,125 @@ +package telegram + +import ( + "context" + stderrors "errors" + "fmt" + "strconv" + "time" + + "github.com/LightningTipBot/LightningTipBot/internal/telegram/intercept" + + "github.com/LightningTipBot/LightningTipBot/internal/errors" + + "github.com/LightningTipBot/LightningTipBot/internal" + + log "github.com/sirupsen/logrus" + + "github.com/LightningTipBot/LightningTipBot/internal/lnbits" + "github.com/LightningTipBot/LightningTipBot/internal/str" + tb "gopkg.in/lightningtipbot/telebot.v3" + "gorm.io/gorm" +) + +func (bot TipBot) startHandler(ctx intercept.Context) (intercept.Context, error) { + if !ctx.Message().Private() { + return ctx, errors.Create(errors.NoPrivateChatError) + } + // ATTENTION: DO NOT CALL ANY HANDLER BEFORE THE WALLET IS CREATED + // WILL RESULT IN AN ENDLESS LOOP OTHERWISE + // bot.helpHandler(m) + log.Printf("[⭐️ /start] New user: %s (%d)\n", GetUserStr(ctx.Sender()), ctx.Sender().ID) + walletCreationMsg := bot.trySendMessageEditable(ctx.Sender(), Translate(ctx, "startSettingWalletMessage")) + user, err := bot.initWallet(ctx.Sender()) + if err != nil { + log.Errorln(fmt.Sprintf("[startHandler] Error with initWallet: %s", err.Error())) + if bot.ErrorLogger != nil { + bot.ErrorLogger.LogCriticalError(err, "Wallet initialization failed", ctx.Sender()) + } + bot.tryEditMessage(walletCreationMsg, Translate(ctx, "startWalletErrorMessage")) + return ctx, err + } + bot.tryDeleteMessage(walletCreationMsg) + ctx.Context = context.WithValue(ctx, "user", user) + bot.helpHandler(ctx) + bot.trySendMessage(ctx.Sender(), Translate(ctx, "startWalletReadyMessage")) + bot.balanceHandler(ctx) + + // send the user a warning about the fact that they need to set a username + if len(ctx.Sender().Username) == 0 { + bot.trySendMessage(ctx.Sender(), Translate(ctx, "startNoUsernameMessage"), tb.NoPreview) + } + return ctx, nil +} + +func (bot TipBot) initWallet(tguser *tb.User) (*lnbits.User, error) { + user, err := GetUser(tguser, bot) + if stderrors.Is(err, gorm.ErrRecordNotFound) { + user = &lnbits.User{Telegram: tguser} + err = bot.createWallet(user) + if err != nil { + return user, err + } + // set user initialized + user, err := GetUser(tguser, bot) + user.Initialized = true + err = UpdateUserRecord(user, bot) + if err != nil { + log.Errorln(fmt.Sprintf("[initWallet] error updating user: %s", err.Error())) + return user, 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 user, err + } + } else if user.Initialized { + // wallet is already initialized + return user, nil + } else { + err = fmt.Errorf("could not initialize wallet") + return user, err + } + return user, nil +} + +func (bot TipBot) createWallet(user *lnbits.User) error { + UserStr := GetUserStr(user.Telegram) + u, err := bot.Client.CreateUserWithInitialWallet(strconv.FormatInt(user.Telegram.ID, 10), + fmt.Sprintf("%d (%s)", user.Telegram.ID, UserStr), + internal.Configuration.Lnbits.AdminId, + UserStr) + if err != nil { + errormsg := fmt.Sprintf("[createWallet] Create wallet error: %s", err.Error()) + log.Errorln(errormsg) + return err + } + user.Wallet = &lnbits.Wallet{} + user.ID = u.ID + user.Name = u.Name + wallet, err := bot.Client.Wallets(*user) + if err != nil { + errormsg := fmt.Sprintf("[createWallet] Get wallet error: %s", err.Error()) + log.Errorln(errormsg) + return err + } + user.Wallet = &wallet[0] + + user.AnonID = fmt.Sprint(str.Int32Hash(user.ID)) + user.AnonIDSha256 = str.AnonIdSha256(user) + user.UUID = str.UUIDSha256(user) + + user.Initialized = false + user.CreatedAt = time.Now() + err = UpdateUserRecord(user, bot) + if err != nil { + errormsg := fmt.Sprintf("[createWallet] Update user record error: %s", err.Error()) + log.Errorln(errormsg) + return err + } + return nil +} diff --git a/internal/telegram/state.go b/internal/telegram/state.go new file mode 100644 index 00000000..3839d942 --- /dev/null +++ b/internal/telegram/state.go @@ -0,0 +1,25 @@ +package telegram + +import ( + "github.com/LightningTipBot/LightningTipBot/internal/lnbits" + "github.com/LightningTipBot/LightningTipBot/internal/telegram/intercept" +) + +type StateCallbackMessage map[lnbits.UserStateKey]func(ctx intercept.Context) (intercept.Context, error) + +var stateCallbackMessage StateCallbackMessage + +func initializeStateCallbackMessage(bot *TipBot) { + stateCallbackMessage = StateCallbackMessage{ + lnbits.UserStateLNURLEnterAmount: bot.enterAmountHandler, + lnbits.UserEnterAmount: bot.enterAmountHandler, + lnbits.UserEnterUser: bot.enterUserHandler, + lnbits.UserEnterShopTitle: bot.enterShopTitleHandler, + lnbits.UserStateShopItemSendPhoto: bot.addShopItemPhoto, + lnbits.UserStateShopItemSendPrice: bot.enterShopItemPriceHandler, + lnbits.UserStateShopItemSendTitle: bot.enterShopItemTitleHandler, + lnbits.UserStateShopItemSendItemFile: bot.addItemFileHandler, + lnbits.UserEnterShopsDescription: bot.enterShopsDescriptionHandler, + lnbits.UserEnterDallePrompt: bot.confirmGenerateImages, + } +} diff --git a/internal/telegram/telegram.go b/internal/telegram/telegram.go new file mode 100644 index 00000000..fc25b2f0 --- /dev/null +++ b/internal/telegram/telegram.go @@ -0,0 +1,184 @@ +package telegram + +import ( + "fmt" + "strconv" + "time" + + "github.com/LightningTipBot/LightningTipBot/internal/rate" + "github.com/eko/gocache/store" + log "github.com/sirupsen/logrus" + tb "gopkg.in/lightningtipbot/telebot.v3" +) + +// getChatIdFromRecipient will parse the recipient to int64 +func (bot *TipBot) getChatIdFromRecipient(to tb.Recipient) (int64, error) { + chatId, err := strconv.ParseInt(to.Recipient(), 10, 64) + if err != nil { + return 0, err + } + return chatId, nil +} + +func (bot TipBot) tryForwardMessage(to tb.Recipient, what tb.Editable, options ...interface{}) (msg *tb.Message) { + rate.CheckLimit(to) + // ChatId is used for the keyboard + chatId, err := bot.getChatIdFromRecipient(to) + if err != nil { + log.Errorf("[tryForwardMessage] error converting message recipient to int64: %v", err) + return + } + msg, err = bot.Telegram.Forward(to, what, bot.appendMainMenu(chatId, to, options)...) + if err != nil { + log.Warnln(err.Error()) + } + return +} +func (bot TipBot) trySendMessage(to tb.Recipient, what interface{}, options ...interface{}) (msg *tb.Message) { + rate.CheckLimit(to) + // ChatId is used for the keyboard + chatId, err := bot.getChatIdFromRecipient(to) + if err != nil { + log.Errorf("[trySendMessage] error converting message recipient to int64: %v", err) + return + } + log.Tracef("[trySendMessage] chatId: %d", chatId) + msg, err = bot.Telegram.Send(to, what, bot.appendMainMenu(chatId, to, options)...) + if err != nil { + log.Warnln(err.Error()) + } + return +} + +func (bot TipBot) trySendMessageEditable(to tb.Recipient, what interface{}, options ...interface{}) (msg *tb.Message) { + rate.CheckLimit(to) + msg, err := bot.Telegram.Send(to, what, options...) + if err != nil { + log.Warnln(err.Error()) + } + return +} + +func (bot TipBot) tryReplyMessage(to *tb.Message, what interface{}, options ...interface{}) (msg *tb.Message) { + rate.CheckLimit(to) + msg, err := bot.Telegram.Reply(to, what, bot.appendMainMenu(to.Chat.ID, to, options)...) + if err != nil { + log.Warnln(err.Error()) + } + return +} + +func (bot TipBot) tryEditMessage(to tb.Editable, what interface{}, options ...interface{}) (msg *tb.Message, err error) { + // get a sig for the rate limiter + sig, chat := to.MessageSig() + if chat != 0 { + sig = strconv.FormatInt(chat, 10) + } + rate.CheckLimit(sig) + + _, chatId := to.MessageSig() + log.Tracef("[tryEditMessage] sig: %s, chatId: %d", sig, chatId) + msg, err = bot.Telegram.Edit(to, what, options...) + if err != nil { + log.Warnln(err.Error()) + } + return +} + +func (bot TipBot) tryDeleteMessage(msg tb.Editable) { + if !allowedToPerformAction(bot, msg, isAdminAndCanDelete) { + return + } + rate.CheckLimit(msg) + err := bot.Telegram.Delete(msg) + if err != nil { + log.Warnln(err.Error()) + } + return + +} + +// allowedToPerformAction will check if bot is allowed to perform an action on the tb.Editable. +// this function will persist the admins list in cache for 5 minutes. +// if no admins list is found for this group, bot will always fetch a fresh list. +func allowedToPerformAction(bot TipBot, editable tb.Editable, action func(members []tb.ChatMember, me *tb.User) bool) bool { + switch editable.(type) { + case *tb.Message: + message := editable.(*tb.Message) + if message.Sender.ID == bot.Telegram.Me.ID { + break + } + chat := message.Chat + if chat.Type == tb.ChatSuperGroup || chat.Type == tb.ChatGroup { + admins, err := bot.Cache.Get(fmt.Sprintf("admins-%d", chat.ID)) + if err != nil { + admins, err = bot.Telegram.AdminsOf(message.Chat) + if err != nil { + log.Warnln(err.Error()) + return false + } + bot.Cache.Set(fmt.Sprintf("admins-%d", chat.ID), admins, &store.Options{Expiration: 5 * time.Minute}) + } + if action(admins.([]tb.ChatMember), bot.Telegram.Me) { + return true + } + return false + } + } + return true +} + +// isAdminAndCanDelete will check if me is in members list and allowed to delete messages +func isAdminAndCanDelete(members []tb.ChatMember, me *tb.User) bool { + for _, admin := range members { + if admin.User.ID == me.ID { + return admin.CanDeleteMessages + } + } + return false +} + +// isOwner will check if user is owner of group +func (bot *TipBot) isOwner(chat *tb.Chat, me *tb.User) bool { + members, err := bot.Telegram.AdminsOf(chat) + if err != nil { + log.Warnln(err.Error()) + return false + } + for _, admin := range members { + if admin.User.ID == me.ID && admin.Role == "creator" { + return true + } + } + return false +} + +// isAdmin will check if user is admin in a group +func (bot *TipBot) isAdmin(chat *tb.Chat, me *tb.User) bool { + members, err := bot.Telegram.AdminsOf(chat) + if err != nil { + log.Warnln(err.Error()) + return false + } + for _, admin := range members { + if admin.User.ID == me.ID { + return true + } + } + return false +} + +// isAdmin will check if user is admin in a group +func (bot *TipBot) isAdminAndCanInviteUsers(chat *tb.Chat, me *tb.User) bool { + members, err := bot.Telegram.AdminsOf(chat) + if err != nil { + log.Warnln(err.Error()) + return false + } + for _, admin := range members { + if admin.User.ID == me.ID { + return admin.CanInviteUsers + } + } + return false +} diff --git a/internal/telegram/text.go b/internal/telegram/text.go new file mode 100644 index 00000000..d4eaacf2 --- /dev/null +++ b/internal/telegram/text.go @@ -0,0 +1,127 @@ +package telegram + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/LightningTipBot/LightningTipBot/internal/errors" + "github.com/LightningTipBot/LightningTipBot/internal/telegram/intercept" + + "github.com/LightningTipBot/LightningTipBot/internal/lnbits" + "github.com/LightningTipBot/LightningTipBot/pkg/lightning" + log "github.com/sirupsen/logrus" + tb "gopkg.in/lightningtipbot/telebot.v3" +) + +func (bot *TipBot) anyTextHandler(ctx intercept.Context) (intercept.Context, error) { + m := ctx.Message() + if m.Chat.Type != tb.ChatPrivate { + return ctx, errors.Create(errors.NoPrivateChatError) + } + + // check if user is in Database, if not, initialize wallet + user := LoadUser(ctx) + if user.Wallet == nil || !user.Initialized { + return bot.startHandler(ctx) + } + + // check if the user clicked on the balance button + if strings.HasPrefix(m.Text, MainMenuCommandBalance) { + bot.tryDeleteMessage(m) + // overwrite the message text so it doesn't cause an infinite loop + // because balanceHandler calls anyTextHAndler... + m.Text = "" + return bot.balanceHandler(ctx) + } + + // could be an invoice + anyText := strings.ToLower(m.Text) + if lightning.IsInvoice(anyText) { + m.Text = "/pay " + anyText + return bot.payHandler(ctx) + } + if lightning.IsLnurl(anyText) { + m.Text = "/lnurl " + anyText + return bot.lnurlHandler(ctx) + } + if c := stateCallbackMessage[user.StateKey]; c != nil { + return c(ctx) + //ResetUserState(user, bot) + } + return ctx, nil +} + +type EnterUserStateData struct { + ID string `json:"ID"` // holds the ID of the tx object in bunt db + Type string `json:"Type"` // holds type of the tx in bunt db (needed for type checking) + Amount int64 `json:"Amount"` // holds the amount entered by the user mSat + AmountMin int64 `json:"AmountMin"` // holds the minimum amount that needs to be entered mSat + AmountMax int64 `json:"AmountMax"` // holds the maximum amount that needs to be entered mSat + OiringalCommand string `json:"OiringalCommand"` // hold the originally entered command for evtl later use +} + +func (bot *TipBot) askForUser(ctx context.Context, id string, eventType string, originalCommand string) (enterUserStateData *EnterUserStateData, err error) { + user := LoadUser(ctx) + if user.Wallet == nil { + return // errors.New("user has no wallet"), 0 + } + enterUserStateData = &EnterUserStateData{ + ID: id, + Type: eventType, + OiringalCommand: originalCommand, + } + // set LNURLPayParams in the state of the user + stateDataJson, err := json.Marshal(enterUserStateData) + if err != nil { + log.Errorln(err) + return + } + SetUserState(user, bot, lnbits.UserEnterUser, string(stateDataJson)) + // Let the user enter a user and return + bot.trySendMessage(user.Telegram, Translate(ctx, "enterUserMessage"), tb.ForceReply) + return +} + +// enterAmountHandler is invoked in anyTextHandler when the user needs to enter an amount +// the amount is then stored as an entry in the user's stateKey in the user database +// any other ctx that relies on this, needs to load the resulting amount from the database +func (bot *TipBot) enterUserHandler(ctx intercept.Context) (intercept.Context, error) { + m := ctx.Message() + user := LoadUser(ctx) + if user.Wallet == nil { + return ctx, errors.Create(errors.UserNoWalletError) + } + + if !(user.StateKey == lnbits.UserEnterUser) { + ResetUserState(user, bot) + return ctx, errors.Create(errors.InvalidSyntaxError) + } + if len(m.Text) < 4 || strings.HasPrefix(m.Text, "/") || m.Text == SendMenuCommandEnter { + ResetUserState(user, bot) + return ctx, errors.Create(errors.InvalidSyntaxError) + } + + var EnterUserStateData EnterUserStateData + err := json.Unmarshal([]byte(user.StateData), &EnterUserStateData) + if err != nil { + log.Errorf("[EnterUserHandler] %s", err.Error()) + ResetUserState(user, bot) + return ctx, err + } + + userstr := m.Text + + // find out which type the object in bunt has waiting for an amount + // we stored this in the EnterAmountStateData before + switch EnterUserStateData.Type { + case "CreateSendState": + m.Text = fmt.Sprintf("/send %s", userstr) + return bot.sendHandler(ctx) + default: + ResetUserState(user, bot) + return ctx, errors.Create(errors.InvalidSyntaxError) + } + return ctx, nil +} diff --git a/internal/telegram/ticket.go b/internal/telegram/ticket.go new file mode 100644 index 00000000..5d556921 --- /dev/null +++ b/internal/telegram/ticket.go @@ -0,0 +1,245 @@ +package telegram + +import ( + "bytes" + "encoding/json" + "fmt" + "strconv" + "time" + + "github.com/LightningTipBot/LightningTipBot/internal" + "github.com/LightningTipBot/LightningTipBot/internal/errors" + "github.com/LightningTipBot/LightningTipBot/internal/lnbits" + "github.com/LightningTipBot/LightningTipBot/internal/runtime" + "github.com/LightningTipBot/LightningTipBot/internal/storage" + "github.com/LightningTipBot/LightningTipBot/internal/telegram/intercept" + "github.com/LightningTipBot/LightningTipBot/internal/utils" + log "github.com/sirupsen/logrus" + "github.com/skip2/go-qrcode" + "github.com/tidwall/buntdb" + tb "gopkg.in/lightningtipbot/telebot.v3" +) + +var defaultTicketDuration = time.Minute * 15 + +// handleTelegramNewMember is invoked when users join groups. +// handler will create a new invoice and send it to the group chat. +// a ticket callback timer function is stored in the blunt db. +func (bot *TipBot) handleTelegramNewMember(ctx intercept.Context) (intercept.Context, error) { + id := strconv.FormatInt(ctx.Chat().ID, 10) + group, err := bot.loadGroup(id) + if err != nil { + return ctx, err + } + if !bot.isAdmin(ctx.Chat(), bot.Telegram.Me) { + log.Traceln("[TICKET] I am not an admin of this group") + return ctx, fmt.Errorf("no admin rights") + } + user := LoadUser(ctx) + // if the user does not have an account we put his Telegram user here + // because we will need it after the invoice callback has been triggered in stopJoinTicketTimer + if user == nil { + user = &lnbits.User{ + ID: "", + Telegram: ctx.Message().Sender, + } + } + + ownerUser, err := GetUser(group.Owner, *bot) + if err != nil { + log.Errorln("[TICKET] Error: no owner found") + return ctx, err + } + ticket := JoinTicket{ + Sender: ctx.Sender(), + Ticket: group.Ticket, + } + // group owner creates invoice + invoice, err := ownerUser.Wallet.Invoice( + lnbits.InvoiceParams{ + Out: false, + Amount: ticket.Ticket.Price, + Memo: ticket.Ticket.Memo, + Webhook: internal.GetWebhookURL()}, + bot.Client) + if err != nil { + errmsg := fmt.Sprintf("[handleTelegramNewMember] Could not create an invoice: %s", err.Error()) + log.Errorln(errmsg) + return ctx, err + } + + // send the owner invoice to chat + // TicketEvent is used for callback button (if user has balance) + ticketEvent := TicketEvent{ + // invoice event is used for invoice callbacks (do not kick if invoice is paid) + InvoiceEvent: &InvoiceEvent{ + Base: storage.New(storage.ID(fmt.Sprintf("invoice-event:%s", id))), + Invoice: &Invoice{PaymentHash: invoice.PaymentHash, + PaymentRequest: invoice.PaymentRequest, + Amount: ticket.Ticket.Price, + Memo: ticket.Ticket.Memo}, + Payer: user, + Chat: ctx.Chat(), + Callback: InvoiceCallbackPayJoinTicket, + CallbackData: "", + LanguageCode: ctx.Value("publicLanguageCode").(string), + }, + Group: group, + Base: storage.New(storage.ID(fmt.Sprintf("ticket-event:%s", id))), + } + captionText := fmt.Sprintf("⚠️ %s, this group requires you to pay *%s* to join. You have 15 minutes to pay or you will be kicked for one day.", GetUserStrMd(ctx.Message().Sender), utils.FormatSats(ticket.Ticket.Price)) + + var balance int64 = 0 + if user.ID != "" { + // if the user has an account + // // if the user has enough balance, we send him a payment button + balance, err = bot.GetUserBalance(user) + if err != nil { + errmsg := fmt.Sprintf("[/group] Error: Could not get user balance: %s", err.Error()) + log.Errorln(errmsg) + bot.trySendMessage(ctx.Message().Sender, Translate(ctx, "errorTryLaterMessage")) + return ctx, errors.New(errors.GetBalanceError, err) + } + } else { + balance = 0 + } + + var msg *tb.Message + if balance >= group.Ticket.Price { + _, menu := bot.getSendPayButton(ctx, ticketEvent) + msg = bot.trySendMessageEditable(ctx.Chat(), captionText, menu) + } else { + // create qr code + qr, err := qrcode.Encode(invoice.PaymentRequest, qrcode.Medium, 256) + if err != nil { + return ctx, err + } + entryMessage := &tb.Photo{ + File: tb.File{FileReader: bytes.NewReader(qr)}, + Caption: captionText} + entryMessage.Caption = fmt.Sprintf("%s\n\n`%s`", entryMessage.Caption, invoice.PaymentRequest) + msg = bot.trySendMessageEditable(ctx.Chat(), entryMessage) + } + ticketEvent.Message = msg + ticket.Message = msg + ticket.CreatedTimestamp = time.Now() + err = bot.Bunt.Set(&ticket) + if err != nil { + return ctx, err + } + // save invoice struct for later use + err = ticketEvent.Set(&ticketEvent, bot.Bunt) + if err != nil { + return ctx, err + } + err = ticketEvent.InvoiceEvent.Set(ticketEvent.InvoiceEvent, bot.Bunt) + if err != nil { + return ctx, err + } + bot.startTicketCallbackFunctionTimer(ticket) + fmt.Println(ctx.Message()) + return ctx, nil +} + +// stopTicketTimer will load the timer function based on the event. +// should stop the ticker timer function and remove ticket from bluntDB. +func (bot *TipBot) stopJoinTicketTimer(event Event) { + ev := event.(*InvoiceEvent) + ticket := JoinTicket{Sender: ev.Payer.Telegram, Message: ev.Message} + err := bot.Bunt.Get(&ticket) + if err != nil { + log.Errorf("[stopJoinTicketTimer] %v", err) + return + } + if commission := getTicketCommission(ticket.Ticket); commission > 0 { + me, err := GetUser(bot.Telegram.Me, *bot) + if err != nil { + log.Errorf("[stopJoinTicketTimer] %v", err) + return + } + invoice, err := me.Wallet.Invoice( + lnbits.InvoiceParams{ + Out: false, + Amount: commission, + Memo: fmt.Sprintf("Ticket %d", ticket.Message.Chat.ID)}, + bot.Client) + if err != nil { + log.Errorf("[stopJoinTicketTimer] %v", err) + return + } + _, err = ticket.Ticket.Creator.Wallet.Pay(lnbits.PaymentParams{Bolt11: invoice.PaymentRequest, Out: true}, bot.Client) + if err != nil { + log.Errorf("[stopJoinTicketTimer] %v", err) + return + } + } + + d := time.Until(time.Now().Add(defaultTicketDuration)) + bot.tryDeleteMessage(ev.Message) + t := runtime.GetFunction(ticket.Key(), runtime.WithDuration(d)) + t.StopChan <- struct{}{} + err = bot.Bunt.Delete(ticket.Key(), &ticket) + if err != nil { + log.Errorf("[stopJoinTicketTimer] %v", err) + } +} + +// startTicketCallbackFunctionTimer will start a ticket which will ban users if the timer runs out. +func (bot *TipBot) startTicketCallbackFunctionTimer(ticket JoinTicket) { + // check if ticket is already expired + if ticket.CreatedTimestamp.Add(defaultTicketDuration).Before(time.Now()) { + return + } + // ticket is valid. create duration until kick + ticketDuration := time.Until(ticket.CreatedTimestamp.Add(defaultTicketDuration)) + // create function timer + t := runtime.NewResettableFunction(ticket.Key(), + runtime.WithTimer(time.NewTimer(ticketDuration))) + // run the ticket callback function + t.Do(func() { + // ticket expired + member, err := bot.Telegram.ChatMemberOf(ticket.Message.Chat, ticket.Sender) + if err != nil { + log.Errorln("🧨 could not fetch / ban chat member") + return + } + // ban user + err = bot.Telegram.Ban(ticket.Message.Chat, member) + if err != nil { + log.Errorln(err) + } + err = bot.Bunt.Delete(ticket.Key(), &ticket) + if err != nil { + log.Errorln(err) + } + bot.tryDeleteMessage(ticket.Message) + }) +} + +// restartPersistedTickets kicks of all ticket timers +func (bot *TipBot) restartPersistedTickets() { + bot.Bunt.View(func(tx *buntdb.Tx) error { + err := tx.Ascend("join-ticket", func(key, value string) bool { + ticket := JoinTicket{} + err := json.Unmarshal([]byte(value), &ticket) + if err != nil { + return true + } + bot.startTicketCallbackFunctionTimer(ticket) + return true // continue iteration + }) + return err + }) +} +func getTicketCommission(ticket *Ticket) int64 { + if ticket.Price < 20 { + return 0 + } + // 2% cut + 100 sat(s) base fee + commissionSat := ticket.Price*ticket.Cut/100 + ticket.BaseFee + if ticket.Price <= 1000 { + // if < 1000, then 10% cut + 10 sat(s) base fee + commissionSat = ticket.Price*ticket.CutCheap/100 + ticket.BaseFeeCheap + } + return commissionSat +} diff --git a/internal/telegram/tip.go b/internal/telegram/tip.go new file mode 100644 index 00000000..65585142 --- /dev/null +++ b/internal/telegram/tip.go @@ -0,0 +1,149 @@ +package telegram + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/LightningTipBot/LightningTipBot/internal/errors" + "github.com/LightningTipBot/LightningTipBot/internal/telegram/intercept" + + "github.com/LightningTipBot/LightningTipBot/internal" + "github.com/LightningTipBot/LightningTipBot/internal/str" + "github.com/LightningTipBot/LightningTipBot/internal/thirdparty" + + "github.com/LightningTipBot/LightningTipBot/internal/i18n" + log "github.com/sirupsen/logrus" + tb "gopkg.in/lightningtipbot/telebot.v3" +) + +func helpTipUsage(ctx context.Context, errormsg string) string { + if len(errormsg) > 0 { + return fmt.Sprintf(Translate(ctx, "tipHelpText"), fmt.Sprintf("%s", errormsg)) + } else { + return fmt.Sprintf(Translate(ctx, "tipHelpText"), "") + } +} + +func TipCheckSyntax(ctx context.Context, m *tb.Message) (bool, string) { + arguments := strings.Split(m.Text, " ") + if len(arguments) < 2 { + return false, Translate(ctx, "tipEnterAmountMessage") + } + return true, "" +} + +func (bot *TipBot) tipHandler(ctx intercept.Context) (intercept.Context, error) { + m := ctx.Message() + // check and print all commands + bot.anyTextHandler(ctx) + user := LoadUser(ctx) + if user.Wallet == nil { + return ctx, fmt.Errorf("user has no wallet") + } + + // only if message is a reply + if !m.IsReply() { + bot.tryDeleteMessage(m) + bot.trySendMessage(m.Sender, helpTipUsage(ctx, Translate(ctx, "tipDidYouReplyMessage"))) + bot.trySendMessage(m.Sender, Translate(ctx, "tipInviteGroupMessage")) + return ctx, errors.Create(errors.NoReplyMessageError) + } + + if ok, err := TipCheckSyntax(ctx, m); !ok { + bot.trySendMessage(m.Sender, helpTipUsage(ctx, err)) + NewMessage(m, WithDuration(0, bot)) + return ctx, errors.Create(errors.InvalidSyntaxError) + } + + // 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)) + bot.trySendMessage(m.Sender, helpTipUsage(ctx, Translate(ctx, "tipValidAmountMessage"))) + log.Warnln(errmsg) + return ctx, errors.Create(errors.InvalidAmountError) + } + + err = bot.parseCmdDonHandler(ctx) + if err == nil { + return ctx, fmt.Errorf("invalid parseCmdDonHandler") + } + // TIP COMMAND IS VALID + from := LoadUser(ctx) + to := LoadReplyToUser(ctx) + + if from.Telegram.ID == to.Telegram.ID { + NewMessage(m, WithDuration(0, bot)) + bot.trySendMessage(m.Sender, Translate(ctx, "tipYourselfMessage")) + return ctx, fmt.Errorf("cannot tip yourself") + } + + toUserStrMd := GetUserStrMd(to.Telegram) + fromUserStrMd := GetUserStrMd(from.Telegram) + toUserStr := GetUserStr(to.Telegram) + fromUserStr := GetUserStr(from.Telegram) + + if _, exists := bot.UserExists(to.Telegram); !exists { + log.Infof("[/tip] User %s has no wallet.", toUserStr) + to, err = bot.CreateWalletForTelegramUser(to.Telegram) + if err != nil { + errmsg := fmt.Errorf("[/tip] Error: Could not create wallet for %s", toUserStr) + log.Errorln(errmsg) + return ctx, fmt.Errorf("could not create wallet for %s", toUserStr) + } + } + + // 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.", fromUserStr, toUserStr) + 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)) + bot.trySendMessage(m.Sender, fmt.Sprintf("%s: %s", Translate(ctx, "tipErrorMessage"), Translate(ctx, "tipUndefinedErrorMsg"))) + errMsg := fmt.Sprintf("[/tip] Transaction failed: %s", err.Error()) + log.Warnln(errMsg) + + // Enhanced error logging for tip transaction failures + if bot.ErrorLogger != nil { + bot.ErrorLogger.LogTransactionError(err, "tip", amount, from.Telegram, to.Telegram) + } + + return ctx, err + } + + // update tooltip if necessary + messageHasTip := tipTooltipHandler(m, bot, amount, to.Initialized) + + log.Infof("[💸 tip] Tip from %s to %s (%d sat).", fromUserStr, toUserStr, amount) + + // notify users + bot.trySendMessage(from.Telegram, fmt.Sprintf(i18n.Translate(from.Telegram.LanguageCode, "tipSentMessage"), thirdparty.FormatSatsWithLKR(amount), toUserStrMd)) + + // forward tipped message to user once + if !messageHasTip { + bot.tryForwardMessage(to.Telegram, m.ReplyTo, tb.Silent) + } + bot.trySendMessage(to.Telegram, fmt.Sprintf(i18n.Translate(to.Telegram.LanguageCode, "tipReceivedMessage"), fromUserStrMd, thirdparty.FormatSatsWithLKR(amount))) + + if len(tipMemo) > 0 { + bot.trySendMessage(to.Telegram, fmt.Sprintf("✉️ %s", str.MarkdownEscape(tipMemo))) + } + // delete the tip message after a few seconds, this is default behaviour + NewMessage(m, WithDuration(time.Second*time.Duration(internal.Configuration.Telegram.MessageDisposeDuration), bot)) + return ctx, nil +} diff --git a/internal/telegram/tipjar.go b/internal/telegram/tipjar.go new file mode 100644 index 00000000..ab493651 --- /dev/null +++ b/internal/telegram/tipjar.go @@ -0,0 +1,380 @@ +package telegram + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/LightningTipBot/LightningTipBot/internal/telegram/intercept" + + "github.com/LightningTipBot/LightningTipBot/internal/runtime/mutex" + "github.com/LightningTipBot/LightningTipBot/internal/storage" + + "github.com/eko/gocache/store" + + "github.com/LightningTipBot/LightningTipBot/internal/errors" + "github.com/LightningTipBot/LightningTipBot/internal/i18n" + "github.com/LightningTipBot/LightningTipBot/internal/lnbits" + "github.com/LightningTipBot/LightningTipBot/internal/thirdparty" + "github.com/LightningTipBot/LightningTipBot/internal/utils" + + log "github.com/sirupsen/logrus" + tb "gopkg.in/lightningtipbot/telebot.v3" +) + +var ( + inlineTipjarMenu = &tb.ReplyMarkup{ResizeKeyboard: false} + btnCancelInlineTipjar = inlineTipjarMenu.Data("🚫", "cancel_tipjar_inline") + btnAcceptInlineTipjar = inlineTipjarMenu.Data("💸 Pay", "confirm_tipjar_inline") +) + +type InlineTipjar struct { + *storage.Base + Message string `json:"inline_tipjar_message"` + Amount int64 `json:"inline_tipjar_amount"` + GivenAmount int64 `json:"inline_tipjar_givenamount"` + PerUserAmount int64 `json:"inline_tipjar_peruseramount"` + To *lnbits.User `json:"inline_tipjar_to"` + From []*lnbits.User `json:"inline_tipjar_from"` + Memo string `json:"inline_tipjar_memo"` + NTotal int `json:"inline_tipjar_ntotal"` + NGiven int `json:"inline_tipjar_ngiven"` + LanguageCode string `json:"languagecode"` +} + +func (bot TipBot) mapTipjarLanguage(ctx context.Context, command string) context.Context { + if len(strings.Split(command, " ")) > 1 { + c := strings.Split(command, " ")[0][1:] // cut the / + ctx = bot.commandTranslationMap(ctx, c) + } + return ctx +} + +func (bot TipBot) createTipjar(ctx context.Context, text string, sender *tb.User) (*InlineTipjar, error) { + amount, err := decodeAmountFromCommand(text) + if err != nil { + return nil, errors.New(errors.DecodeAmountError, err) + } + peruserStr, err := getArgumentFromCommand(text, 2) + if err != nil { + return nil, errors.New(errors.DecodePerUserAmountError, err) + } + perUserAmount, err := GetAmount(peruserStr) + if err != nil { + return nil, errors.New(errors.InvalidAmountError, err) + } + if perUserAmount < 1 || amount%perUserAmount != 0 { + return nil, errors.New(errors.InvalidAmountPerUserError, fmt.Errorf("invalid amount per user")) + } + nTotal := int(amount / perUserAmount) + toUser := LoadUser(ctx) + // toUserStr := GetUserStr(sender) + // // check for memo in command + memo := GetMemoFromCommand(text, 3) + + inlineMessage := fmt.Sprintf( + Translate(ctx, "inlineTipjarMessage"), + perUserAmount, + GetUserStrMd(toUser.Telegram), + 0, + amount, + 0, + MakeTipjarbar(0, amount), + ) + if len(memo) > 0 { + inlineMessage = inlineMessage + fmt.Sprintf(Translate(ctx, "inlineTipjarAppendMemo"), memo) + } + id := fmt.Sprintf("tipjar:%s:%d", RandStringRunes(10), amount) + + return &InlineTipjar{ + Base: storage.New(storage.ID(id)), + Message: inlineMessage, + Amount: amount, + To: toUser, + Memo: memo, + PerUserAmount: perUserAmount, + NTotal: nTotal, + NGiven: 0, + GivenAmount: 0, + LanguageCode: ctx.Value("publicLanguageCode").(string), + }, nil + +} +func (bot TipBot) makeTipjar(ctx context.Context, m *tb.Message, query bool) (*InlineTipjar, error) { + tipjar, err := bot.createTipjar(ctx, m.Text, m.Sender) + if err != nil { + switch err.(errors.TipBotError).Code { + case errors.DecodeAmountError: + bot.trySendMessage(m.Sender, fmt.Sprintf(Translate(ctx, "inlineTipjarHelpText"), Translate(ctx, "inlineTipjarInvalidAmountMessage"))) + bot.tryDeleteMessage(m) + return nil, err + case errors.DecodePerUserAmountError: + bot.trySendMessage(m.Sender, fmt.Sprintf(Translate(ctx, "inlineTipjarHelpText"), "")) + bot.tryDeleteMessage(m) + return nil, err + case errors.InvalidAmountError: + bot.trySendMessage(m.Sender, fmt.Sprintf(Translate(ctx, "inlineTipjarHelpText"), Translate(ctx, "inlineTipjarInvalidAmountMessage"))) + bot.tryDeleteMessage(m) + return nil, err + case errors.InvalidAmountPerUserError: + bot.trySendMessage(m.Sender, fmt.Sprintf(Translate(ctx, "inlineTipjarHelpText"), Translate(ctx, "inlineTipjarInvalidPeruserAmountMessage"))) + bot.tryDeleteMessage(m) + return nil, err + case errors.GetBalanceError: + // log.Errorln(err.Error()) + bot.tryDeleteMessage(m) + return nil, err + case errors.BalanceToLowError: + // log.Errorf(err.Error()) + bot.trySendMessage(m.Sender, Translate(ctx, "inlineSendBalanceLowMessage")) + bot.tryDeleteMessage(m) + return nil, err + } + } + return tipjar, err +} + +func (bot TipBot) makeQueryTipjar(ctx intercept.Context) (*InlineTipjar, error) { + tipjar, err := bot.createTipjar(ctx, ctx.Query().Text, ctx.Query().Sender) + if err != nil { + switch err.(errors.TipBotError).Code { + case errors.DecodeAmountError: + bot.inlineQueryReplyWithError(ctx, TranslateUser(ctx, "inlineQueryTipjarTitle"), fmt.Sprintf(TranslateUser(ctx, "inlineQueryTipjarDescription"), bot.Telegram.Me.Username)) + return nil, err + case errors.DecodePerUserAmountError: + bot.inlineQueryReplyWithError(ctx, TranslateUser(ctx, "inlineQueryTipjarTitle"), fmt.Sprintf(TranslateUser(ctx, "inlineQueryTipjarDescription"), bot.Telegram.Me.Username)) + return nil, err + case errors.InvalidAmountError: + bot.inlineQueryReplyWithError(ctx, TranslateUser(ctx, "inlineSendInvalidAmountMessage"), fmt.Sprintf(TranslateUser(ctx, "inlineQueryTipjarDescription"), bot.Telegram.Me.Username)) + return nil, err + case errors.InvalidAmountPerUserError: + bot.inlineQueryReplyWithError(ctx, TranslateUser(ctx, "inlineTipjarInvalidPeruserAmountMessage"), fmt.Sprintf(TranslateUser(ctx, "inlineQueryTipjarDescription"), bot.Telegram.Me.Username)) + return nil, err + case errors.GetBalanceError: + bot.inlineQueryReplyWithError(ctx, TranslateUser(ctx, "inlineQueryTipjarTitle"), fmt.Sprintf(TranslateUser(ctx, "inlineQueryTipjarDescription"), bot.Telegram.Me.Username)) + return nil, err + case errors.BalanceToLowError: + log.Errorf(err.Error()) + bot.inlineQueryReplyWithError(ctx, TranslateUser(ctx, "inlineSendBalanceLowMessage"), fmt.Sprintf(TranslateUser(ctx, "inlineQueryTipjarDescription"), bot.Telegram.Me.Username)) + return nil, err + } + } + return tipjar, err +} + +func (bot TipBot) makeTipjarKeyboard(ctx context.Context, inlineTipjar *InlineTipjar) *tb.ReplyMarkup { + inlineTipjarMenu := &tb.ReplyMarkup{ResizeKeyboard: true} + // slice of buttons + buttons := make([]tb.Btn, 0) + cancelInlineTipjarButton := inlineTipjarMenu.Data(Translate(ctx, "cancelButtonMessage"), "cancel_tipjar_inline", inlineTipjar.ID) + buttons = append(buttons, cancelInlineTipjarButton) + acceptInlineTipjarButton := inlineTipjarMenu.Data(Translate(ctx, "payReceiveButtonMessage"), "confirm_tipjar_inline", inlineTipjar.ID) + buttons = append(buttons, acceptInlineTipjarButton) + + inlineTipjarMenu.Inline( + inlineTipjarMenu.Row(buttons...)) + return inlineTipjarMenu +} + +func (bot TipBot) tipjarHandler(ctx intercept.Context) (intercept.Context, error) { + m := ctx.Message() + bot.anyTextHandler(ctx) + if m.Private() { + bot.trySendMessage(m.Sender, fmt.Sprintf(Translate(ctx, "inlineTipjarHelpText"), Translate(ctx, "inlineTipjarHelpTipjarInGroup"))) + return ctx, errors.Create(errors.NoPrivateChatError) + } + ctx.Context = bot.mapTipjarLanguage(ctx, m.Text) + inlineTipjar, err := bot.makeTipjar(ctx, m, false) + if err != nil { + log.Errorf("[tipjar] %s", err.Error()) + return ctx, err + } + toUserStr := GetUserStr(m.Sender) + bot.trySendMessage(m.Chat, inlineTipjar.Message, bot.makeTipjarKeyboard(ctx, inlineTipjar)) + log.Infof("[tipjar] %s created tipjar %s: %d sat (%d per user)", toUserStr, inlineTipjar.ID, inlineTipjar.Amount, inlineTipjar.PerUserAmount) + return ctx, inlineTipjar.Set(inlineTipjar, bot.Bunt) +} + +func (bot TipBot) handleInlineTipjarQuery(ctx intercept.Context) (intercept.Context, error) { + q := ctx.Query() + inlineTipjar, err := bot.makeQueryTipjar(ctx) + if err != nil { + // log.Errorf("[tipjar] %s", err.Error()) + return ctx, err + } + urls := []string{ + queryImage, + } + results := make(tb.Results, len(urls)) // []tb.Result + for i, url := range urls { + result := &tb.ArticleResult{ + // URL: url, + Text: inlineTipjar.Message, + Title: fmt.Sprintf(TranslateUser(ctx, "inlineResultTipjarTitle"), thirdparty.FormatSatsWithLKR(inlineTipjar.Amount)), + Description: TranslateUser(ctx, "inlineResultTipjarDescription"), + // required for photos + ThumbURL: url, + } + result.ReplyMarkup = &tb.ReplyMarkup{InlineKeyboard: bot.makeTipjarKeyboard(ctx, inlineTipjar).InlineKeyboard} + results[i] = result + // needed to set a unique string ID for each result + results[i].SetResultID(inlineTipjar.ID) + + bot.Cache.Set(inlineTipjar.ID, inlineTipjar, &store.Options{Expiration: 5 * time.Minute}) + log.Infof("[tipjar] %s created inline tipjar %s: %d sat (%d per user)", GetUserStr(inlineTipjar.To.Telegram), inlineTipjar.ID, inlineTipjar.Amount, inlineTipjar.PerUserAmount) + } + + err = bot.Telegram.Answer(q, &tb.QueryResponse{ + Results: results, + CacheTime: 1, + IsPersonal: true, + }) + if err != nil { + log.Errorln(err) + return ctx, err + } + return ctx, nil +} + +func (bot *TipBot) acceptInlineTipjarHandler(ctx intercept.Context) (intercept.Context, error) { + c := ctx.Callback() + from := LoadUser(ctx) + if from.Wallet == nil { + return ctx, errors.Create(errors.UserNoWalletError) + } + tx := &InlineTipjar{Base: storage.New(storage.ID(c.Data))} + mutex.LockWithContext(ctx, tx.ID) + defer mutex.UnlockWithContext(ctx, tx.ID) + fn, err := tx.Get(tx, bot.Bunt) + if err != nil { + // log.Errorf("[tipjar] %s", err.Error()) + return ctx, err + } + inlineTipjar := fn.(*InlineTipjar) + to := inlineTipjar.To + if !inlineTipjar.Active { + log.Errorf(fmt.Sprintf("[tipjar] tipjar %s inactive.", inlineTipjar.ID)) + bot.tryEditMessage(c, i18n.Translate(inlineTipjar.LanguageCode, "inlineTipjarCancelledMessage"), &tb.ReplyMarkup{}) + return ctx, errors.Create(errors.NotActiveError) + } + + if from.Telegram.ID == to.Telegram.ID { + bot.trySendMessage(from.Telegram, Translate(ctx, "sendYourselfMessage")) + return ctx, errors.Create(errors.SelfPaymentError) + } + // // check if to user has already given to the tipjar + for _, a := range inlineTipjar.From { + if a.Telegram.ID == from.Telegram.ID { + // to user is already in To slice, has taken from facuet + // log.Infof("[tipjar] %s already gave to tipjar %s", GetUserStr(to.Telegram), inlineTipjar.ID) + return ctx, errors.Create(errors.UnknownError) + } + } + + defer inlineTipjar.Set(inlineTipjar, bot.Bunt) + + if inlineTipjar.GivenAmount < inlineTipjar.Amount { + toUserStrMd := GetUserStrMd(to.Telegram) + fromUserStrMd := GetUserStrMd(from.Telegram) + toUserStr := GetUserStr(to.Telegram) + fromUserStr := GetUserStr(from.Telegram) + + // todo: user new get username function to get userStrings + transactionMemo := fmt.Sprintf("🍯 Tipjar from %s to %s.", fromUserStr, toUserStr) + t := NewTransaction(bot, from, to, inlineTipjar.PerUserAmount, TransactionType("tipjar")) + t.Memo = transactionMemo + + success, err := t.Send() + if !success { + bot.trySendMessage(from.Telegram, Translate(ctx, "sendErrorMessage")) + errMsg := fmt.Sprintf("[tipjar] Transaction failed: %s", err.Error()) + log.Errorln(errMsg) + return ctx, errors.New(errors.UnknownError, err) + } + + log.Infof("[💸 tipjar] Tipjar %s from %s to %s (%d sat).", inlineTipjar.ID, fromUserStr, toUserStr, inlineTipjar.PerUserAmount) + inlineTipjar.NGiven += 1 + inlineTipjar.From = append(inlineTipjar.From, from) + inlineTipjar.GivenAmount = inlineTipjar.GivenAmount + inlineTipjar.PerUserAmount + + bot.trySendMessage(to.Telegram, fmt.Sprintf(i18n.Translate(to.Telegram.LanguageCode, "inlineTipjarReceivedMessage"), fromUserStrMd, thirdparty.FormatSatsWithLKR(inlineTipjar.PerUserAmount))) + bot.trySendMessage(from.Telegram, fmt.Sprintf(i18n.Translate(from.Telegram.LanguageCode, "inlineTipjarSentMessage"), thirdparty.FormatSatsWithLKR(inlineTipjar.PerUserAmount), toUserStrMd)) + if err != nil { + errmsg := fmt.Errorf("[tipjar] Error: Send message to %s: %s", toUserStr, err) + log.Warnln(errmsg) + } + + // build tipjar message + inlineTipjar.Message = fmt.Sprintf( + i18n.Translate(inlineTipjar.LanguageCode, "inlineTipjarMessage"), + thirdparty.FormatSatsWithLKR(inlineTipjar.PerUserAmount), + GetUserStrMd(inlineTipjar.To.Telegram), + thirdparty.FormatSatsWithLKR(inlineTipjar.GivenAmount), + thirdparty.FormatSatsWithLKR(inlineTipjar.Amount), + inlineTipjar.NGiven, + MakeTipjarbar(inlineTipjar.GivenAmount, inlineTipjar.Amount), + ) + memo := inlineTipjar.Memo + if len(memo) > 0 { + inlineTipjar.Message = inlineTipjar.Message + fmt.Sprintf(i18n.Translate(inlineTipjar.LanguageCode, "inlineTipjarAppendMemo"), memo) + } + // update message + log.Infoln(inlineTipjar.Message) + bot.tryEditMessage(c, inlineTipjar.Message, bot.makeTipjarKeyboard(ctx, inlineTipjar)) + } + if inlineTipjar.GivenAmount >= inlineTipjar.Amount { + // tipjar is full + inlineTipjar.Message = fmt.Sprintf( + i18n.Translate(inlineTipjar.LanguageCode, "inlineTipjarEndedMessage"), + GetUserStrMd(inlineTipjar.To.Telegram), + thirdparty.FormatSatsWithLKR(inlineTipjar.Amount), + inlineTipjar.NGiven, + ) + bot.tryEditMessage(c, inlineTipjar.Message) + // send update to tipjar creator + if inlineTipjar.Active && inlineTipjar.To.Telegram.ID != 0 { + bot.trySendMessage(inlineTipjar.To.Telegram, listTipjarGivers(inlineTipjar)) + } + inlineTipjar.Active = false + } + return ctx, nil + +} + +func (bot *TipBot) cancelInlineTipjarHandler(ctx intercept.Context) (intercept.Context, error) { + c := ctx.Callback() + tx := &InlineTipjar{Base: storage.New(storage.ID(c.Data))} + mutex.LockWithContext(ctx, tx.ID) + defer mutex.UnlockWithContext(ctx, tx.ID) + fn, err := tx.Get(tx, bot.Bunt) + if err != nil { + log.Errorf("[cancelInlineTipjarHandler] %s", err.Error()) + return ctx, err + } + inlineTipjar := fn.(*InlineTipjar) + if c.Sender.ID != inlineTipjar.To.Telegram.ID { + return ctx, errors.Create(errors.UnknownError) + } + bot.tryEditMessage(c, i18n.Translate(inlineTipjar.LanguageCode, "inlineTipjarCancelledMessage"), &tb.ReplyMarkup{}) + + // send update to tipjar creator + if inlineTipjar.Active && inlineTipjar.To.Telegram.ID != 0 { + bot.trySendMessage(inlineTipjar.To.Telegram, listTipjarGivers(inlineTipjar)) + } + + // set the inlineTipjar inactive + inlineTipjar.Active = false + return ctx, inlineTipjar.Set(inlineTipjar, bot.Bunt) +} + +func listTipjarGivers(inlineTipjar *InlineTipjar) string { + var from_str string + from_str = fmt.Sprintf("🍯 *Tipjar summary*\n\nMemo: %s\nCapacity: %s\nGivers: %d\nCollected: %s\n\n*Givers:*\n\n", inlineTipjar.Memo, utils.FormatSats(inlineTipjar.Amount), inlineTipjar.NGiven, utils.FormatSats(inlineTipjar.GivenAmount)) + from_str += "```\n" + for _, from := range inlineTipjar.From { + from_str += fmt.Sprintf("%s\n", GetUserStr(from.Telegram)) + } + from_str += "```" + return from_str +} diff --git a/tooltip.go b/internal/telegram/tooltip.go similarity index 60% rename from tooltip.go rename to internal/telegram/tooltip.go index 8083530e..83f79ab5 100644 --- a/tooltip.go +++ b/internal/telegram/tooltip.go @@ -1,4 +1,4 @@ -package main +package telegram import ( "encoding/json" @@ -8,13 +8,13 @@ import ( "time" "github.com/LightningTipBot/LightningTipBot/internal/runtime" - "github.com/LightningTipBot/LightningTipBot/internal/storage" "github.com/tidwall/buntdb" "github.com/tidwall/gjson" + "github.com/LightningTipBot/LightningTipBot/internal/utils" log "github.com/sirupsen/logrus" - tb "gopkg.in/tucnak/telebot.v2" + tb "gopkg.in/lightningtipbot/telebot.v3" ) const ( @@ -22,22 +22,27 @@ const ( tooltipAndOthersMessage = " ... and others" tooltipMultipleTipsMessage = "%s (%d tips by %s)" tooltipSingleTipMessage = "%s (by %s)" - tooltipTipAmountMessage = "🏅 %d sat" + tooltipTipAmountMessage = "🏅 %s" ) type TipTooltip struct { Message - TipAmount int `json:"tip_amount"` + ID string `json:"id"` + TipAmount int64 `json:"tip_amount"` Ntips int `json:"ntips"` LastTip time.Time `json:"last_tip"` Tippers []*tb.User `json:"tippers"` } +func (ttt TipTooltip) Key() string { + return fmt.Sprintf("tip-tool-tip:%s", ttt.ID) +} + const maxNamesInTipperMessage = 5 type TipTooltipOption func(m *TipTooltip) -func TipAmount(amount int) TipTooltipOption { +func TipAmount(amount int64) TipTooltipOption { return func(m *TipTooltip) { m.TipAmount = amount } @@ -51,6 +56,7 @@ func Tips(nTips int) TipTooltipOption { func NewTipTooltip(m *tb.Message, opts ...TipTooltipOption) *TipTooltip { tipTooltip := &TipTooltip{ + ID: fmt.Sprintf("%d-%d", m.Chat.ID, m.ReplyTo.ID), Message: Message{ Message: m, }, @@ -65,7 +71,7 @@ func NewTipTooltip(m *tb.Message, opts ...TipTooltipOption) *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(tooltipTipAmountMessage, ttt.TipAmount) + tipToolTipMessage := fmt.Sprintf(tooltipTipAmountMessage, utils.FormatSats(ttt.TipAmount)) if len(ttt.Tippers) > 1 { tipToolTipMessage = fmt.Sprintf(tooltipMultipleTipsMessage, tipToolTipMessage, ttt.Ntips, tippersStr) } else { @@ -78,7 +84,7 @@ func (ttt TipTooltip) getUpdatedTipTooltipMessage(botUserName string, notInitial return tipToolTipMessage } -// getTippersString joins all tippers username or telegram id's as mentions (@username or [inline mention of a user](tg://user?id=123456789)) +// 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 { @@ -100,9 +106,9 @@ func getTippersString(tippers []*tb.User) string { } // 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) +func tipTooltipExists(m *tb.Message, bot *TipBot) (bool, *TipTooltip) { + message := NewTipTooltip(&tb.Message{Chat: &tb.Chat{ID: m.Chat.ID}, ReplyTo: &tb.Message{ID: m.ReplyTo.ID}}) + err := bot.Bunt.Get(message) if err != nil { return false, message } @@ -111,66 +117,65 @@ func tipTooltipExists(replyToId int, bot *TipBot) (bool, *TipTooltip) { } // 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) { +func tipTooltipHandler(m *tb.Message, bot *TipBot, amount int64, 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) + hasTip, ttt := tipTooltipExists(m, bot) + log.Debugf("[tip] %s has tip: %t", ttt.ID, hasTip) if hasTip { // update the tooltip with new tippers err := ttt.updateTooltip(bot, m.Sender, amount, !initializedWallet) if err != nil { - log.Println(err) + log.Errorln(err) // could not update the message (return false to ) return false } } else { - tipmsg := fmt.Sprintf(tooltipTipAmountMessage, amount) - userStr := GetUserStrMd(m.Sender) - tipmsg = fmt.Sprintf(tooltipSingleTipMessage, tipmsg, userStr) - - if !initializedWallet { - tipmsg = tipmsg + fmt.Sprintf("\n%s", fmt.Sprintf(tooltipChatWithBotMessage, 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)) + newToolTip(m, bot, amount, initializedWallet) } // 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 { +func newToolTip(m *tb.Message, bot *TipBot, amount int64, initializedWallet bool) { + tipmsg := fmt.Sprintf(tooltipTipAmountMessage, utils.FormatSats(amount)) + userStr := GetUserStrMd(m.Sender) + tipmsg = fmt.Sprintf(tooltipSingleTipMessage, tipmsg, userStr) + + if !initializedWallet { + tipmsg = tipmsg + fmt.Sprintf("\n%s", fmt.Sprintf(tooltipChatWithBotMessage, GetUserStrMd(bot.Telegram.Me))) + } + msg := bot.tryReplyMessage(m.ReplyTo, tipmsg, tb.Silent) + message := NewTipTooltip(msg, TipAmount(amount), Tips(1)) + message.Tippers = appendUinqueUsersToSlice(message.Tippers, m.Sender) + runtime.IgnoreError(bot.Bunt.Set(message)) + log.Debugf("[newToolTip]: New reply message: %d (Bunt: %s)", msg.ID, message.Key()) +} + +// updateToolTip updates existing tip tool tip in Telegram +func (ttt *TipTooltip) updateTooltip(bot *TipBot, user *tb.User, amount int64, 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) + ttt.editTooltip(bot, notInitializedWallet) + log.Debugf("[updateTooltip]: Update tip tooltip (Bunt: %s)", ttt.Key()) + 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.Debugln("loading persistent tip tool tip messages") + runtime.IgnoreError(bot.Bunt.View(func(tx *buntdb.Tx) error { + err := tx.Ascend(MessageOrderedByReplyToFrom, func(key, value string) bool { + replyToUserId := gjson.Get(value, MessageOrderedByReplyToFrom) + if replyToUserId.String() == strconv.FormatInt(user.ID, 10) { + log.Debugln("[tipTooltipInitializedHandler] loading persistent 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()) - } + // edit to remove the "chat with bot" message + ttt.editTooltip(&bot, false) } return true @@ -179,17 +184,9 @@ func tipTooltipInitializedHandler(user *tb.User, bot TipBot) { })) } -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 +func (ttt *TipTooltip) editTooltip(bot *TipBot, notInitializedWallet bool) { + tipToolTip := ttt.getUpdatedTipTooltipMessage(GetUserStrMd(bot.Telegram.Me), notInitializedWallet) + bot.tryEditMessage(ttt.Message.Message, tipToolTip) + // ttt.Message.Message.Text = m.Text } diff --git a/tooltip_test.go b/internal/telegram/tooltip_test.go similarity index 89% rename from tooltip_test.go rename to internal/telegram/tooltip_test.go index 7fe89929..e24eba08 100644 --- a/tooltip_test.go +++ b/internal/telegram/tooltip_test.go @@ -1,10 +1,10 @@ -package main +package telegram import ( "testing" "time" - tb "gopkg.in/tucnak/telebot.v2" + tb "gopkg.in/lightningtipbot/telebot.v3" ) var ( @@ -45,7 +45,7 @@ func Test_getTippersString(t *testing.T) { func TestMessage_getTooltipMessage(t *testing.T) { type fields struct { Message Message - TipAmount int + TipAmount int64 Ntips int LastTip time.Time Tippers []*tb.User @@ -66,13 +66,13 @@ func TestMessage_getTooltipMessage(t *testing.T) { 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.", + want: "🏅 10 sat(s) (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.", + want: "🏅 100 sat(s) (6 tips by @username1, @username2, @username3, @username4, @username5, ... and others)\n🗑 Chat with @test-bot to manage your wallet.", }, } for _, tt := range tests { diff --git a/internal/telegram/transaction.go b/internal/telegram/transaction.go new file mode 100644 index 00000000..a5a1af1e --- /dev/null +++ b/internal/telegram/transaction.go @@ -0,0 +1,147 @@ +package telegram + +import ( + "fmt" + "time" + + log "github.com/sirupsen/logrus" + + "github.com/LightningTipBot/LightningTipBot/internal/lnbits" + tb "gopkg.in/lightningtipbot/telebot.v3" +) + +type Transaction struct { + ID uint `gorm:"primarykey"` + Time time.Time `json:"time"` + Bot *TipBot `gorm:"-"` + From *lnbits.User `json:"from" gorm:"-"` + To *lnbits.User `json:"to" gorm:"-"` + FromId int64 `json:"from_id" ` + ToId int64 `json:"to_id" ` + FromUser string `json:"from_user"` + ToUser string `json:"to_user"` + Type string `json:"type"` + Amount int64 `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"` + Invoice lnbits.Invoice `gorm:"embedded;embeddedPrefix:invoice_"` +} + +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 *lnbits.User, to *lnbits.User, amount int64, opts ...TransactionOption) *Transaction { + t := &Transaction{ + Bot: bot, + From: from, + To: to, + FromUser: GetUserStr(from.Telegram), + ToUser: GetUserStr(to.Telegram), + FromId: from.Telegram.ID, + ToId: to.Telegram.ID, + Amount: amount, + Memo: "Powered by @BitcoinDeepaBot", + Time: time.Now(), + Success: false, + } + for _, opt := range opts { + opt(t) + } + return t + +} + +func (t *Transaction) Send() (success bool, err error) { + success, err = t.SendTransaction(t.Bot, t.From, t.To, t.Amount, t.Memo) + if success { + t.Success = success + } + + // save transaction to db + tx := t.Bot.DB.Transactions.Save(t) + if tx.Error != nil { + errMsg := fmt.Sprintf("Error: Could not log transaction: %s", err.Error()) + log.Errorln(errMsg) + } + return success, err +} + +func (t *Transaction) SendTransaction(bot *TipBot, from *lnbits.User, to *lnbits.User, amount int64, memo string) (bool, error) { + fromUserStr := GetUserStr(from.Telegram) + toUserStr := GetUserStr(to.Telegram) + + t.FromWallet = from.Wallet.ID + t.FromLNbitsID = from.ID + + // check if fromUser has available balance (wallet balance - pot balance) + balance, err := bot.GetUserAvailableBalance(from) + if err != nil { + errmsg := fmt.Sprintf("could not get available balance of user %s", fromUserStr) + log.Errorln(errmsg) + return false, err + } + // check if fromUser has sufficient available balance + if balance < amount { + errmsg := fmt.Sprintf("available balance too low.") + log.Warnf("Available balance of user %s too low", fromUserStr) + return false, fmt.Errorf(errmsg) + } + + t.ToWallet = to.ID + t.ToLNbitsID = to.ID + + // generate invoice + invoice, err := to.Wallet.Invoice( + lnbits.InvoiceParams{ + Amount: int64(amount), + Out: false, + Memo: memo}, + bot.Client) + if err != nil { + errmsg := fmt.Sprintf("[Send] Error: Could not create invoice for user %s", toUserStr) + log.Errorln(errmsg) + return false, err + } + t.Invoice = invoice + // pay invoice + _, err = from.Wallet.Pay(lnbits.PaymentParams{Out: true, Bolt11: invoice.PaymentRequest}, bot.Client) + if err != nil { + errmsg := fmt.Sprintf("[Send] Payment failed (%s to %s of %d sat): %s", fromUserStr, toUserStr, amount, err.Error()) + log.Warnf(errmsg) + return false, err + } + + // check if fromUser has 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 + } + _, err = bot.GetUserBalance(to) + if err != nil { + errmsg := fmt.Sprintf("could not get balance of user %s", fromUserStr) + log.Errorln(errmsg) + return false, err + } + + return true, err +} diff --git a/internal/telegram/transactions.go b/internal/telegram/transactions.go new file mode 100644 index 00000000..cac766ea --- /dev/null +++ b/internal/telegram/transactions.go @@ -0,0 +1,174 @@ +package telegram + +import ( + "fmt" + "time" + + "github.com/LightningTipBot/LightningTipBot/internal/lnbits" + "github.com/LightningTipBot/LightningTipBot/internal/str" + "github.com/LightningTipBot/LightningTipBot/internal/telegram/intercept" + "github.com/LightningTipBot/LightningTipBot/internal/utils" + "github.com/eko/gocache/store" + log "github.com/sirupsen/logrus" + tb "gopkg.in/lightningtipbot/telebot.v3" +) + +type TransactionsList struct { + ID string `json:"id"` + User *lnbits.User `json:"from"` + Payments lnbits.Payments `json:"payments"` + LanguageCode string `json:"languagecode"` + CurrentPage int `json:"currentpage"` + MaxPages int `json:"maxpages"` + TxPerPage int `json:"txperpage"` +} + +func (txlist *TransactionsList) printTransactions(ctx intercept.Context) string { + txstr := "" + // for _, p := range payments { + payments := txlist.Payments + pagenr := txlist.CurrentPage + tx_per_page := txlist.TxPerPage + if pagenr > (len(payments)+1)/tx_per_page { + pagenr = 0 + } + if len(payments) < tx_per_page { + tx_per_page = len(payments) + } + start := pagenr * (tx_per_page - 1) + end := start + tx_per_page + if end >= len(payments) { + end = len(payments) - 1 + } + for i := start; i <= end; i++ { + p := payments[i] + if p.Pending { + txstr += "🔄" + } else { + if p.Amount < 0 { + txstr += "🔴" + } else { + txstr += "🟢" + } + } + timestr := time.Unix(int64(p.Time), 0).UTC().Format("2 Jan 06 15:04") + txstr += fmt.Sprintf("` %s`", timestr) + txstr += fmt.Sprintf("` %s`", utils.FormatSats(p.Amount/1000, true)) + // if p.Fee > 0 { + fee := p.Fee + if fee < 1000 { + fee = 1000 + } + txstr += fmt.Sprintf(" _(fee: %s)_", utils.FormatSats(fee/1000)) + // } + memo := p.Memo + memo_maxlen := 50 + if len(memo) > memo_maxlen { + memo = memo[:memo_maxlen] + "..." + } + if len(memo) > 0 { + txstr += fmt.Sprintf("\n✉️ %s", str.MarkdownEscape(memo)) + } + txstr += "\n" + } + txstr += fmt.Sprintf("\nShowing %d transactions. Page %d of %d.", len(payments), txlist.CurrentPage+1, txlist.MaxPages) + return txstr +} + +var ( + transactionsMeno = &tb.ReplyMarkup{ResizeKeyboard: true} + btnLeftTransactionsButton = inlineTipjarMenu.Data("◀️", "left_transactions") + btnRightTransactionsButton = inlineTipjarMenu.Data("▶️", "right_transactions") +) + +func (bot *TipBot) makeTransactionsKeyboard(ctx intercept.Context, txlist TransactionsList) *tb.ReplyMarkup { + leftTransactionsButton := transactionsMeno.Data("←", "left_transactions", txlist.ID) + rightTransactionsButton := transactionsMeno.Data("→", "right_transactions", txlist.ID) + + if txlist.CurrentPage == 0 { + transactionsMeno.Inline( + transactionsMeno.Row( + leftTransactionsButton), + ) + } else if txlist.CurrentPage == txlist.MaxPages-1 { + transactionsMeno.Inline( + transactionsMeno.Row( + rightTransactionsButton), + ) + } else { + transactionsMeno.Inline( + transactionsMeno.Row( + leftTransactionsButton, + rightTransactionsButton), + ) + } + return transactionsMeno +} + +func (bot *TipBot) transactionsHandler(ctx intercept.Context) (intercept.Context, error) { + m := ctx.Message() + user := LoadUser(ctx) + payments, err := bot.Client.Payments(*user.Wallet) + if err != nil { + log.Errorf("[transactions] Error: %s", err.Error()) + return ctx, err + } + tx_per_page := 10 + transactionsList := TransactionsList{ + ID: fmt.Sprintf("txlist:%d:%s", user.Telegram.ID, RandStringRunes(5)), + User: user, + Payments: payments, + LanguageCode: ctx.Value("userLanguageCode").(string), + CurrentPage: 0, + TxPerPage: tx_per_page, + MaxPages: (len(payments)+1)/tx_per_page + 1, + } + bot.Cache.Set(fmt.Sprintf("%s_transactions", user.Name), transactionsList, &store.Options{Expiration: 1 * time.Minute}) + txstr := transactionsList.printTransactions(ctx) + bot.trySendMessage(m.Sender, txstr, bot.makeTransactionsKeyboard(ctx, transactionsList)) + return ctx, nil +} + +func (bot *TipBot) transactionsScrollLeftHandler(ctx intercept.Context) (intercept.Context, error) { + c := ctx.Callback() + user := LoadUser(ctx) + transactionsListInterface, err := bot.Cache.Get(fmt.Sprintf("%s_transactions", user.Name)) + if err != nil { + log.Info("Transactions not in cache anymore") + return ctx, err + } + transactionsList := transactionsListInterface.(TransactionsList) + + if c.Sender.ID == transactionsList.User.Telegram.ID { + if transactionsList.CurrentPage < transactionsList.MaxPages-1 { + transactionsList.CurrentPage++ + } else { + return ctx, err + } + bot.Cache.Set(fmt.Sprintf("%s_transactions", user.Name), transactionsList, &store.Options{Expiration: 1 * time.Minute}) + bot.tryEditMessage(c.Message, transactionsList.printTransactions(ctx), bot.makeTransactionsKeyboard(ctx, transactionsList)) + } + return ctx, nil +} + +func (bot *TipBot) transactionsScrollRightHandler(ctx intercept.Context) (intercept.Context, error) { + c := ctx.Callback() + user := LoadUser(ctx) + transactionsListInterface, err := bot.Cache.Get(fmt.Sprintf("%s_transactions", user.Name)) + if err != nil { + log.Info("Transactions not in cache anymore") + return ctx, err + } + transactionsList := transactionsListInterface.(TransactionsList) + + if c.Sender.ID == transactionsList.User.Telegram.ID { + if transactionsList.CurrentPage > 0 { + transactionsList.CurrentPage-- + } else { + return ctx, nil + } + bot.Cache.Set(fmt.Sprintf("%s_transactions", user.Name), transactionsList, &store.Options{Expiration: 1 * time.Minute}) + bot.tryEditMessage(c.Message, transactionsList.printTransactions(ctx), bot.makeTransactionsKeyboard(ctx, transactionsList)) + } + return ctx, nil +} diff --git a/internal/telegram/translate.go b/internal/telegram/translate.go new file mode 100644 index 00000000..e1cc26cb --- /dev/null +++ b/internal/telegram/translate.go @@ -0,0 +1,24 @@ +package telegram + +import ( + "context" + + "github.com/nicksnyder/go-i18n/v2/i18n" + log "github.com/sirupsen/logrus" +) + +func Translate(ctx context.Context, MessgeID string) string { + str, err := LoadPublicLocalizer(ctx).Localize(&i18n.LocalizeConfig{MessageID: MessgeID}) + if err != nil { + log.Warnf("Error translating message %s: %s", MessgeID, err) + } + return str +} + +func TranslateUser(ctx context.Context, MessgeID string) string { + str, err := LoadUserLocalizer(ctx).Localize(&i18n.LocalizeConfig{MessageID: MessgeID}) + if err != nil { + log.Warnf("Error translating message %s: %s", MessgeID, err) + } + return str +} diff --git a/internal/telegram/users.go b/internal/telegram/users.go new file mode 100644 index 00000000..8746306c --- /dev/null +++ b/internal/telegram/users.go @@ -0,0 +1,166 @@ +package telegram + +import ( + "errors" + "fmt" + "strings" + "time" + + "github.com/LightningTipBot/LightningTipBot/internal/lnbits" + "github.com/LightningTipBot/LightningTipBot/internal/str" + "github.com/eko/gocache/store" + log "github.com/sirupsen/logrus" + tb "gopkg.in/lightningtipbot/telebot.v3" + "gorm.io/gorm" +) + +func SetUserState(user *lnbits.User, bot *TipBot, stateKey lnbits.UserStateKey, stateData string) { + user.StateKey = stateKey + user.StateData = stateData + UpdateUserRecord(user, *bot) + +} + +func ResetUserState(user *lnbits.User, bot *TipBot) { + user.ResetState() + UpdateUserRecord(user, *bot) +} + +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 str.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) GetUserBalanceCached(user *lnbits.User) (amount int64, err error) { + u, err := bot.Cache.Get(fmt.Sprintf("%s_balance", user.Name)) + if err != nil { + return bot.GetUserBalance(user) + } + cachedBalance := u.(int64) + return cachedBalance, nil +} + +func (bot *TipBot) GetUserBalance(user *lnbits.User) (amount int64, err error) { + if user.Wallet == nil { + return 0, errors.New("User has no wallet") + } + + wallet, err := bot.Client.Info(*user.Wallet) + if err != nil { + errmsg := fmt.Sprintf("[GetUserBalance] Error: Couldn't fetch user %s's info from LNbits: %s", GetUserStr(user.Telegram), err.Error()) + log.Errorln(errmsg) + return + } + user.Wallet.Balance = wallet.Balance + err = UpdateUserRecord(user, *bot) + if err != nil { + return + } + // msat to sat + amount = int64(wallet.Balance) / 1000 + log.Debugf("[GetUserBalance] %s's balance: %d sat\n", GetUserStr(user.Telegram), amount) + + // update user balance in cache + bot.Cache.Set( + fmt.Sprintf("%s_balance", user.Name), + amount, + &store.Options{Expiration: 1 * time.Hour}, + ) + return +} + +func (bot *TipBot) GetUserAvailableBalance(user *lnbits.User) (amount int64, err error) { + walletBalance, err := bot.GetUserBalance(user) + if err != nil { + return 0, err + } + + potBalance, err := bot.GetUserTotalPotBalance(user) + if err != nil { + return 0, fmt.Errorf("could not get pot balance: %w", err) + } + + availableBalance := walletBalance - potBalance + if availableBalance < 0 { + availableBalance = 0 + } + + log.Debugf("[GetUserAvailableBalance] %s's available balance: %d sat (wallet: %d, pots: %d)\n", + GetUserStr(user.Telegram), availableBalance, walletBalance, potBalance) + + return availableBalance, nil +} + +func (bot *TipBot) CreateWalletForTelegramUser(tbUser *tb.User) (*lnbits.User, error) { + // failsafe: do not create wallet for existing user + if _, exists := bot.UserExists(tbUser); exists { + return nil, fmt.Errorf("user already exists") + } + user := &lnbits.User{Telegram: tbUser} + 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 user, err + } + // todo: remove this. we're doing this already in bot.createWallet(). + err = UpdateUserRecord(user, *bot) + if err != nil { + return nil, err + } + log.Printf("[CreateWalletForTelegramUser] Wallet created for user %s. ", userStr) + return user, 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 +} + +func (bot *TipBot) UserIsBanned(user *lnbits.User) bool { + // do not respond to banned users + if user.Wallet == nil { + log.Tracef("[UserIsBanned] User %s has no wallet.\n", GetUserStr(user.Telegram)) + return false + } + if strings.HasPrefix(user.Wallet.Adminkey, "banned") || strings.Contains(user.Wallet.Adminkey, "_") { + log.Debugf("[UserIsBanned] User %s is banned. Not responding.", GetUserStr(user.Telegram)) + return true + } + return false +} diff --git a/internal/thirdparty/ceyloncashfx.go b/internal/thirdparty/ceyloncashfx.go new file mode 100644 index 00000000..2ab46078 --- /dev/null +++ b/internal/thirdparty/ceyloncashfx.go @@ -0,0 +1,41 @@ +package thirdparty + +import ( + "encoding/json" + "fmt" + "net/http" + "time" +) + +type ExchangeRateResponse struct { + Description string `json:"description"` + BuyingRate float64 `json:"buying_rate"` + SellingRate float64 `json:"selling_rate"` + ChequeBuyingRate float64 `json:"cheque_buying_rate"` + ChequeSellingRate float64 `json:"cheque_selling_rate"` + TelegraphicTransfersBuyingRate float64 `json:"telegraphic_transfers_buying_rate"` + TelegraphicTransfersSellingRate float64 `json:"telegraphic_transfers_selling_rate"` +} + +// GetUSDToLKRRate fetches USD to LKR exchange rate from Ceylon Cash +func GetUSDToLKRRate() (float64, error) { + url := "https://fx.ceyloncash.com/currency/USD" + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Get(url) + if err != nil { + return 0, fmt.Errorf("failed to fetch exchange rate: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return 0, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + var exchangeResponse ExchangeRateResponse + if err := json.NewDecoder(resp.Body).Decode(&exchangeResponse); err != nil { + return 0, fmt.Errorf("failed to decode exchange response: %v", err) + } + + // Use selling rate as it's typically what you'd pay to get LKR for USD + return exchangeResponse.SellingRate, nil +} diff --git a/internal/thirdparty/coingecko.go b/internal/thirdparty/coingecko.go new file mode 100644 index 00000000..e3ae5edd --- /dev/null +++ b/internal/thirdparty/coingecko.go @@ -0,0 +1,105 @@ +package thirdparty + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + utils "github.com/LightningTipBot/LightningTipBot/internal/utils" +) + +type PriceResponse struct { + Bitcoin struct { + USD float64 `json:"usd"` + } `json:"bitcoin"` +} + +const SATS_PER_BITCOIN = 100_000_000 + +// Caching price for 10 mins +var cache = utils.NewCache(10 * time.Minute) + +// GetSatPrice fetches the current Bitcoin price in USD and LKR exchange rate, then calculates the price per satoshi +func GetSatPrice() (float64, float64, error) { + key := "sat-price" + valueFromCache, hasCache := cache.Get(key) + if hasCache { + parts := strings.Split(valueFromCache, "-") + + LKRPerSat, _ := strconv.ParseFloat(parts[0], 64) + USDPerSat, _ := strconv.ParseFloat(parts[1], 64) + + return LKRPerSat, USDPerSat, nil + } + + // Get Bitcoin price in USD from CoinGecko + bitcoinUSD, err := getBitcoinPriceUSD() + if err != nil { + return 0, 0, fmt.Errorf("failed to fetch bitcoin price: %v", err) + } + + // Get USD to LKR exchange rate from Ceylon Cash + usdToLKR, err := GetUSDToLKRRate() + if err != nil { + return 0, 0, fmt.Errorf("failed to fetch exchange rate: %v", err) + } + + // Calculate Bitcoin price in LKR + bitcoinLKR := bitcoinUSD * usdToLKR + + // Calculate price per sat + LKRPerSat := bitcoinLKR / SATS_PER_BITCOIN + USDPerSat := bitcoinUSD / SATS_PER_BITCOIN + + cache.Set(key, fmt.Sprintf("%f-%f", LKRPerSat, USDPerSat)) + + return LKRPerSat, USDPerSat, nil +} + +// getBitcoinPriceUSD fetches Bitcoin price in USD from CoinGecko +func getBitcoinPriceUSD() (float64, error) { + url := "https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd" + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Get(url) + if err != nil { + return 0, fmt.Errorf("failed to fetch bitcoin price: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return 0, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + var priceResponse PriceResponse + if err := json.NewDecoder(resp.Body).Decode(&priceResponse); err != nil { + return 0, fmt.Errorf("failed to decode response: %v", err) + } + + return priceResponse.Bitcoin.USD, nil +} + +// LKRToSat converts a LKR amount to satoshis using the current price. +func LKRToSat(amount float64) (int64, error) { + lkrPerSat, _, err := GetSatPrice() + if err != nil || lkrPerSat == 0 { + return 0, fmt.Errorf("price unavailable") + } + sats := int64(amount / lkrPerSat) + return sats, nil +} + +// FormatSatsWithLKR formats sats amount with LKR conversion in the format: {amount} sats (රු. {lkr_amount}) +func FormatSatsWithLKR(amount int64) string { + lkrPerSat, _, err := GetSatPrice() + if err != nil { + // Fallback to sats only if LKR price is unavailable + return utils.FormatSats(amount) + } + + lkrValue := lkrPerSat * float64(amount) + return fmt.Sprintf("%s (රු. %s)", utils.FormatSats(amount), utils.FormatFloatWithCommas(lkrValue)) + +} diff --git a/internal/utils/cache.go b/internal/utils/cache.go new file mode 100644 index 00000000..f90db015 --- /dev/null +++ b/internal/utils/cache.go @@ -0,0 +1,68 @@ +package utils + +import ( + "sync" + "time" +) + +type CacheItem struct { + value string + expiration time.Time +} + +type Cache struct { + data map[string]CacheItem + mutex sync.Mutex + ttl time.Duration +} + +func NewCache(ttl time.Duration) *Cache { + return &Cache{ + data: make(map[string]CacheItem), + ttl: ttl, + } +} + +func (c *Cache) Set(key string, value string) { + c.mutex.Lock() + defer c.mutex.Unlock() + c.data[key] = CacheItem{ + value: value, + expiration: time.Now().Add(c.ttl), + } +} + +func (c *Cache) Get(key string) (string, bool) { + c.mutex.Lock() + defer c.mutex.Unlock() + + item, exists := c.data[key] + if !exists || time.Now().After(item.expiration) { + delete(c.data, key) + return "", false + } + return item.value, true +} + +func (c *Cache) Delete(key string) { + c.mutex.Lock() + defer c.mutex.Unlock() + delete(c.data, key) +} + +// SetNX sets the key if it does not exist or has expired. Returns true if set, false if already exists. +func (c *Cache) SetNX(key string, value string) bool { + c.mutex.Lock() + defer c.mutex.Unlock() + + item, exists := c.data[key] + if exists && time.Now().Before(item.expiration) { + return false + } + + c.data[key] = CacheItem{ + value: value, + expiration: time.Now().Add(c.ttl), + } + return true +} diff --git a/internal/utils/html.go b/internal/utils/html.go new file mode 100644 index 00000000..047b66e7 --- /dev/null +++ b/internal/utils/html.go @@ -0,0 +1,15 @@ +package utils + +import "strings" + +// EscapeHTML escapes special HTML characters for safe Telegram HTML message formatting. +func EscapeHTML(text string) string { + replacer := strings.NewReplacer( + "&", "&", + "<", "<", + ">", ">", + "\"", """, + "'", "'", + ) + return replacer.Replace(text) +} diff --git a/internal/utils/number_format.go b/internal/utils/number_format.go new file mode 100644 index 00000000..9a8c7b34 --- /dev/null +++ b/internal/utils/number_format.go @@ -0,0 +1,64 @@ +package utils + +import ( + "fmt" + "strconv" + "strings" +) + +// FormatFloatWithCommas formats a float64 with thousands separators and two decimal digits. +func FormatFloatWithCommas(value float64) string { + negative := value < 0 + if negative { + value = -value + } + s := fmt.Sprintf("%.2f", value) + parts := strings.SplitN(s, ".", 2) + intPart := parts[0] + fracPart := "" + if len(parts) > 1 { + fracPart = parts[1] + } + n := len(intPart) + for i := n - 3; i > 0; i -= 3 { + intPart = intPart[:i] + "," + intPart[i:] + } + if negative { + intPart = "-" + intPart + } + if fracPart != "" { + return intPart + "." + fracPart + } + return intPart +} + +// FormatSats formats an integer satoshi value with thousands separators and includes "sat" or "sats". +// If withPlus is true, a "+" sign is added for positive amounts. +func FormatSats(amount int64, withPlus ...bool) string { + showPlus := false + if len(withPlus) > 0 { + showPlus = withPlus[0] + } + + sign := "" + if amount < 0 { + sign = "-" + amount = -amount + } else if showPlus && amount > 0 { + sign = "+" + } + + s := strconv.FormatInt(amount, 10) + n := len(s) + for i := n - 3; i > 0; i -= 3 { + s = s[:i] + "," + s[i:] + } + + // Add "sat" or "sats" based on the absolute value + unit := " sat" + if amount != 1 { + unit = " sats" + } + + return sign + s + unit +} diff --git a/invoice.go b/invoice.go deleted file mode 100644 index 6ba0c3f6..00000000 --- a/invoice.go +++ /dev/null @@ -1,95 +0,0 @@ -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 []`\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 = GetMemoFromCommand(m.Text, 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 -} diff --git a/link.go b/link.go deleted file mode 100644 index fd378790..00000000 --- a/link.go +++ /dev/null @@ -1,51 +0,0 @@ -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)}) -} diff --git a/lnurl.go b/lnurl.go deleted file mode 100644 index bb487d00..00000000 --- a/lnurl.go +++ /dev/null @@ -1,393 +0,0 @@ -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 = "⌨️ Enter an amount between %d and %d sat." - lnurlHelpText = "📖 Oops, that didn't work. %s\n\n" + - "*Usage:* `/lnurl [amount] `\n" + - "*Example:* `/lnurl LNURL1DP68GUR...`" -) - -// lnurlHandler is invoked on /lnurl command -func (bot TipBot) lnurlHandler(m *tb.Message) { - // commands: - // /lnurl - // /lnurl - // or /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 - } - - SetUserState(user, bot, lnbits.UserStateLNURLEnterAmount, string(paramsJson)) - - bot.tryDeleteMessage(msg) - // Let the user enter an amount and return - bot.trySendMessage(m.Sender, fmt.Sprintf(lnurlEnterAmountMessage, payParams.MinSendable/1000, payParams.MaxSendable/1000), 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 - } - SetUserState(user, bot, lnbits.UserStateConfirmLNURLPay, string(paramsJson)) - 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.Debugf("[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()) - ResetUserState(user, bot) - return - } - if user.StateKey == lnbits.UserStateLNURLEnterAmount { - a, err := strconv.Atoi(m.Text) - if err != nil { - log.Errorln(err) - bot.trySendMessage(m.Sender, lnurlInvalidAmountMessage) - ResetUserState(user, bot) - return - } - amount := int64(a) - var stateResponse LnurlStateResponse - err = json.Unmarshal([]byte(user.StateData), &stateResponse) - if err != nil { - log.Errorln(err) - ResetUserState(user, bot) - 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)) - ResetUserState(user, bot) - return - } - stateResponse.Amount = a - state, err := json.Marshal(stateResponse) - if err != nil { - log.Errorln(err) - ResetUserState(user, bot) - return - } - SetUserState(user, bot, lnbits.UserStateConfirmLNURLPay, string(state)) - 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 -} diff --git a/main.go b/main.go index fbc67ea1..61dc2814 100644 --- a/main.go +++ b/main.go @@ -1,13 +1,34 @@ package main import ( - log "github.com/sirupsen/logrus" + "net/http" "runtime/debug" + "strings" + "time" + + "github.com/LightningTipBot/LightningTipBot/internal" + "github.com/LightningTipBot/LightningTipBot/internal/api" + "github.com/LightningTipBot/LightningTipBot/internal/api/admin" + "github.com/LightningTipBot/LightningTipBot/internal/api/userpage" + "github.com/LightningTipBot/LightningTipBot/internal/lndhub" + "github.com/LightningTipBot/LightningTipBot/internal/lnurl" + "github.com/LightningTipBot/LightningTipBot/internal/nostr" + "github.com/LightningTipBot/LightningTipBot/internal/runtime/mutex" + "github.com/LightningTipBot/LightningTipBot/internal/telegram" + + _ "net/http/pprof" + + tb "gopkg.in/lightningtipbot/telebot.v3" + + "github.com/LightningTipBot/LightningTipBot/internal/lnbits/webhook" + "github.com/LightningTipBot/LightningTipBot/internal/price" + "github.com/LightningTipBot/LightningTipBot/internal/utils" + log "github.com/sirupsen/logrus" ) // setLogger will initialize the log format func setLogger() { - log.SetLevel(log.InfoLevel) + log.SetLevel(log.DebugLevel) customFormatter := new(log.TextFormatter) customFormatter.TimestampFormat = "2006-01-02 15:04:05" customFormatter.FullTimestamp = true @@ -17,14 +38,128 @@ func setLogger() { func main() { // set logger setLogger() - defer withRecovery() - bot := NewBot() + + // Create bot first + bot := telegram.NewBot() + + defer withRecovery(bot.ErrorLogger) + price.NewPriceWatcher().Start() + startApiServer(&bot) bot.Start() } +func startApiServer(bot *telegram.TipBot) { + // log errors from interceptors + bot.Telegram.OnError = func(err error, ctx tb.Context) { + if err == nil { + return + } + + errMsg := err.Error() + + // Filter out empty/ghost errors from telebot (code:0, empty message) + if errMsg == "" || errMsg == `{"message":"","Err":{},"code":0}` { + return + } + + // Filter out annoying interceptor errors + if strings.Contains(errMsg, "[requirePrivateChatInterceptor]") { + return + } + + // Log errors to Telegram group + if bot.ErrorLogger != nil { + userInfo := []interface{}{} + if ctx != nil && ctx.Sender() != nil { + userInfo = append(userInfo, ctx.Sender()) + } + if ctx != nil && ctx.Chat() != nil { + userInfo = append(userInfo, ctx.Chat()) + } + bot.ErrorLogger.LogError(err, "Telegram Bot Error", userInfo...) + } + } + // start internal webhook server + webhook.NewServer(bot) + // start external api server + s := api.NewServer(internal.Configuration.Bot.LNURLServerUrl.Host) + + s.AppendRoute("/health", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) + }, http.MethodGet) + + // append lnurl ctx functions + lnUrl := lnurl.New(bot) + s.AppendRoute("/.well-known/lnurlp/{username}", lnUrl.Handle, http.MethodGet) + // userpage server + userpage := userpage.New(bot) + s.AppendRoute("/@{username}", userpage.UserPageHandler, http.MethodGet) + s.AppendRoute("/app/@{username}", userpage.UserWebAppHandler, http.MethodGet) + + // nostr nip05 identifier + nostr := nostr.New(bot) + s.AppendRoute("/.well-known/nostr.json", nostr.Handle, http.MethodGet) + + // append lndhub ctx functions + hub := lndhub.New(bot) + s.AppendAuthorizedRoute(`/lndhub/ext/auth`, api.AuthTypeNone, api.AccessKeyTypeNone, bot.DB.Users, hub.Handle) + s.AppendAuthorizedRoute(`/lndhub/ext/{.*}`, api.AuthTypeBearerBase64, api.AccessKeyTypeAdmin, bot.DB.Users, hub.Handle) + s.AppendAuthorizedRoute(`/lndhub/ext`, api.AuthTypeBearerBase64, api.AccessKeyTypeAdmin, bot.DB.Users, hub.Handle) + + // starting api service + apiService := api.Service{ + Bot: bot, + MemoCache: utils.NewCache(time.Minute * 5), + } + s.AppendAuthorizedRoute(`/api/v1/paymentstatus/{payment_hash}`, api.AuthTypeBasic, api.AccessKeyTypeInvoice, bot.DB.Users, apiService.PaymentStatus, http.MethodPost) + s.AppendAuthorizedRoute(`/api/v1/invoicestatus/{payment_hash}`, api.AuthTypeBasic, api.AccessKeyTypeInvoice, bot.DB.Users, apiService.InvoiceStatus, http.MethodPost) + s.AppendAuthorizedRoute(`/api/v1/payinvoice`, api.AuthTypeBasic, api.AccessKeyTypeAdmin, bot.DB.Users, apiService.PayInvoice, http.MethodPost) + s.AppendAuthorizedRoute(`/api/v1/invoicestream`, api.AuthTypeBasic, api.AccessKeyTypeInvoice, bot.DB.Users, apiService.InvoiceStream, http.MethodGet) + s.AppendAuthorizedRoute(`/api/v1/createinvoice`, api.AuthTypeBasic, api.AccessKeyTypeInvoice, bot.DB.Users, apiService.CreateInvoice, http.MethodPost) + s.AppendAuthorizedRoute(`/api/v1/balance`, api.AuthTypeBasic, api.AccessKeyTypeInvoice, bot.DB.Users, apiService.Balance, http.MethodGet) + + // Analytics API endpoints (HMAC authenticated) + if internal.IsAPIAnalyticsEnabled() { + s.AppendRoute(`/api/v1/analytics/transactions`, api.AnalyticsHMACMiddleware(apiService.GetTransactionAnalytics), http.MethodGet) + s.AppendRoute(`/api/v1/analytics/user/{user_id}/transactions`, api.AnalyticsHMACMiddleware(apiService.GetUserTransactionHistory), http.MethodGet) + log.Infof("Analytics API endpoints registered with HMAC security") + } else { + log.Infof("Analytics API endpoints disabled in configuration") + } + + // Bot pay HTTP API module with wallet-based HMAC security (only if enabled) + if internal.IsAPISendEnabled() { + s.AppendRoute(`/api/v1/send`, api.WalletHMACMiddleware(apiService.Send), http.MethodPost) + log.Infof("API Send endpoint registered at /api/v1/send with wallet-based HMAC security") + + // User balance endpoint with wallet-based HMAC security + s.AppendRoute(`/api/v1/userbalance`, api.WalletHMACMiddleware(apiService.UserBalance), http.MethodPost) + log.Infof("API UserBalance endpoint registered at /api/v1/userbalance with wallet-based HMAC security") + } else { + log.Infof("API Send endpoint disabled in configuration") + } + + // start internal admin server + adminService := admin.New(bot) + internalAdminServer := api.NewServer(internal.Configuration.Bot.AdminAPIHost) + internalAdminServer.AppendRoute("/mutex", mutex.ServeHTTP) + internalAdminServer.AppendRoute("/mutex/unlock/{id}", mutex.UnlockHTTP) + internalAdminServer.AppendRoute("/admin/ban/{id}", adminService.BanUser) + internalAdminServer.AppendRoute("/admin/unban/{id}", adminService.UnbanUser) + internalAdminServer.AppendRoute("/admin/dalle/enable", adminService.EnableDalle) + internalAdminServer.AppendRoute("/admin/dalle/disable", adminService.DisableDalle) + internalAdminServer.PathPrefix("/debug/pprof/", http.DefaultServeMux) + +} -func withRecovery() { +func withRecovery(errorLogger *telegram.ErrorLogger) { if r := recover(); r != nil { log.Errorln("Recovered panic: ", r) debug.PrintStack() + + // Log to Telegram if error logger is available + if errorLogger != nil { + errorLogger.LogPanic(r, "Main Application") + } } } diff --git a/pay.go b/pay.go deleted file mode 100644 index fd672a71..00000000 --- a/pay.go +++ /dev/null @@ -1,167 +0,0 @@ -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 `\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) - - SetUserState(user, bot, lnbits.UserStateConfirmPayment, paymentRequest) - - // // // 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 - } - ResetUserState(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 - ResetUserState(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 - } - -} diff --git a/photo.go b/photo.go deleted file mode 100644 index b334f16e..00000000 --- a/photo.go +++ /dev/null @@ -1,79 +0,0 @@ -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." - photoQrRecognizedMessage = "✅ QR code:\n`%s`" -) - -// TryRecognizeInvoiceFromQrCode will try to read an invoice string from a qr code and invoke the payment handler. -func TryRecognizeQrCode(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) || lightning.IsLnurl(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, "") - // 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 := TryRecognizeQrCode(img) - if err != nil { - log.Errorf("tryRecognizeQrCodes error: %v\n", err) - bot.trySendMessage(m.Sender, photoQrNotRecognizedMessage) - return - } - - bot.trySendMessage(m.Sender, fmt.Sprintf(photoQrRecognizedMessage, data.String())) - // invoke payment handler - if lightning.IsInvoice(data.String()) { - m.Text = fmt.Sprintf("/pay %s", data.String()) - bot.confirmPaymentHandler(m) - return - } else if lightning.IsLnurl(data.String()) { - m.Text = fmt.Sprintf("/lnurl %s", data.String()) - bot.lnurlHandler(m) - return - } -} diff --git a/pkg/lightning/lightning.go b/pkg/lightning/lightning.go index 2858c7fd..51ee95fb 100644 --- a/pkg/lightning/lightning.go +++ b/pkg/lightning/lightning.go @@ -21,7 +21,7 @@ func IsInvoice(message string) bool { func IsLnurl(message string) bool { message = strings.ToLower(message) - if strings.HasPrefix(message, "lnurl") { + if strings.HasPrefix(message, "lnurl") || strings.HasPrefix(message, "lightning:lnurl") { // string must be a single word if !strings.Contains(message, " ") { return true diff --git a/resources/logo_round.png b/resources/logo_round.png index c587840e..cf07587c 100644 Binary files a/resources/logo_round.png and b/resources/logo_round.png differ diff --git a/resources/send_to_card.png b/resources/send_to_card.png new file mode 100644 index 00000000..ea3b3c86 Binary files /dev/null and b/resources/send_to_card.png differ diff --git a/send.go b/send.go deleted file mode 100644 index 06873c83..00000000 --- a/send.go +++ /dev/null @@ -1,291 +0,0 @@ -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 []`\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 - } - ResetUserState(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 DEMANDS an amount, while /send also works without - // todo: /send 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 := GetMemoFromCommand(m.Text, 3) - - 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 - } - - SetUserState(user, *bot, lnbits.UserStateConfirmSend, sendData) - - 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 - } - ResetUserState(user, *bot) - - // 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 - ResetUserState(user, *bot) - - // 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 -} diff --git a/start.go b/start.go deleted file mode 100644 index d15a8adc..00000000 --- a/start.go +++ /dev/null @@ -1,114 +0,0 @@ -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 -} diff --git a/telegram.go b/telegram.go deleted file mode 100644 index abad6dac..00000000 --- a/telegram.go +++ /dev/null @@ -1,44 +0,0 @@ -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()) - } -} diff --git a/text.go b/text.go deleted file mode 100644 index 5e7c8e94..00000000 --- a/text.go +++ /dev/null @@ -1,49 +0,0 @@ -package main - -import ( - "strings" - - log "github.com/sirupsen/logrus" - - "github.com/LightningTipBot/LightningTipBot/internal/lnbits" - "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 || !user.Initialized { - 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 - } - if lightning.IsLnurl(anyText) { - m.Text = "/lnurl " + anyText - bot.lnurlHandler(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) - } - -} diff --git a/tip.go b/tip.go deleted file mode 100644 index fb0b8f5a..00000000 --- a/tip.go +++ /dev/null @@ -1,153 +0,0 @@ -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 []`\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 -} diff --git a/transaction.go b/transaction.go deleted file mode 100644 index 8cd16251..00000000 --- a/transaction.go +++ /dev/null @@ -1,156 +0,0 @@ -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 -} diff --git a/translations/README.md b/translations/README.md new file mode 100644 index 00000000..5e50afdb --- /dev/null +++ b/translations/README.md @@ -0,0 +1,48 @@ +# Translation guide + +Thank you for helping to translate this bot into many different languages. If you chose to translate this bot, please try to test every possible case that you can think of. As time passes, new features will be added and your translation could become out of date. It would be great, if you could update your language's translation if you notice any weird changes. + +## Quick and dirty summary +* Duplicate `en.toml` to your localization and edit string by string. +* Do not translate commands in the text! I.e. `/balance` stays `/balance`. +* Pay attention to every single `"""` and `%s` or `%d`. +* Your end result should have exactly the same number of lines as `en.toml`. +* Start sentences with `C`apital letters, end them with a full stop`.` + +## General +* The bot checks the language settings of each Telegram user and translate the interaction with the user (private chats, **inline commands?**) to the user's language, if a translation is available. Otherwise, it will default to english. All messages in groups will be english. If the user does not have a language setting, it will default to english. +* For now, all `/commands` are in english. That means that all `/command` references in the help messages should remain english for now. We plan to implement localized commands, which is why you will find the strings in the translation files. Please chose simple, single-worded, lower-case, for the command translations. +* Please use a spell checker, like Google Docs to check your final translation. Thanks :) + +## Language +* Please use a "kind" and "playful" tone in your translations. We do not have to be very dry and technical in tone. Please use a respectful language. +* Please remember who the prototypical user is: a non-technical fruit-selling lady in Brazil that wants to sell Mangos for Satoshis. + +## Standards +* Please "fork" your translation from the english translation file `en.toml`. Simply copy the file, rename it to your language code (look it up on Google if you're unsure) and start editing :) +* Please use only "sat" as a denominator for amounts, do not use the plural form "sats". +* Please choose an appropriate expression for "Amount" and keep it across the entire translation. +* Please reuse all Emojis in the same location and order as the original text. +* Do not add line breaks. All translations should have the same number of lines. +* Please use english words for Bitcoin- and Lightning-native concepts like "Lightning", "Wallet", "Invoice", "Tip", and other technical terms like "Log", "Bot", etc. **IF** your language does not have a widely-used and recognized alternative for it. If most software in your language uses another word instead of "Wallet" for example, then we should also use that. +* For fixed english terms like "Tip" I recommend using the english version and giving a translation in parenthesis like "... Tips (*kleine Beträge*) senden kann". The text in *italic* is the next best translation of "Tips" + + +## Technical +* Every string should be wrapped in three quotes `"""` +* Strings can span over multiple lines. +* Every string variable found in the original english language file should be translated. If a specific string is missing in a translation, the english version will be used for that particular string. +* Every language has their own translation file. The file for english is `en.toml`. + +* Headings to many sections are **bold** starting and ending with asterix `*`. Italic starts and ends with an underscore `_`. +* Command examples are in `code format` starting end ending with ``` ` ``` + +## Pleaceholders +* Symbols like `%s`, `%d`, `%f` are meant as placeholders for other bits of text, numbers, floats. Please reuse them in every string you translate. +* Please do not change the order of the placeholders in your translation. It would break things. +* Please do not use modifiers for **bold**, *italic*, and others around the placeholders. We are using MarkdownV1 and it would break things. Do not do `_%s_` for example. + +## GitHub infos +* Please submit translations as a GitHub pull-request. This way, you can easily work with others and review each other. To submit a pull-request, you need a Github account. Then, fork the entire project (using the button in the upper-right corner). +* Then, create a new branch for your translation. Do this using the Github UI or via the terminal inside the project repository: `git checkout -b translation_es` for example. Then, create the appropriate language file and put it in the translations folder. Then, add it to the branch with by navigating to the translations folder `git add es.toml` and `git commit -m 'add spanish'`. Finally, push the branch to your fork `git push --set-upstream origin translation_es`. When done, open a pull-request in the *original github repo* and select your forked branch. +* Good luck :) diff --git a/translations/cs.toml b/translations/cs.toml new file mode 100644 index 00000000..a6e857bf --- /dev/null +++ b/translations/cs.toml @@ -0,0 +1,410 @@ +# COMMANDS + +helpCommandStr = """pomoc""" +basicsCommandStr = """základy""" +tipCommandStr = """tip""" +balanceCommandStr = """zůstatek""" +sendCommandStr = """poslat""" +invoiceCommandStr = """invoice""" +payCommandStr = """zaplatit""" +donateCommandStr = """podpořit""" +advancedCommandStr = """pokročilé""" +transactionsCommandStr = """transakce""" +logCommandStr = """log""" +listCommandStr = """list""" + +linkCommandStr = """odkaz""" +lnurlCommandStr = """LNURL""" +faucetCommandStr = """faucet""" + +tipjarCommandStr = """tipjar""" +receiveCommandStr = """přijmout""" +hideCommandStr = """skrýt""" +volcanoCommandStr = """sopka""" +showCommandStr = """ukázat""" +optionsCommandStr = """možnosti""" +settingsCommandStr = """nastavení""" +saveCommandStr = """uložit""" +deleteCommandStr = """smazat""" +infoCommandStr = """info""" + +# NOTIFICATIONS + +cantDoThatMessage = """To nelze udělat.""" +cantClickMessage = """Toto tlačítko nejde zmáčkout.""" +balanceTooLowMessage = """Tvůj zůstatek je příliš malý.""" + +# BUTTONS + +sendButtonMessage = """✅ Odeslat""" +payButtonMessage = """✅ Zaplatit""" +payReceiveButtonMessage = """💸 Zaplatit""" +receiveButtonMessage = """✅ Přijmout""" +withdrawButtonMessage = """✅ Vybrat""" +cancelButtonMessage = """🚫 Zrušit""" +collectButtonMessage = """✅ Vybrat""" +nextButtonMessage = """Dále""" +backButtonMessage = """Zpět""" +acceptButtonMessage = """Potvrdit""" +denyButtonMessage = """Odmítnout""" +tipButtonMessage = """Platba""" +revealButtonMessage = """Odhalit""" +showButtonMessage = """Ukázat""" +hideButtonMessage = """Skrýt""" +joinButtonMessage = """Připojit se""" +optionsButtonMessage = """Možnosti""" +settingsButtonMessage = """Nastavení""" +saveButtonMessage = """Uložit""" +deleteButtonMessage = """Smazat""" +infoButtonMessage = """Info""" + +cancelButtonEmoji = """🚫""" +payButtonEmoji = """💸""" + +# HELP + +helpMessage = """⚡️ *Peněženka* +_Tento bot je bitcoinová lightning peněženka, která umožňuje posílat platby na Telegramu. Pro použití přidejte bota do chatu. Základní jednotkou je 1 sat. 100 000 000 sats = 1 bitcoin. Napiš 📚 /basics pro víc info._ + +❤️ *Podpořit* +_Tento bot si neúčtuje žádné poplatky, ale jeho provoz stojí nějaké sats. Pokud se Ti líbí, zvaž prosím podporu tohoto projektu. Pro podporu napiš_ `/donate 1000` + +%s + +⚙️ *Příkazy* +*/tip* 🏅 Odpověz na zprávu, abys poslal tip: `/tip <částka> []` +*/balance* 👑 Zkontroluj svůj zůstatek: `/balance` +*/send* 💸 Poslat platbu uživateli: `/send <částka> @uživatel nebo uživatel@ln.tips []` +*/invoice* ⚡️ Vytvořit lightning (invoice): `/invoice <částka> []` +*/pay* ⚡️ Zaplatit lightning invoice: `/pay ` +*/donate* ❤️ Podpořit projekt: `/donate 1000` +*/advanced* 🤖 Pokročilé funkce. +*/help* 📖 Tato nápověda.""" + +infoHelpMessage = """ℹ️ *Info*""" +infoYourLightningAddress = """Tvoje lightning adresa je `%s`""" + +basicsMessage = """🧡 *Bitcoin* +_Bitcoin jsou internetové peníze. Jsou decentralizované, k jejich použití nepotřebuješ povolení a nemají žádnou centrální autoritu, která by je řídila. Bitcoin jsou zdravé peníze, které jsou na rozdíl od starého finančního systému rychlejší, bezpečnější a pro každého._ + +🧮 *Ekonomnika* +_Nejmenší jednotkou bitcoinu je 1 sat. 100 000 000 sats = 1 bitcoin. Nikdy nebude existovat víc než 21 milionů bitcoinů. Kurz bitcoinu vůči státním měnám se může často a náhle měnit. Nicméně, pokud žijete na bitcoinovém standardu, 1 sat se bude pořád rovnat 1 satu._ + +⚡️ *Lightning Network* +_Lightningová síť je platební protokol který umožňuje rychlé a levné bitcoinové platby nevyžadující téměř žádnou energii. Tato síť škáluje bitcoin pro miliardy lidí na celém světě._ + +📲 *Lightning peněženky* +_Tvoje prostředky z této lightning peněženky můžeš poslat na jakoukoliv jinou a naopak. Doporučené lightning peněženky pro mobil jsou_ [Phoenix](https://phoenix.acinq.co/)_,_ [Breez](https://breez.technology/)_,_ [Muun](https://muun.com/)_ (držíte své klíče), nebo_ [Wallet of Satoshi](https://www.walletofsatoshi.com/) _(jednoduchá, ale klíče od sats nedržíte)_. + +📄 *Open Source* +_Tento bot je volný a otevřený_ [open source](https://github.com/LightningTipBot/LightningTipBot) _software. Můžeš ho běžet na vlastním počítači a používat jej ve vlastní komunitě._ + +✈️ *Telegram* +_Přidej tohoto bota do své skupiny na Telegramu a můžeš posílat /tip za zprávy. Pokud uděláš bota adminem skupiny, bude odstraňovat příkazy, aby zůstal chat přehlednější._ + +🏛 *Podmínky* +_Nejsme správci Tvých prostředků. Budeme konat v Tvém nejlepším zájmu, ale také jsme si vědomi nejisté situace bez KYC, dokud nepřijdeme na něco jiného. Jakákoli částka, kterou si pošleš do peněženky, je proto považována za příspěvek podpory. Neposílej nám všechny svoje peníze. Vezmi v potaz, že bot je v beta fázi vývoje. Používáš ho na vlastní riziko._ + +❤️ *Podpora* +_Tento bot si neúčtuje žádné poplatky, ale jeho provoz stojí nějaké sats. Pokud se Ti líbí, zvaž prosím podporu tohoto projektu. Pro podporu napiš_ `/donate 1000`""" + +helpNoUsernameMessage = """👋 Prosím, nastav si uživatelské jméno na Telegramu.""" + +advancedMessage = """%s + +👉 *Inline příkazy* +*send* 💸 Poslat sats do chatu: `%s send <částka> [] []` +*receive* 🏅 Vyžádat platbu: `... receive <částka> [] []` +*faucet* 🚰 Vytvořit faucet: `... faucet <částka_na_uživatele> []` +*tipjar* 🍯 Vytvořit tipjar: `... tipjar <částka_na_uživatele> []` + +📖 V každém chatu můžeš používat inline příkazy, dokonce i v soukromých konverzacích. Po zadání inline příkazu chvilku vyčkej a *klikni* na výsledek, nemačkej enter. + +⚙️ *Pokročilé příkazy* +*/transactions* 📊 Seznam transakcí +*/link* 🔗 Propojit peněženku s [BlueWallet](https://bluewallet.io/) nebo [Zeus](https://zeusln.app/) +*/lnurl* ⚡️ LNURL obdržet nebo zaplatit: `/lnurl` nebo `/lnurl [memo]` +*/nostr* 💜 Connect to Nostr: `/nostr` +*/faucet* 🚰 Vytvořit faucet: `/faucet <částka_na_uživatele>` +*/tipjar* 🍯 Vytvořit tipjar: `/tipjar <částka_na_uživatele>` +*/group* 🎟 Vytvořit vstupenky do skupiny: `/group add []` +*/shop* 🛍 Prohlížet obchody: `/shop` nebo `/shop ` +*/generate* 🎆 Vytvořit obrázky od DALLE 2: `/generate `""" + +# GENERIC +enterAmountRangeMessage = """💯 Zadej částku mezi %s a %s.""" +enterAmountMessage = """💯 Zadej částku.""" +enterUserMessage = """👤 Zadej uživatele.""" +enterTextMessage = """⌨️ Zadej text.""" +errorReasonMessage = """🚫 Chyba: %s""" + +# START + +startSettingWalletMessage = """🧮 Nastavování peněženky...""" +startWalletCreatedMessage = """🧮 Peněženka vytvořena.""" +startWalletReadyMessage = """✅ *Tvá peněženka je připravena.*""" +startWalletErrorMessage = """🚫 Chyba při vytváření peněženky. Zkus to později znovu.""" +startNoUsernameMessage = """☝️ Vypadá to, že na Telegramu nemáš ještě uživatelské jméno (@username). Nevadí, pro používání tohoto bota ho nepotřebuješ. Nicméně, pro snazší používání peněženky si nastav uživatelské jméno v nastavení Telegramu. Poté zadej příkaz /balance a bot si zaktualizuje své záznamy.""" + +# BALANCE + +balanceMessage = """👑 *Tvůj zůstatek:* %s. (%s USD / %s LKR)""" +balanceErrorMessage = """🚫 Nepodařilo se získat info o zůstatku. Zkus to později znovu.""" + +# TIP + +tipDidYouReplyMessage = """Odpověděl jsi na zprávu kvůli platbě? Pro odpověď na jakoukoli zprávu na PC klikni pravým myšítkem a vyber ´Odpovědět´ nebo na mobilu přes zprávu přejeď do strany. Pokud chceš poslat platbu jinému uživateli přímo, použij příkaz /send""" +tipInviteGroupMessage = """ℹ️ Tohoto bota můžeš přizvat do jakékoli skupiny a posílat v ní platby.""" +tipEnterAmountMessage = """Zadal jsi částku?""" +tipValidAmountMessage = """Zadal jsi platnou částku?""" +tipYourselfMessage = """📖 Nemůžeš poslat platbu sobě.""" +tipSentMessage = """💸 %s posláno pro %s.""" +tipReceivedMessage = """🏅 %s ti poslal %s.""" +tipErrorMessage = """🚫 Platba selhala.""" +tipUndefinedErrorMsg = """Prosím, zkus to později znovu.""" +tipHelpText = """📖 Ou, tohle se nezdařilo. %s + +*Použití:* `/tip <částka> []` +*Příklad:* `/tip 1000 Díky moc!`""" + +# SEND + +sendValidAmountMessage = """Zadal jsi platnou částku?""" +sendUserHasNoWalletMessage = """🚫 Uživatel %s si ještě nevytvořil peněženku.""" +sendSentMessage = """💸 %s posláno uživateli %s.""" +sendPublicSentMessage = """💸 %s posláno od %s uživateli %s.""" +sendReceivedMessage = """🏅 %s ti poslal %s.""" +sendErrorMessage = """🚫 Odeslání selhalo.""" +confirmSendMessage = """Chceš zaplatit uživateli %s?\n\n💸 Částka: %s""" +confirmSendAppendMemo = """\n✉️ %s""" +sendCancelledMessage = """🚫 Odeslání zrušeno.""" +errorTryLaterMessage = """🚫 Chyba. Prosím, zkus to později znovu.""" +sendSyntaxErrorMessage = """Zadal jsi částku a příjemce? Můžeš použít příkaz /send k poslání platby buď uživateli Telegramu jako %s nebo na lightning adresu jako LightningTipBot@ln.tips.""" +sendHelpText = """📖 Ou, tohle se nezdařilo. %s + +*Použití:* `/send <částka> []` +*Příklad:* `/send 1000 @LightningTipBot Všichni jsme Satoshi ❤️` +*Příklad:* `/send 1234 LightningTipBot@ln.tips`""" + +# INVOICE + +invoiceReceivedMessage = """⚡️ Obdržel jsi %s.""" +invoiceReceivedCurrencyMessage = """⚡️ Obdržel jsi %s (%s %s).""" +invoiceEnterAmountMessage = """Zadal jsi částku?""" +invoiceValidAmountMessage = """Zadal jsi platnou částku?""" +invoiceHelpText = """📖 Ou, tohle se nezdařilo. %s + +*Použití:* `/invoice <částka> []` +*Příklad:* `/invoice 1000 Děkuji!`""" +invoicePaidText = """✅ Invoice zaplacena.""" + +# PAY + +paymentCancelledMessage = """🚫 Platba zrušena.""" +invoicePaidMessage = """⚡️ Platba odeslána.""" +invoicePublicPaidMessage = """⚡️ Platba od %s odeslána.""" +invalidInvoiceHelpMessage = """Máš platnou lightning invoice? Zkus příkaz /send k poslání platby uživateli Telegramu nebo na lightning adresu.""" +invoiceNoAmountMessage = """🚫 Nemůžu zaplatit invoice bez částky.""" +insufficientFundsMessage = """🚫 Nedostatek prostředků. Máš %s, ale potřebuješ alespoň %s.""" +feeReserveMessage = """⚠️ Odeslání celého zůstatku se nemusí zdařit kvůli poplatkům v síti. Nechej rezervu aspoň 1% na poplatky.""" +invoicePaymentFailedMessage = """🚫 Platba selhala: %s""" +invoiceUndefinedErrorMessage = """Nemohl jsem zaplatit invoice.""" +confirmPayInvoiceMessage = """Chceš poslat tuto platbu?\n\n💸 Částka: %s""" +confirmPayAppendMemo = """\n✉️ %s""" +payHelpText = """📖 Ou, tohle se nezdařilo. %s + +*Použití:* `/pay ` +*Příklad:* `/pay lnbc20n1psscehd...`""" + +# DONATE + +donationSuccess = """🙏 Díky za tvůj příspěvek.""" +donationErrorMessage = """🚫 Ale ne, odeslání přspěvku selhalo.""" +donationProgressMessage = """🧮 Příprava odeslání příspěvku...""" +donationFailedMessage = """🚫 Příspěvek selhal: %s""" +donateEnterAmountMessage = """Zadal jsi částku?""" +donateValidAmountMessage = """Zadal jsi platnou částku?""" +donateHelpText = """📖 Ou, tohle se nezdařilo. %s + +*Použití:* `/donate <částka>` +*Příklad:* `/donate 1000`""" + +# PHOTO + +photoQrNotRecognizedMessage = """🚫 Lightning invoice nebo LNURL nešlo rozpoznat. Pokus se QR kód vycentrovat, oříznout, nebo ho více přiblížit.""" +photoQrRecognizedMessage = """✅ QR kód: +`%s`""" + +# LNURL + +lnurlReceiveInfoText = """👇 Pro příjem plateb můžeš používat tuto statickou LNURL.""" +lnurlResolvingUrlMessage = """🧮 Zpracovávám adresu...""" +lnurlGettingUserMessage = """🧮 Připravuji platbu...""" +lnurlPaymentFailed = """🚫 Platba selhala: %s""" +lnurlInvalidAmountMessage = """🚫 Neplatná částka.""" +lnurlInvalidAmountRangeMessage = """🚫 Částka musí být mezi %s a %s.""" +lnurlNoUsernameMessage = """🚫 Pro příjem plateb pomocí LNURL potřebuješ na Telegramu uživatelské jméno.""" +lnurlHelpText = """📖 Ou, tohle se nezdařilo. %s + +*Použití:* `/lnurl [částka] ` +*Příklad:* `/lnurl LNURL1DP68GUR...`""" + +# LNURL LOGIN + +confirmLnurlAuthMessager = """Chceš se přihlásit do %s?""" +lnurlSuccessfulLogin = """✅ Přihlášení úspěšné.""" +loginButtonMessage = """✅ Přihlásit""" +loginCancelledMessage = """🚫 Přihlášení zrušeno.""" + +# LNURL WITHDRAW + +confirmLnurlWithdrawMessage = """Chceš provést tento výběr?\n\n💸 Částka: %s""" +lnurlPreparingWithdraw = """🧮 Příprava výběru...""" +lnurlWithdrawFailed = """🚫 Výběr selhal.""" +lnurlWithdrawCancelled = """🚫 Výběr zrušen.""" +lnurlWithdrawSuccess = """✅ Výběr vyžádán.""" + +# LINK + +walletConnectMessage = """🔗 *Propojit vlastní peněženku* + +⚠️ Nikdy s nikým nesdílej URL nebo QR kód, jinak bude mít přístup k tvým prostředkům. Použij příkaz /api pro své API klíče. + +- *BlueWallet:* Vyber *Nová peněženka*, *Importovat peněženku*, *Scanovat nebo importovat ze souboru* a nascanuj QR kód. +- *Zeus:* Zkopíruj URL níže, vyber *Add a new node/Přidat novou nodu*, zvol *LNDHub* jako Node interface/Rozhraní nody, vlož URL a ulož nastavení *Save Node Config*.""" +couldNotLinkMessage = """🚫 Propojení s peněženkou se nezdařilo. Prosím, zkus to později znovu.""" +linkHiddenMessage = """🔍 Propojení je skryto. Abys ho znovu viděl, zadej /link příkaz.""" + +# API + +apiConnectMessage = """🔗 *Tvé API klíče* + +⚠️ Nikdy s nikým tyto klíče nesdílej, jinak bude mít přístup k tvým prostředkům. Použij příkaz /link k propojení své peněženky. + +- *Admin klíč:* `%s` +- *Invoice klíč:* `%s`""" +apiHiddenMessage = """🔍 Klíče jsou skryty. Abys je znovu viděl, zadej /api příkaz.""" + +# FAUCET + +inlineQueryFaucetTitle = """🚰 Vytvořit faucet.""" +inlineQueryFaucetDescription = """Použití: @%s faucet <částka_na_uživatele>""" +inlineResultFaucetTitle = """🚰 Vytvořit faucet s %s.""" +inlineResultFaucetDescription = """👉 Klikni zde pro vytvoření faucetu v tomto chatu.""" + +inlineFaucetMessage = """Zmáčkni ✅ pro výběr %s z faucetu od %s. + +🚰 Zbývá: %d/%s (rozdáno %d/%d uživatelům) +%s""" +inlineFaucetEndedMessage = """🚰 Faucet je prázdný 🍺\n\n🏅 %s rozdáno %d uživatelům.""" +inlineFaucetAppendMemo = """\n✉️ %s""" +inlineFaucetCreateWalletMessage = """Komunikuj s %s 👈 pro správu své peněženky.""" +inlineFaucetCancelledMessage = """🚫 Faucet zrušen.""" +inlineFaucetInvalidPeruserAmountMessage = """🚫 Částka na uživatele musí dělit kapacitu faucetu beze zbytku nebo je příliš malá (min 5 sats).""" +inlineFaucetInvalidAmountMessage = """🚫 Neplatná částka.""" +inlineFaucetSentMessage = """🚰 %s posláno %s.""" +inlineFaucetReceivedMessage = """🚰 %s ti poslal %s.""" +inlineFaucetHelpFaucetInGroup = """Vytvoř faucet ve skupině s botem nebo použij 👉 inline příkaz (napiš /advanced pro víc info).""" +inlineFaucetAlreadyTookMessage = """🚫 Z tohoto faucetu už jsi čerpal.""" +inlineFaucetHelpText = """📖 Ou, tohle se nezdařilo. %s + +*Použití:* `/faucet <částka_na_uživatele>` +*Příklad:* `/faucet 210 21`""" + +# INLINE SEND + +inlineQuerySendTitle = """💸 Poslat platbu do chatu.""" +inlineQuerySendDescription = """Použití: @%s send <částka> [] []""" +inlineResultSendTitle = """💸 Poslat %s.""" +inlineResultSendDescription = """👉 Klikni pro poslání %s do tohoto chatu.""" + +inlineSendMessage = """Zmáčkni ✅ pro obdržení platby od %s.\n\n💸 Částka: %s""" +inlineSendAppendMemo = """\n✉️ %s""" +inlineSendUpdateMessageAccept = """💸 %s posláno od %s uživateli %s.""" +inlineSendCreateWalletMessage = """Chatuj s %s 👈 pro správu své peněženky.""" +sendYourselfMessage = """📖 Nemůžeš platit sám sobě.""" +inlineSendFailedMessage = """🚫 Platba selhala.""" +inlineSendInvalidAmountMessage = """🚫 Částka musí být větší než 0.""" +inlineSendBalanceLowMessage = """🚫 Tvůj zůstatek je příliš malý.""" + +# INLINE RECEIVE + +inlineQueryReceiveTitle = """🏅 Vyžádat platbu v chatu.""" +inlineQueryReceiveDescription = """Použití: @%s receive <částka> [] []""" +inlineResultReceiveTitle = """🏅 Dostat %s.""" +inlineResultReceiveDescription = """👉 Klikni pro vyžádání platby %s.""" + +inlineReceiveMessage = """Zmáčkni 💸 pro zaplacení uživateli %s.\n\n💸 Částka: %s""" +inlineReceiveAppendMemo = """\n✉️ %s""" +inlineReceiveUpdateMessageAccept = """💸 %s posláno od %s uživateli %s.""" +inlineReceiveCreateWalletMessage = """Chatuj s %s 👈 pro správu své peněženky.""" +inlineReceiveYourselfMessage = """📖 Nemůžeš platit sám sobě.""" +inlineReceiveFailedMessage = """🚫 Platba selhala.""" +inlineReceiveCancelledMessage = """🚫 Platba zrušena.""" + +# TIPJAR + +inlineQueryTipjarTitle = """🍯 Vytvořit tipjar.""" +inlineQueryTipjarDescription = """Použití: @%s tipjar <částka_od_uživatele>""" +inlineResultTipjarTitle = """🍯 Vytvořit tipjar na %s.""" +inlineResultTipjarDescription = """👉 Klikni zde pro vytvoření tipjar v tomto chatu.""" + +inlineTipjarMessage = """Zmáčkni 💸 pro *platbu %s* do tohoto tipjar od %s. + +🙏 Věnováno: *%d*/%s (od %d uživatelů) +%s""" +inlineTipjarEndedMessage = """🍯 Tipjar od %s je plný ⭐️\n\n🏅 Věnováno %s od %d uživatelů.""" +inlineTipjarAppendMemo = """\n✉️ %s""" +inlineTipjarCancelledMessage = """🚫 Tipjar zrušen.""" +inlineTipjarInvalidPeruserAmountMessage = """🚫 Částka od jednoho uživatele musí dělit kapacitu tipjar beze zbytku.""" +inlineTipjarInvalidAmountMessage = """🚫 Neplatná částka.""" +inlineTipjarSentMessage = """🍯 %s posláno pro %s.""" +inlineTipjarReceivedMessage = """🍯 %s ti poslal %s.""" +inlineTipjarHelpTipjarInGroup = """Vytvoř tipjar ve skupině s botem nebo použij 👉 inline příkaz (/advanced pro víc info).""" +inlineTipjarHelpText = """📖 Ou, tohle se nezdařilo. %s + +*Použití:* `/tipjar <částka_od_uživatele>` +*Příklad:* `/tipjar 210 21`""" + +# GROUP TICKETS +groupAddGroupHelpMessage = """📖 Ou, tohle se nezdařilo. Tento příkaz funguje pouze ve skupinovém chatu. Mohou ho použít pouze vlastníci skupin.\nPoužití: `/group add [<částka>]`\nPříklad: `/group add NejvícHustáSkupina 1000`""" +groupJoinGroupHelpMessage = """📖 Ou, tohle se nezdařilo. Prosím, zkus to znovu.\nPoužití: `/join `\nPříklad: `/join NejvícHustáSkupina`""" +groupClickToJoinMessage = """🎟 [Klikni zde](%s) 👈 pro připojení `%s`.""" +groupTicketIssuedGroupMessage = """🎟 Uživatel %s obdržel vstupenku do této skupiny.""" +groupPayInvoiceMessage = """🎟 Pro připojení se ke skupině %s, zaplať výše uvedenou invoice.""" +groupBotIsNotAdminMessage = """🚫 Ou, tohle se nezdařilo. Musíš mě jmenovat adminem a dát mi práva zvát uživatele.""" +groupNameExists = """🚫 Skupina s tímto názvem už existuje. Prosím, vyber jiný název.""" +groupAddedMessage = """🎟 Vstupenky pro skupinu `%s` přidány.\nAlias: `%s` Cena: %s\n\nPro vyžádání vstupenky do této skupiny začni soukromý chat s %s a napiš `/join %s`.""" +groupNotFoundMessage = """🚫 Skupinu s tímto názvem se nepodařilo najít.""" +groupReceiveTicketInvoiceCommission = """🎟 Obdržel jsi *%s* (bez provize %s) za vstupenku do skupiny `%s` od uživatele %s.""" +groupReceiveTicketInvoice = """🎟 Obdržel jsi *%s* za vstupenku do skupiny `%s` od uživatele %s.""" +commandPrivateMessage = """Prosím, použij tento příkaz v soukromém chatu s %s.""" +groupHelpMessage = """🎟 Vstupenky do soukromých skupin 🎟 + +Prodej vstupenky do své _soukromé skupiny_ a zbavíš se spam botů. + +*Pokyny pro skupinové adminy:* + +1) Pozvi %s do své skupiny a udělej ho adminem. +2) Skupinu změň na soukromou. +3) Ve své skupině, jako její majitel, napiš `/group add []`. + +_Poplatky: Bot si bere provizi 10%% +10 sats za levné vstupenky. Je-li cena vstupenky >= 1000 sats, je provize 2%% + 100 sats._ + +*Pokyny pro členy skupin:* + +Pro připojení ke skupině si promluv s %s a v soukromé zprávě napiš `/join `. + +📖 *Použití:* +*Pro adminy (ve skupinovém chatu):* `/group add []`\nPříklad: `/group add NejvícHustáSkupina 1000` +*Pro uživatele (v soukromém chatu):* `/join `\nPříklad: `/join NejvícHustáSkupina`""" + +# DALLE GENERATE +generateDalleHelpMessage = """Vytvořit obrázky pomocí OpenAI DALLE 2.\nPoužití: `/generate `\nCena: 1000 sat""" +generateDallePayInvoiceMessage = """Zaplať tuto invoice pro vytvoření 4 obrázků 👇""" +generateDalleGeneratingMessage = """Tvé obrázky se generují. Chvilku strpení...""" +# CONVERT LKR TO SAT +convertEnterAmountMessage = """Zadal jsi částku?""" +convertInvalidAmountMessage = """Zadal jsi platnou částku?""" +convertPriceErrorMessage = """🚫 Nepodařilo se získat cenu.""" +convertResultMessage = """%.2f LKR je přibližně %s.""" diff --git a/translations/de.toml b/translations/de.toml new file mode 100644 index 00000000..659fdafe --- /dev/null +++ b/translations/de.toml @@ -0,0 +1,370 @@ +# COMMANDS + +helpCommandStr = """hilfe""" +basicsCommandStr = """grundlagen""" +tipCommandStr = """tip""" +balanceCommandStr = """guthaben""" +sendCommandStr = """sende""" +invoiceCommandStr = """invoice""" +payCommandStr = """bezahle""" +donateCommandStr = """spende""" +advancedCommandStr = """fortgeschritten""" +transactionsCommandStr = """transaktionen""" +logCommandStr = """log""" +listCommandStr = """liste""" + +linkCommandStr = """verbinde""" +lnurlCommandStr = """lnurl""" +faucetCommandStr = """zapfhahn""" + +tipjarCommandStr = """spendendose""" +receiveCommandStr = """empfange""" +hideCommandStr = """verstecke""" +volcanoCommandStr = """vulkan""" +showCommandStr = """zeige""" +optionsCommandStr = """optionen""" +settingsCommandStr = """einstellungen""" +saveCommandStr = """save""" +deleteCommandStr = """lösche""" +infoCommandStr = """info""" + +# NOTIFICATIONS + +cantDoThatMessage = """Du kannst das nicht tun.""" +cantClickMessage = """Du kannst diesen Knopf nicht drücken.""" +balanceTooLowMessage = """Dein Guthaben ist zu niedrig.""" + +# BUTTONS + +sendButtonMessage = """✅ Senden""" +payButtonMessage = """✅ Bezahlen""" +payReceiveButtonMessage = """💸 Bezahlen""" +receiveButtonMessage = """✅ Empfangen""" +withdrawButtonMessage = """✅ Abheben""" +cancelButtonMessage = """🚫 Abbrechen""" +collectButtonMessage = """✅ Einsammeln""" +nextButtonMessage = """Vor""" +backButtonMessage = """Zurück""" +acceptButtonMessage = """Akzeptieren""" +denyButtonMessage = """Verweigern""" +tipButtonMessage = """Tip""" +revealButtonMessage = """Aufdecken""" +showButtonMessage = """Zeigen""" +hideButtonMessage = """Verstecken""" +joinButtonMessage = """Mitmachen""" +optionsButtonMessage = """Optionen""" +settingsButtonMessage = """Einstellungen""" +saveButtonMessage = """Speichern""" +deleteButtonMessage = """Löschen""" +infoButtonMessage = """Info""" + +# HELP + +helpMessage = """⚡️ *Wallet* +_Dieser Bot ist ein Bitcoin Lightning Wallet der Tips (kleine Beträge) auf Telegram senden kann. Um ein Tip zu senden, füge den Bot in einen Gruppenchat hinzu. Die grundlegende Einheit von Tips sind Satoshis (sat). 100,000,000 sat = 1 Bitcoin. Gebe 📚 /basics ein für mehr Informationen._ + +❤️ *Spenden* +_Dieser Bot erhebt keine Gebühren, kostet aber Satoshis um betrieben zu werden. Wenn du den Bot magst, kannst das Projekt mit eine Spende unterstützen. Um zu spenden, gebe ein: _ `/donate 1000` + +%s + +⚙️ *Befehle* +*/tip* 🏅 Antworte auf eine Nachricht um einen Tip zu senden: `/tip []` +*/balance* 👑 Frage dein Guthaben ab: `/balance` +*/send* 💸 Sende an einen Benutzer: `/send @user oder user@ln.tips []` +*/invoice* ⚡️ Empfange mit Lightning: `/invoice []` +*/pay* ⚡️ Bezahle mit Lightning: `/pay ` +*/donate* ❤️ Spende an das Projekt: `/donate 1000` +*/advanced* 🤖 Fortgeschrittene Funktionen. +*/help* 📖 Rufe die Hilfe auf.""" + +infoHelpMessage = """ℹ️ *Info*""" +infoYourLightningAddress = """Deine Lightning Adresse ist `%s`""" + +basicsMessage = """🧡 *Bitcoin* +_Bitcoin ist die Währung des Internets. Bitcoin ist offen für jeden, ist dezentral, und es gibt niemanden, der Bitcoin kontrolliert. Bitcoin ist hartes Geld, welches schneller, sicherer, und fairer ist, als das klassische Finanzsystem._ + +🧮 *Ökonomie* +_Die kleinste Einheit von Bitcoin sind Satoshis (sat) und 100,000,000 sat = 1 Bitcoin. Es wird niemals mehr als 21 Millionen Bitcoin geben. Der Fiatgeld-Wert von Bitcoin kann sich täglich ändern. Wenn du jedoch auf einem Bitcoin-Standard lebst, wird 1 sat für immer 1 sat Wert sein._ + +⚡️ *Das Lightning Netzwerk* +_Das Lightning Netzwerk ist ein Bezahlprotokoll, das schnelle und günstige Bitcoin Zahlungen erlaubt, die fast keine Energie kosten. Dadurch skaliert Bitcoin auf Milliarden von Menschen weltweit._ + +📲 *Lightning Wallets* +_Dein Geld auf diesem Bot kannst du an jedes andere Lightning Wallet der Welt versenden. Empfohlene Lightning Wallets für dein Handy sind _ [Phoenix](https://phoenix.acinq.co/)_,_ [Breez](https://breez.technology/)_,_ [Muun](https://muun.com/)_ (eigene Verwahrung), oder_ [Wallet of Satoshi](https://www.walletofsatoshi.com/) _(einfach)_. + +📄 *Open Source* +_Dieser Bot ist kostenlose_ [Open Source](https://github.com/LightningTipBot/LightningTipBot) _Software. Du kannst ihn auf deinem eigenen Rechner laufen lassen und für deine eigene Community betreiben._ + +✈️ *Telegram* +_Füge den Bot in deine Telegram Gruppenchats hinzu, um Nachrichten ein /tip zu senden. Wenn der Bot Admin der Gruppe ist, wird er auch den Chat aufräumen, indem er manche Befehle nach Ausführung löscht._ + +🏛 *Bedingungen* +_Wir sind keine Verwahrer deines Geldes. Wir werden in deinem besten Interesse handeln, aber sind uns auch dessen bewusst, dass die Situation ohne KYC etwas kompliziert ist. Solange wir keine andere Lösung gefunden haben, werden wir alle Beträge auf diesem Bot als Spenden betrachten. Gebe uns nicht dein ganzes Geld. Sei dir dessen bewusst, dass dieser Bot immer noch in der Betaphase ist. Benutzung auf eigene Gefahr._ + +❤️ *Spenden* +_Dieser Bot erhebt keine Gebühren, kostet aber Satoshis um betrieben zu werden. Wenn du den Bot magst, kannst das Projekt mit eine Spende unterstützen. Um zu spenden, gebe ein: _ `/donate 1000`""" + +helpNoUsernameMessage = """👋 Wähle einen Benutzernamen in den Telegram Einstellungen.""" + +advancedMessage = """%s + +👉 *Inline Befehle* +*send* 💸 Sende sats an einen Chat: `%s send [] []` +*receive* 🏅 Bitte um Zahlung: `... receive [] []` +*faucet* 🚰 Erzeuge einen Zapfhahn: `... faucet []` +*tipjar* 🍯 Erzeuge eine Spendendose: `... tipjar []` + +📖 Du kannst Inline Befehle in jedem Chat verwenden, sogar in privaten Nachrichten. Warte eine Sekunde, nachdem du den Befehl eingegeben hast und *klicke* auf das Ergebnis, statt Enter einzugeben. + +⚙️ *Fortgeschrittene Befehle* +*/transactions* 📊 Liste der Transaktionen +*/link* 🔗 Verbinde dein Wallet mit [BlueWallet](https://bluewallet.io/) oder [Zeus](https://zeusln.app/) +*/lnurl* ⚡️ Lnurl empfangen oder senden: `/lnurl` oder `/lnurl [memo]` +*/nostr* 💜 Verbinde Nostr: `/nostr` +*/faucet* 🚰 Erzeuge einen Zapfhahn: `/faucet ` +*/tipjar* 🍯 Erzeuge eine Spendendose: `/tipjar ` +*/group* 🎟 Gruppenchat Funktionen: `/group` +*/shop* 🛍 Durchsuche Shops: `/shop` oder `/shop ` +*/generate* 🎆 Generiere Bilder mit DALLE-2: `/generate ` +*/chat* 💬 Chatte mit GPT: `/chat `""" + +# GENERIC +enterAmountRangeMessage = """💯 Gebe Betrag zwuschen %s und %s ein.""" +enterAmountMessage = """💯 Gebe Betrag ein.""" +enterUserMessage = """👤 Gebe Benutzernamen ein.""" +errorReasonMessage = """🚫 Fehler: %s""" + +# START + +startSettingWalletMessage = """🧮 Richte dein Wallet ein...""" +startWalletCreatedMessage = """🧮 Wallet erzeugt.""" +startWalletReadyMessage = """✅ *Dein Wallet ist bereit.*""" +startWalletErrorMessage = """🚫 Fehler beim einrichten deines Wallets. Bitte versuche es später noch einmal.""" +startNoUsernameMessage = """☝️ Es sieht so aus, als hättest du noch keinen Telegram @usernamen. Das ist ok, du brauchst keinen, um diesen Bot zu verwenden. Um jedoch alle Funktionen verwenden zu können, solltest du dir einen Usernamen in den Telegram Einstellungen setzen. Gebe danach ein mal /balance ein, damit der Bot seine Informationen über dich aktualisieren kann.""" + +# BALANCE + +balanceMessage = """👑 *Dein Guthaben:* %s. (%s USD / %s LKR)""" +balanceErrorMessage = """🚫 Konnte dein Guthaben nicht abfragen. Bitte versuche es später noch einmal.""" + +# TIP + +tipDidYouReplyMessage = """Hast du auf eine Nachricht geantwortet um ihr ein Tip zu senden? Um zu antworten, rechts-klicke die Nachricht auf deinem Computer oder wische die Nachricht auf deinem Handy. Wenn du direkt Zahlungen an einen anderen Nutzer senden möchtest, benutze den /send Befehl.""" +tipInviteGroupMessage = """ℹ️ Übrigens, du kannst diesen Bot in jeden Gruppenchat einladen und dort Tips verteilen.""" +tipEnterAmountMessage = """Hast du einen Betrag eingegeben?""" +tipValidAmountMessage = """Hast du einen gültigen Betrag eingegeben?""" +tipYourselfMessage = """📖 Du kannst dir nicht selbst Tips senden.""" +tipSentMessage = """💸 %s an %s gesendet.""" +tipReceivedMessage = """🏅 %s hat dir ein Tip von %s gesendet.""" +tipErrorMessage = """🚫 Tip fehlgeschlagen.""" +tipUndefinedErrorMsg = """bitte versuche es später noch einmal.""" +tipHelpText = """📖 Ups, das hat nicht geklappt. %s + +*Befehl:* `/tip []` +*Beispiel:* `/tip 1000 Geiles Meme!`""" + +# SEND + +sendValidAmountMessage = """Hast du einen gültigen Betrag eingegeben?""" +sendUserHasNoWalletMessage = """🚫 Der Benutzer %s hat noch keinen Wallet erstellt.""" +sendSentMessage = """💸 %s gesendet an %s.""" +sendPublicSentMessage = """💸 %s gesendet von %s an %s.""" +sendReceivedMessage = """🏅 %s hat dir %s gesendet.""" +sendErrorMessage = """🚫 Senden fehlgeschlagen.""" +confirmSendMessage = """Möchtest eine Zahlung an %s senden?\n\n💸 Betrag: %s""" +confirmSendAppendMemo = """\n✉️ %s""" +sendCancelledMessage = """🚫 Senden abgebrochen.""" +errorTryLaterMessage = """🚫 Fehler. Bitte versuche es später noch einmal.""" +sendSyntaxErrorMessage = """Hast du einen gültigen Betrag und Empfänger eingegeben? Du kannst den /send Befehl verwenden, um entweder an Telegram Nutzer zu senden, wie z.B. %s oder an eine Lightning Adresse, wie LightningTipBot@ln.tips.""" +sendHelpText = """📖 Ups, das hat nicht geklappt. %s + +*Befehl:* `/send []` +*Beispiel:* `/send 1000 @LightningTipBot Ich liebe diesen Bot ❤️` +*Beispiel:* `/send 1234 LightningTipBot@ln.tips`""" + +# INVOICE + +invoiceReceivedMessage = """⚡️ Du hast %s erhalten.""" +invoiceReceivedCurrencyMessage = """⚡️ Du hast %s (%s %s) erhalten.""" +invoiceEnterAmountMessage = """Hast du einen Betrag eingegeben?""" +invoiceValidAmountMessage = """Hast du einen gültigen Betrag eingegeben?""" +invoiceHelpText = """📖 Ups, das hat nicht geklappt. %s + +*Befehl:* `/invoice []` +*Beispiel:* `/invoice 1000 Thank you!`""" + +# PAY + +paymentCancelledMessage = """🚫 Zahlung abgebrochen.""" +invoicePaidMessage = """⚡️ Zahlung gesendet.""" +invoicePublicPaidMessage = """⚡️ Zahlung gesendet von %s.""" +invalidInvoiceHelpMessage = """Hast du eine gültige Lightning Invoice eingegeben? Versuche den /send Befehl, falls du an einen Telegram Nutzer oder eine Lightning Adresse senden willst.""" +invoiceNoAmountMessage = """🚫 Kann keine Invoices ohne Betrag bezahlen.""" +insufficientFundsMessage = """🚫 Guthaben zu niedrig. Du hast %s aber brauchst mindestens %s.""" +feeReserveMessage = """⚠️ Dein gesamtes Guthaben zu senden könnte wegen den Netzwerkgebühren fehlschlagen. Falls das passiert, versuche es mit einem etwas geringeren Betrag.""" +invoicePaymentFailedMessage = """🚫 Zahlung fehlgeschlagen: %s""" +invoiceUndefinedErrorMessage = """Konnte Invoice nicht bezahlen.""" +confirmPayInvoiceMessage = """Möchtest du diese Zahlung senden?\n\n💸 Betrag: %s""" +confirmPayAppendMemo = """\n✉️ %s""" +payHelpText = """📖 Ups, das hat nicht geklappt. %s + +*Befehl:* `/pay ` +*Beispiel:* `/pay lnbc20n1psscehd...`""" + +# DONATE + +donationSuccess = """🙏 Danke für deine Spende.""" +donationErrorMessage = """🚫 Oh nein. Spende fehlgeschlagen.""" +donationProgressMessage = """🧮 Bereite deine Spende vor...""" +donationFailedMessage = """🚫 Spende fehlgeschlagen: %s""" +donateEnterAmountMessage = """Hast du deinen Betrag eingegeben?""" +donateValidAmountMessage = """Hast du einen gültigen Betrag eingegeben?""" +donateHelpText = """📖 Ups, das hat nicht geklappt. %s + +*Befehl:* `/donate ` +*Beispiel:* `/donate 1000`""" + +# PHOTO + +photoQrNotRecognizedMessage = """🚫 Keine Lightning Invoice oder LNURL erkannt. Versuche den QR Code zu zentrieren, beschneide ihn oder zoome mehr heran.""" +photoQrRecognizedMessage = """✅ QR Code: +`%s`""" + +# LNURL + +lnurlReceiveInfoText = """👇 Du kannst diese statische LNURL benutzen um bezahlt zu werden.""" +lnurlResolvingUrlMessage = """🧮 Löse Adresse auf...""" +lnurlGettingUserMessage = """🧮 Bereite Zahlung vor...""" +lnurlPaymentFailed = """🚫 Zahlung fehlgeschlagen: %s""" +lnurlInvalidAmountMessage = """🚫 Ungültiger Betrag.""" +lnurlInvalidAmountRangeMessage = """🚫 Betrag muss zwischen %s und %s liegen.""" +lnurlNoUsernameMessage = """🚫 Du musst einen Telegram Username anlegen, um per LNURL Zahlungen zu empfangen.""" +lnurlHelpText = """📖 Ups, das hat nicht geklappt. %s + +*Befehl:* `/lnurl [betrag] ` +*Beispiel:* `/lnurl LNURL1DP68GUR...`""" + +# LNURL LOGIN + +confirmLnurlAuthMessager = """Möchtest du dich bei %s einloggen?""" +lnurlSuccessfulLogin = """✅ Login erfolgreich.""" +loginButtonMessage = """✅ Login""" +loginCancelledMessage = """🚫 Login abgebrochen.""" + +# LNURL WITHDRAW + +confirmLnurlWithdrawMessage = """Möchtest du diese Abhebung tätigen?\n\n💸 Betrag: %s""" +lnurlPreparingWithdraw = """🧮 Bereite Abhebung vor...""" +lnurlWithdrawFailed = """🚫 Abhebung gescheitert.""" +lnurlWithdrawCancelled = """🚫 Abhebung abgebrochen.""" +lnurlWithdrawSuccess = """✅ Abhebung angefordert.""" + +# LINK + +walletConnectMessage = """🔗 *Verbinde dein Wallet* + +⚠️ Teile diese URL und den QR code mit niemandem! Jeder, der darauf Zugriff hat, hat auch Zugriff auf dein Konto. Nutze /api für deine API keys. + +- *BlueWallet:* Drücke *New wallet*, *Import wallet*, *Scan or import a file*, und scanne den QR Code. +- *Zeus:* Kopiere die URL unten, drücke *Add a new node*, *Import* (die URL), *Save Node Config*.""" +couldNotLinkMessage = """🚫 Konnte dein Wallet nicht verbinden. Bitte versuche es später noch einmal.""" +linkHiddenMessage = """🔍 Link versteckt. Gebe /link ein um ihn wieder zu sehen.""" + +# API + +walletConnectMessage = """🔗 *Deine API Keys* + +⚠️ Teile diese Key mit niemandem! Jeder, der darauf Zugriff hat, hat auch Zugriff auf dein Konto. Nutze /link um dein Wallet zu verbinden. + +- *Admin key:* `%s` +- *Invoice key:* `%s`""" +apiHiddenMessage = """🔍 Keys versteckt. Gebe /api ein um sie wieder zu sehen.""" + +# FAUCET + +inlineQueryFaucetTitle = """🚰 Erzeuge einen Zapfhahn.""" +inlineQueryFaucetDescription = """Befehl: @%s faucet """ +inlineResultFaucetTitle = """🚰 Erzeuge einen %s Zapfhahn.""" +inlineResultFaucetDescription = """👉 Klicke hier um den Zapfhahn in diesen Chat zu senden.""" + +inlineFaucetMessage = """Drücke ✅ um %s aus %s's Zapfhahn zu zapfen. + +🚰 Verbleibend: %d/%s (%d/%d gezapft) +%s""" +inlineFaucetEndedMessage = """🚰 Zapfhahn leer 🍺\n\n🏅 %s an %d User ausgeschüttet.""" +inlineFaucetAppendMemo = """\n✉️ %s""" +inlineFaucetCreateWalletMessage = """Chatte mit %s 👈 um dein Wallet zu verwalten.""" +inlineFaucetCancelledMessage = """🚫 Zapfhahn abgebrochen.""" +inlineFaucetInvalidPeruserAmountMessage = """🚫 Die Pro-User Menge muss ein natürlicher Teiler der Gesamtmenge sein.""" +inlineFaucetInvalidAmountMessage = """🚫 Ungültige Menge.""" +inlineFaucetSentMessage = """🚰 %s an %s gesendet.""" +inlineFaucetReceivedMessage = """🚰 %s hat dir %s gesendet.""" +inlineFaucetHelpFaucetInGroup = """Erzeuge einen Zapfhahn in einer Gruppe, wo der Bot eingeladen ist oder benutze einen 👉 Inline Befehl (/advanced für mehr).""" +inlineFaucetHelpText = """📖 Ups, das hat nicht geklappt. %s + +*Befehl:* `/faucet ` +*Beispiel:* `/faucet 210 21`""" + +# INLINE SEND + +inlineQuerySendTitle = """💸 Sende Zahlungen in einem Chat.""" +inlineQuerySendDescription = """Befehl: @%s send [] []""" +inlineResultSendTitle = """💸 Sende %s.""" +inlineResultSendDescription = """👉 Klicke hier um %s in diesen Chat zu senden.""" + +inlineSendMessage = """Klicke ✅ um eine Zahlung von %s zu erhalten.\n\n💸 Betrag: %s""" +inlineSendAppendMemo = """\n✉️ %s""" +inlineSendUpdateMessageAccept = """💸 %s gesendet von %s an %s.""" +inlineSendCreateWalletMessage = """Chatte mit %s 👈 um dein Wallet zu verwalten.""" +sendYourselfMessage = """📖 Du kannst dich nicht selbst bezahlen.""" +inlineSendFailedMessage = """🚫 Senden fehlgeschlagen.""" +inlineSendInvalidAmountMessage = """🚫 Betrag muss größer als 0 sein.""" +inlineSendBalanceLowMessage = """🚫 Dein Guthaben reicht nicht aus.""" + +# INLINE RECEIVE + +inlineQueryReceiveTitle = """🏅 Empfange eine Zahlung in einem Chat.""" +inlineQueryReceiveDescription = """Befehl: @%s receive [] []""" +inlineResultReceiveTitle = """🏅 Empfange %s.""" +inlineResultReceiveDescription = """👉 Klicke hier um eine Zahlung von %s zu empfangen.""" + +inlineReceiveMessage = """Drücke 💸 um an %s zu zahlen.\n\n💸 Betrag: %s""" +inlineReceiveAppendMemo = """\n✉️ %s""" +inlineReceiveUpdateMessageAccept = """💸 %s gesendet von %s an %s.""" +inlineReceiveCreateWalletMessage = """Chatte mit %s 👈 um dein Wallet zu verwalten.""" +inlineReceiveYourselfMessage = """📖 Du kannst dich nicht selbst bezahlen.""" +inlineReceiveFailedMessage = """🚫 Empfangen fehlgeschlagen.""" +inlineReceiveCancelledMessage = """🚫 Empfangen abgebrochen.""" + +# TIPJAR + +inlineQueryTipjarTitle = """🍯 Erzeuge eine Spendendose.""" +inlineQueryTipjarDescription = """Befehl: @%s tipjar """ +inlineResultTipjarTitle = """🍯 Erzeuge eine Spendendose für %s.""" +inlineResultTipjarDescription = """👉 Klicke hier um eine Spendendose zu erzeugen.""" + +inlineTipjarMessage = """Drücke 💸 up *%s* in die Spendendose von %s zu *zahlen*. + +🙏 Gespendet: *%d*/%s (von %d Benutzern) +%s""" +inlineTipjarEndedMessage = """🍯 %s's Spendendose is voll ⭐️\n\n🏅 %s gespendet von %d Nutzern.""" +inlineTipjarAppendMemo = """\n✉️ %s""" +inlineTipjarCancelledMessage = """🚫 Spendendose abgebrochen.""" +inlineTipjarInvalidPeruserAmountMessage = """🚫 Die Pro-User Menge muss ein natürlicher Teiler der Gesamtmenge sein.""" +inlineTipjarInvalidAmountMessage = """🚫 Ungültige Menge.""" +inlineTipjarSentMessage = """🍯 %s an %s gesendet.""" +inlineTipjarReceivedMessage = """🍯 %s hat dir %s gesendet.""" +inlineTipjarHelpTipjarInGroup = """Erzeuge eine Spendendose in einer Gruppe, wo der Bot eingeladen ist oder benutze einen 👉 Inline Befehl (/advanced für mehr).""" +inlineTipjarHelpText = """📖 Ups, das hat nicht geklappt. %s + +*Befehl:* `/tipjar ` +*Beispiel:* `/tipjar 210 21`""" +# CONVERT LKR TO SAT +convertEnterAmountMessage = """Did you enter an amount?""" +convertInvalidAmountMessage = """Did you enter a valid amount?""" +convertPriceErrorMessage = """🚫 Couldn't fetch price.""" +convertResultMessage = """%.2f LKR is about %s.""" + +# CONVERT SAT TO FIAT +convertSatsResultMessage = """%s is about %s USD / %s LKR""" diff --git a/translations/en.toml b/translations/en.toml new file mode 100644 index 00000000..57cd60cb --- /dev/null +++ b/translations/en.toml @@ -0,0 +1,485 @@ +# COMMANDS + +helpCommandStr = """help""" +basicsCommandStr = """basics""" +tipCommandStr = """tip""" +balanceCommandStr = """balance""" +sendCommandStr = """send""" +invoiceCommandStr = """invoice""" +payCommandStr = """pay""" +donateCommandStr = """donate""" +advancedCommandStr = """advanced""" +transactionsCommandStr = """transactions""" +logCommandStr = """log""" +listCommandStr = """list""" + +linkCommandStr = """link""" +lnurlCommandStr = """lnurl""" +faucetCommandStr = """faucet""" + +tipjarCommandStr = """tipjar""" +receiveCommandStr = """receive""" +hideCommandStr = """hide""" +volcanoCommandStr = """volcano""" +showCommandStr = """show""" +optionsCommandStr = """options""" +settingsCommandStr = """settings""" +saveCommandStr = """save""" +deleteCommandStr = """delete""" +infoCommandStr = """info""" + +# NOTIFICATIONS + +cantDoThatMessage = """You can't do that.""" +cantClickMessage = """You can't click this button.""" +balanceTooLowMessage = """Your balance is too low.""" + +# BUTTONS + +sendButtonMessage = """✅ Send""" +payButtonMessage = """✅ Pay""" +payReceiveButtonMessage = """💸 Pay""" +receiveButtonMessage = """✅ Receive""" +withdrawButtonMessage = """✅ Withdraw""" +cancelButtonMessage = """🚫 Cancel""" +collectButtonMessage = """✅ Collect""" +nextButtonMessage = """Next""" +backButtonMessage = """Back""" +acceptButtonMessage = """Accept""" +denyButtonMessage = """Deny""" +tipButtonMessage = """Tip""" +revealButtonMessage = """Reveal""" +showButtonMessage = """Show""" +hideButtonMessage = """Hide""" +joinButtonMessage = """Join""" +optionsButtonMessage = """Options""" +settingsButtonMessage = """Settings""" +saveButtonMessage = """Save""" +deleteButtonMessage = """Delete""" +infoButtonMessage = """Info""" + +cancelButtonEmoji = """🚫""" +payButtonEmoji = """💸""" + +# HELP + +helpMessage = """⚡️ *BitcoinDeepa Wallet* +_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._ + +%s + +⚙️ *Commands* +*/tip* 🏅 Reply to a message to tip: `/tip []` +*/balance* 👑 Check your balance: `/balance` +*/send* 💸 Send funds to a user: `/send @user or user@ln.tips []` +*/invoice* ⚡️ Receive with Lightning: `/invoice []` +*/pay* ⚡️ Pay with Lightning: `/pay ` +*/transactions* 📊 List transactions +*/link* 🔗 Link your wallet to [BlueWallet](https://bluewallet.io/) or [Zeus](https://zeusln.app/) +*/lnurl* ⚡️ Lnurl receive or pay: `/lnurl` or `/lnurl [memo]` +*/nostr* 💜 Connect to Nostr: `/nostr` +*/faucet* 🚰 Create a faucet: `/faucet ` +*/tipjar* 🍯 Create a tipjar: `/tipjar ` +*/createpot* 🏺 Create a savings pot: `/createpot ` +*/pots* 💰 List your savings pots: `/pots` +*/addtopot* ➕ Add sats to pot: `/addtopot ` +*/withdrawfrompot* ➖ Withdraw from pot: `/withdrawfrompot ` +*/deletepot* 🗑️ Delete empty pot: `/deletepot ` +*/so* 📅 Automate recurring pot savings: `/so create ` +*/help* 📖 Read this help. + +*Community* 🤝 [@bitcoindeepa](https://t.me/bitcoindeepa)""" + +infoHelpMessage = """ℹ️ *Info*""" +infoYourLightningAddress = """Your Lightning address is `%s`""" + +basicsMessage = """🧡 *Bitcoin* +_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._ + +🧮 *Economnics* +_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._ + +⚡️ *The Lightning Network* +_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._ + +📲 *Lightning Wallets* +_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)_. + +📄 *Open Source* +_This bot is free and_ [open source](https://github.com/CeyLabs/BitcoinDeepaBot) _software. You can run it on your own computer and use it in your own community._ + +✈️ *Telegram* +_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._ + +🏛 *Terms* +_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._""" + +helpNoUsernameMessage = """👋 Please, set a Telegram username.""" + +advancedMessage = """%s + +👉 *Inline commands* +*send* 💸 Send sats to chat: `%s send [] []` +*receive* 🏅 Request a payment: `... receive [] []` +*faucet* 🚰 Create a faucet: `... faucet []` +*tipjar* 🍯 Create a tipjar: `... tipjar []` + +📖 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. + +⚙️ *Advanced commands* +*/transactions* 📊 List transactions +*/link* 🔗 Link your wallet to [BlueWallet](https://bluewallet.io/) or [Zeus](https://zeusln.app/) +*/lnurl* ⚡️ Lnurl receive or pay: `/lnurl` or `/lnurl [memo]` +*/nostr* 💜 Connect to Nostr: `/nostr` +*/faucet* 🚰 Create a faucet: `/faucet ` +*/tipjar* 🍯 Create a tipjar: `/tipjar ` +*/group* 🎟 Group chat features: `/group` +*/shop* 🛍 Browse shops: `/shop` or `/shop ` +*/generate* 🎆 Generate DALLE-2 images: `/generate `""" + +# GENERIC +enterAmountRangeMessage = """💯 Enter an amount between %s and %s.""" +enterAmountMessage = """💯 Enter an amount.""" +enterUserMessage = """👤 Enter a user.""" +enterTextMessage = """⌨️ Enter text.""" +errorReasonMessage = """🚫 Error: %s""" + +# START + +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.""" + +# BALANCE + +balanceMessage = """👑 *Main balance:* %s (%s USD / රු. %s)""" +balanceErrorMessage = """🚫 Could not fetch your balance. Please try again later.""" +potBalanceInfo = """💰 *In savings pots:* %s (%s USD / රු. %s)""" +totalBalanceInfo = """🏦 *Total balance:* %s (%s USD / රු. %s)""" + +# TIP + +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 = """💸 %s sent to %s.""" +tipReceivedMessage = """🏅 %s has tipped you %s.""" +tipErrorMessage = """🚫 Tip failed.""" +tipUndefinedErrorMsg = """please try again later.""" +tipHelpText = """📖 Oops, that didn't work. %s + +*Usage:* `/tip []` +*Example:* `/tip 1000 Dank meme!`""" + +# SEND + +sendValidAmountMessage = """Did you enter a valid amount?""" +sendUserHasNoWalletMessage = """🚫 User %s hasn't created a wallet yet.""" +sendSentMessage = """💸 %s sent to %s.""" +sendPublicSentMessage = """💸 %s sent from %s to %s.""" +sendReceivedMessage = """🏅 %s sent you %s.""" +sendErrorMessage = """🚫 Send failed.""" +confirmSendMessage = """Do you want to pay to %s?\n\n💸 Amount: %s""" +confirmSendAppendMemo = """\n✉️ %s""" +sendCancelledMessage = """🚫 Send cancelled.""" +errorTryLaterMessage = """🚫 Error. Please try again later.""" +sendSyntaxErrorMessage = """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 BitcoinDeepaBot@bitcoindeepa.com.""" +sendHelpText = """📖 Oops, that didn't work. %s + +*Usage:* `/send []` +*Example:* `/send 1000 @BitcoinDeepaBot I just like the bot ❤️` +*Example:* `/send 1234 BitcoinDeepaBot@bitcoindeepa.com`""" + +# INVOICE + +invoiceReceivedMessage = """⚡️ You received %s.""" +invoiceReceivedCurrencyMessage = """⚡️ You received %s (%s %s).""" +invoiceEnterAmountMessage = """Did you enter an amount?""" +invoiceValidAmountMessage = """Did you enter a valid amount?""" +invoiceHelpText = """📖 Oops, that didn't work. %s + +*Usage:* `/invoice []` +*Example:* `/invoice 1000 Thank you!`""" +invoicePaidText = """✅ Invoice paid.""" + +# PAY + +paymentCancelledMessage = """🚫 Payment cancelled.""" +invoicePaidMessage = """⚡️ Payment sent.""" +invoicePublicPaidMessage = """⚡️ Payment sent by %s.""" +invalidInvoiceHelpMessage = """Did you enter a valid Lightning invoice? Try /send if you want to send to a Telegram user or to a Lightning address.""" +invoiceNoAmountMessage = """🚫 Can't pay invoices without an amount.""" +insufficientFundsMessage = """🚫 Insufficient funds. You have %s but you need at least %s.""" +feeReserveMessage = """⚠️ Sending your entire balance might fail because of network fees. Reserve at least 1% for fees.""" +invoicePaymentFailedMessage = """🚫 Payment failed: %s""" +invoiceUndefinedErrorMessage = """Could not pay invoice.""" +confirmPayInvoiceMessage = """Do you want to send this payment?\n\n💸 Amount: %s""" +confirmPayAppendMemo = """\n✉️ %s""" +payHelpText = """📖 Oops, that didn't work. %s + +*Usage:* `/pay ` +*Example:* `/pay lnbc20n1psscehd...`""" + +# DONATE + +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 + +*Usage:* `/donate ` +*Example:* `/donate 1000`""" + +# PHOTO + +photoQrNotRecognizedMessage = """🚫 Could not recognize a Lightning invoice or a LNURL. Try to center the QR code, crop the photo, or zoom in.""" +photoQrRecognizedMessage = """✅ QR code: +`%s`""" + +# LNURL + +lnurlReceiveInfoText = """👇 You can use this static LNURL to receive payments.""" +lnurlResolvingUrlMessage = """🧮 Resolving address...""" +lnurlGettingUserMessage = """🧮 Preparing payment...""" +lnurlPaymentFailed = """🚫 Payment failed: %s""" +lnurlInvalidAmountMessage = """🚫 Invalid amount.""" +lnurlInvalidAmountRangeMessage = """🚫 Amount must be between %s and %s.""" +lnurlNoUsernameMessage = """🚫 You need to set a Telegram username to receive payments via LNURL.""" +lnurlHelpText = """📖 Oops, that didn't work. %s + +*Usage:* `/lnurl [amount] ` +*Example:* `/lnurl LNURL1DP68GUR...`""" + +# LNURL LOGIN + +confirmLnurlAuthMessager = """Do you want to login to %s?""" +lnurlSuccessfulLogin = """✅ Login successful.""" +loginButtonMessage = """✅ Login""" +loginCancelledMessage = """🚫 Login cancelled.""" + +# LNURL WITHDRAW + +confirmLnurlWithdrawMessage = """Do you want to make this withdrawal?\n\n💸 Amount: %s""" +lnurlPreparingWithdraw = """🧮 Preparing withdrawal...""" +lnurlWithdrawFailed = """🚫 Withdrawal failed.""" +lnurlWithdrawCancelled = """🚫 Withdrawal cancelled.""" +lnurlWithdrawSuccess = """✅ Withdrawal requested.""" + +# LINK + +walletConnectMessage = """🔗 *Link your wallet* + +⚠️ Never share the URL or the QR code with anyone or they will be able to access your funds. Use /api for your API keys. + +- *BlueWallet:* Press *New wallet*, *Import wallet*, *Scan or import a file*, and scan the QR code. +- *Zeus:* Copy the URL below, press *Add a new node*, select *LNDHub* as Node interface, enter the URL, *Save Node Config*.""" +couldNotLinkMessage = """🚫 Couldn't link your wallet. Please try again later.""" +linkHiddenMessage = """🔍 Link hidden. Enter /link to see it again.""" + +# API + +apiConnectMessage = """🔗 *Your API keys* + +⚠️ Never share these keys with anyone or they will be able to access your funds. Use /link to link your wallet. + +- *Admin key:* `%s` +- *Invoice key:* `%s`""" +apiHiddenMessage = """🔍 Keys hidden. Enter /api to see them again.""" + +# FAUCET + +inlineQueryFaucetTitle = """🚰 Create a faucet.""" +inlineQueryFaucetDescription = """Usage: @%s faucet """ +inlineResultFaucetTitle = """🚰 Create a %s faucet.""" +inlineResultFaucetDescription = """👉 Click here to create a faucet in this chat.""" + +inlineFaucetMessage = """Press ✅ to collect %s from %s's faucet. + +🚰 Remaining: %s/%s (given to %d/%d users) +%s""" +inlineFaucetEndedMessage = """🚰 Faucet empty 🍺\n\n🏅 %s given to %d users.""" +inlineFaucetAppendMemo = """\n✉️ %s""" +inlineFaucetCreateWalletMessage = """Chat with %s 👈 to manage your wallet.""" +inlineFaucetCancelledMessage = """🚫 Faucet cancelled.""" +inlineFaucetInvalidPeruserAmountMessage = """🚫 Peruser amount not divisor of capacity or too low (min 5 sat).""" +inlineFaucetInvalidAmountMessage = """🚫 Invalid amount.""" +inlineFaucetSentMessage = """🚰 %s sent to %s.""" +inlineFaucetReceivedMessage = """🚰 %s sent you %s.""" +inlineFaucetHelpFaucetInGroup = """Create a faucet in a group with the bot inside or use 👉 inline command (/advanced for more).""" +inlineFaucetAlreadyTookMessage = """🚫 You already took from this faucet.""" +inlineFaucetHelpText = """📖 Oops, that didn't work. %s + +*Usage:* `/faucet ` +*Example:* `/faucet 210 21`""" + +# INLINE SEND + +inlineQuerySendTitle = """💸 Send payment to a chat.""" +inlineQuerySendDescription = """Usage: @%s send [] []""" +inlineResultSendTitle = """💸 Send %s.""" +inlineResultSendDescription = """👉 Click to send %s to this chat.""" + +inlineSendMessage = """Press ✅ to receive payment from %s.\n\n💸 Amount: %s""" +inlineSendAppendMemo = """\n✉️ %s""" +inlineSendUpdateMessageAccept = """💸 %s sent from %s to %s.""" +inlineSendCreateWalletMessage = """Chat with %s 👈 to manage your wallet.""" +sendYourselfMessage = """📖 You can't pay to yourself.""" +inlineSendFailedMessage = """🚫 Send failed.""" +inlineSendInvalidAmountMessage = """🚫 Amount must be larger than 0.""" +inlineSendBalanceLowMessage = """🚫 Your balance is too low.""" + +# INLINE RECEIVE + +inlineQueryReceiveTitle = """🏅 Request a payment in a chat.""" +inlineQueryReceiveDescription = """Usage: @%s receive [] []""" +inlineResultReceiveTitle = """🏅 Receive %s.""" +inlineResultReceiveDescription = """👉 Click to request a payment of %s.""" + +inlineReceiveMessage = """Press 💸 to pay to %s.\n\n💸 Amount: %s""" +inlineReceiveAppendMemo = """\n✉️ %s""" +inlineReceiveUpdateMessageAccept = """💸 %s sent from %s to %s.""" +inlineReceiveCreateWalletMessage = """Chat with %s 👈 to manage your wallet.""" +inlineReceiveYourselfMessage = """📖 You can't pay to yourself.""" +inlineReceiveFailedMessage = """🚫 Receive failed.""" +inlineReceiveCancelledMessage = """🚫 Receive cancelled.""" + +# TIPJAR + +inlineQueryTipjarTitle = """🍯 Create a tipjar.""" +inlineQueryTipjarDescription = """Usage: @%s tipjar """ +inlineResultTipjarTitle = """🍯 Create a %s tipjar.""" +inlineResultTipjarDescription = """👉 Click here to create a tipjar in this chat.""" + +inlineTipjarMessage = """Press 💸 to *pay %s* to this tipjar by %s. + +🙏 Given: *%s*/%s (by %d users) +%s""" +inlineTipjarEndedMessage = """🍯 %s's tipjar is full ⭐️\n\n🏅 %s given by %d users.""" +inlineTipjarAppendMemo = """\n✉️ %s""" +inlineTipjarCancelledMessage = """🚫 Tipjar cancelled.""" +inlineTipjarInvalidPeruserAmountMessage = """🚫 Peruser amount not divisor of capacity.""" +inlineTipjarInvalidAmountMessage = """🚫 Invalid amount.""" +inlineTipjarSentMessage = """🍯 %s sent to %s.""" +inlineTipjarReceivedMessage = """🍯 %s sent you %s.""" +inlineTipjarHelpTipjarInGroup = """Create a tipjar in a group with the bot inside or use 👉 inline command (/advanced for more).""" +inlineTipjarHelpText = """📖 Oops, that didn't work. %s + +*Usage:* `/tipjar ` +*Example:* `/tipjar 210 21`""" + +# GROUP TICKETS +groupAddGroupHelpMessage = """📖 Oops, that didn't work. This command only works in a group chat. Only group owners can use this command.\nUsage: `/group add []`\nExample: `/group add TheBestBitcoinGroup 1000`""" +groupJoinGroupHelpMessage = """📖 Oops, that didn't work. Please try again.\nUsage: `/join `\nExample: `/join TheBestBitcoinGroup`""" +groupClickToJoinMessage = """🎟 [Click here](%s) 👈 to join `%s`.""" +groupTicketIssuedGroupMessage = """🎟 User %s has received a ticket for this group.""" +groupPayInvoiceMessage = """🎟 To join the group %s, pay the invoice above.""" +groupBotIsNotAdminMessage = """🚫 Oops, that didn't work. You must make me admin and grant me rights to invite and ban users.""" +groupNameExists = """🚫 A group with this name already exists. Please choose a different name.""" +groupAddedMessagePrivate = """🎟 Tickets for group `%s` added.\n\nAlias: `%s` Price: %s\n\nTo request a ticket for this group, start a private chat with %s and write `/join %s`.""" +groupAddedMessagePublic = """🎟 Tickets enabled.""" +groupNotFoundMessage = """🚫 Could not find a group with this name.""" +groupReceiveTicketInvoiceCommission = """🎟 You received *%s* (excl. %s commission) for a ticket for group `%s` paid by user %s.""" +groupReceiveTicketInvoice = """🎟 You received *%s* for a ticket for group `%s` paid by user %s.""" +commandPrivateMessage = """Please use this command in a private chat with %s.""" +groupHelpMessage = """👥 *Group commands* + +🎟 *Public tickets* + +For admins (in group chat): `/group ticket `\nExample: `/group ticket 1000` + +🎟 *Private tickets* + +Sell tickets for your _private Group_ and get rid of spam bots. + +*Instructions for group admins:* + +1) Invite %s to your group and make it admin. +2) Make your group private. +3) In your group, you (the group owner) write `/group add []`. + +_Fees: The bot takes a 10%% +10 sat commission for cheap tickets. If the ticket is >= 1000 sat, the commission is 2%% + 100 sat._ + +*Instructions for group members:* + +To join a group, talk to %s and write in a private message `/join `. + +📖 *Usage:* +For admins (in group chat): `/group add []`\nExample: `/group add TheBestBitcoinGroup 1000` +For users (in private chat): `/join `\nExample: `/join TheBestBitcoinGroup`""" + +# DALLE GENERATE +generateDalleHelpMessage = """Generate images using OpenAI DALLE 2.\nUsage: `/generate `\nPrice: %s""" +generateDallePayInvoiceMessage = """Pay this invoice to generate four images 👇""" +generateDalleGeneratingMessage = """Your images are being generated. Please wait...""" +# CONVERT LKR TO SAT +convertEnterAmountMessage = """Did you enter an amount?""" +convertInvalidAmountMessage = """Did you enter a valid amount?""" +convertPriceErrorMessage = """🚫 Couldn't fetch price.""" +convertResultMessage = """%.2f LKR is about %s.""" + +# CONVERT SAT TO FIAT +convertSatsResultMessage = """%s is about %s USD / රු. %s""" + +# POTS + +createPotHelpText = """📖 Create a savings pot to set aside sats. + +*Usage:* `/createpot ` +*Example:* `/createpot Holiday Fund` + +🏺 _Create up to 20 savings pots_ +📝 _Name can contain letters, numbers, spaces, hyphens, and underscores_ +💰 _Transfer sats using /addtopot and /withdrawfrompot_""" + +addToPotHelpText = """📖 Transfer sats from your main balance to a savings pot. + +*Usage:* `/addtopot ` +*Example:* `/addtopot "Holiday Fund" 1000` + +💰 _Moves sats from your main balance to the pot_ +🔒 _Sats are safely stored in the pot until withdrawn_""" + +withdrawFromPotHelpText = """📖 Withdraw sats from a savings pot to your main balance. + +*Usage:* `/withdrawfrompot ` +*Example:* `/withdrawfrompot "Holiday Fund" 500` + +💸 _Moves sats from the pot back to your main balance_ +✅ _Sats become available for sending and payments_""" + +deletePotHelpText = """📖 Delete an empty savings pot. + +*Usage:* `/deletepot ` +*Example:* `/deletepot "Holiday Fund"` + +⚠️ _Pot must be empty (0 sats) before deletion_ +🗑️ _This action cannot be undone_""" + +# STANDING ORDERS + +standingOrderHelpText = """📅 *Standing Orders — Automate Recurring Savings* + +Set up automatic monthly transfers from your main wallet into a savings pot. Great for salary-day savings or recurring goals. + +*Commands:* +`/so create ` — Create a standing order +`/so list` — List your active standing orders +`/so delete ` — Delete a standing order by list number + +*Examples:* +`/so create 25 1000 Holiday Fund` — Transfer 1000 sats to "Holiday Fund" on the 25th of every month +`/so create 31 5000 Emergency` — Transfer 5000 sats on the last day of each month (day 31 in shorter months fires on the last available day) + +📌 _Notes:_ +• _Day must be between 1 and 31_ +• _Days 29–31 automatically fire on the last day of shorter months (e.g. Feb 28)_ +• _The pot must already exist — create one first with /createpot_ +• _You can have up to 10 active standing orders_ +• _Orders that fail 3 months in a row are automatically deactivated and you will be notified_""" diff --git a/translations/es.toml b/translations/es.toml new file mode 100644 index 00000000..2f9cd8a2 --- /dev/null +++ b/translations/es.toml @@ -0,0 +1,357 @@ +# COMMANDS + +helpCommandStr = """ayuda""" +basicsCommandStr = """fundamentos""" +tipCommandStr = """tip""" +balanceCommandStr = """saldo""" +sendCommandStr = """enviar""" +invoiceCommandStr = """factura""" +payCommandStr = """pagar""" +donateCommandStr = """donar""" +advancedCommandStr = """avanzado""" +transactionsCommandStr = """transacciones""" +logCommandStr = """log""" +listCommandStr = """lista""" + +linkCommandStr = """enlace""" +lnurlCommandStr = """lnurl""" +faucetCommandStr = """grifo""" + +tipjarCommandStr = """alcancía""" +receiveCommandStr = """recibir""" +hideCommandStr = """ocultar""" +volcanoCommandStr = """volcán""" +showCommandStr = """mostrar""" +optionsCommandStr = """opciones""" +settingsCommandStr = """ajustes""" +saveCommandStr = """guardar""" +deleteCommandStr = """borrar""" +infoCommandStr = """info""" + +# NOTIFICATIONS + +cantDoThatMessage = """No puedes hacer eso.""" +cantClickMessage = """No puedes dar click en este botón.""" +balanceTooLowMessage = """Tu saldo es muy bajo.""" + +# BUTTONS + +sendButtonMessage = """✅ Enviar""" +payButtonMessage = """✅ Pagar""" +payReceiveButtonMessage = """💸 Pagar""" +receiveButtonMessage = """✅ Recibir""" +cancelButtonMessage = """🚫 Cancelar""" +collectButtonMessage = """✅ Cobrar""" +nextButtonMessage = """Siguiente""" +backButtonMessage = """Volver""" +acceptButtonMessage = """Aceptar""" +denyButtonMessage = """Negar""" +tipButtonMessage = """Tip""" +revealButtonMessage = """Revelar""" +showButtonMessage = """Mostrar""" +hideButtonMessage = """Ocultar""" +joinButtonMessage = """Unir""" +optionsButtonMessage = """Opciones""" +settingsButtonMessage = """Ajustes""" +saveButtonMessage = """Guardar""" +deleteButtonMessage = """Borrar""" +infoButtonMessage = """Info""" + +# HELP + +helpMessage = """⚡️ *Monedero* +_Este bot es un monedero Bitcoin Lightning que puede enviar tips (propinas) vía Telegram. Para dar tips, añade el bot a un chat de grupo. La unidad básica de los tips son los Satoshis (sat). 100.000.000 sat = 1 Bitcoin. Escribe 📚 /basics para saber más._ + +❤️ *Donar* +_Este bot no cobra ninguna comisión, pero su funcionamiento cuesta Satoshis. Si te gusta el bot, por favor considera apoyar este proyecto con una donación. Para donar, usa_ `/donate 1000`. + +%s + +⚙️ *Comandos* +*/tip* 🏅 Responder a un mensaje para dar tip: `/tip []` +*/balance* 👑 Consulta tu saldo: `/balance`. +*/send* 💸 Enviar fondos a un usuario: `/send @user o user@ln.tips []` +*/invoice* ⚡️ Recibir con Lightning: `/invoice []` +*/pay* ⚡️ Pagar con Lightning: `/pay ` +*/donate* ❤️ Donar al proyecto: `/donate 1000` +*/advanced* 🤖 Funciones avanzadas. +*/help* 📖 Leer esta ayuda.""" + +infoHelpMessage = """ℹ️ *Info*""" +infoYourLightningAddress = """Su Lightning Address _(Dirección Lightning)_ es `%s`.""" + +basicsMessage = """🧡 *Bitcoin* +_Bitcoin es la moneda de Internet. Es una moneda sin permisos y descentralizada que no tiene dueños ni autoridad que la controle. Bitcoin es un dinero sólido que es más rápido, más seguro y más inclusivo que el sistema financiero fiduciario._ + +🧮 *Economía* +_La unidad más pequeña de Bitcoin son los Satoshis (sat) y 100.000.000 sat = 1 Bitcoin. Sólo habrá 21 millones de Bitcoin. El valor de Bitcoin en moneda fiduciaria puede cambiar diariamente. Sin embargo, si vives en el patrón Bitcoin, 1 sat siempre será igual a 1 sat._ + +⚡️ *La Red Lightning* +_La red Lightning es un protocolo de pago que permite realizar pagos con Bitcoin de forma rápida y barata, con mínimo consumo de energía. Es lo que hace escalar Bitcoin a miles de millones de personas en todo el mundo._ + +📲 *Monederos Lightning* +_Tus fondos en este bot pueden ser enviados a cualquier otro monedero Lightning y viceversa. Los monederos Lightning recomendados para su teléfono son_ [Phoenix](https://phoenix.acinq.co/)_,_ [Breez](https://breez.technology/)_,_ [Muun](https://muun.com/)_ (sin custodia), o_ [Wallet of Satoshi](https://www.walletofsatoshi.com/) _(fácil)_. + +📄 *Código abierto* +_Este bot es gratuito y de_ [Código abierto](https://github.com/LightningTipBot/LightningTipBot)_. Puedes ejecutarlo en tu propio ordenador y utilizarlo en tu propia comunidad._ + +✈️ *Telegram* +_Añade este bot al chat de tu grupo de Telegram para enviar fondos usando /tip. Si haces al bot administrador del grupo, también limpiará los comandos para mantener el chat ordenado._ + +🏛 *Términos* +_No somos custodios de tus fondos. Actuaremos en su mejor interés, pero también somos conscientes de que la situación sin KYC es complicada hasta que demos con una solución óptima. Cualquier cantidad que pongas en tu monedero se considerará una donación. No nos des todo tu dinero. Ten en cuenta que este bot está en desarrollo beta. Utilízalo bajo tu propio riesgo._ + +❤️ *Donar* +_Este bot no cobra ninguna comisión, pero su funcionamiento cuesta satoshis. Si te gusta el bot, por favor considera apoyar este proyecto con una donación. Para donar, use_ `/donate 1000`""" + +helpNoUsernameMessage = """👋 Por favor, pon un nombre de usuario de Telegram.""" + +advancedMessage = """%s + +👉 *Comandos Inline* +*send* 💸 Enviar sats al chat: `%s send [] []` +*receive* 🏅 Solicita un pago: `... receive [] []` +*faucet* 🚰 Crear un grifo: `... faucet []` +*tipjar* 🍯 Crear un tipjar: `... tipjar []` + +📖 Puedes usar comandos _inline_ en todos los chats, incluso en las conversaciones privadas. Espera un segundo después de introducir un comando _inline_ y *haz clic* en el resultado, no pulses enter. + +⚙️ *Comandos avanzados* +*/transactions* 📊 List transactions +*/link* 🔗 Enlaza tu monedero a [ BlueWallet ](https://bluewallet.io/) o [ Zeus ](https://zeusln.app/) +*/lnurl* ⚡️ Lnurl recibir o pagar: `/lnurl` o `/lnurl [memo]` +*/nostr* 💜 Conectar a Nostr: `/nostr` +*/faucet* 🚰 Crear un grifo: `/faucet ` +*/tipjar* 🍯 Crear un tipjar: `/tipjar ` +*/group* 🎟 Create group tickets: `/group add []` +*/shop* 🛍 Browse shops: `/shop` or `/shop ` +*/generate* 🎆 Generate DALLE-2 images: `/generate `""" + +# GENERIC +enterAmountRangeMessage = """💯 Introduce un monto entre %s y %s.""" +enterAmountMessage = """💯 Introduce un monto.""" +enterUserMessage = """👤 Introduce un usuario.""" +errorReasonMessage = """🚫 Error: %s""" + +# START + +startSettingWalletMessage = """🧮 Configurando tu monedero...""" +startWalletCreatedMessage = """🧮 Monedero creado.""" +startWalletReadyMessage = """✅ *Tu monedero está listo.*""" +startWalletErrorMessage = """🚫 Error al iniciar tu monedero. Vuelve a intentarlo más tarde.""" +startNoUsernameMessage = """☝️ Parece que aún no tienes un @nombredeusuario en Telegram. No pasa nada, no necesitas uno para usar este bot. Sin embargo, para hacer un mejor uso de tu monedero, configura un nombre de usuario en los ajustes de Telegram. Luego, introduce /balance para que el bot pueda actualizar su registro de ti.""" + +# BALANCE + +balanceMessage = """👑 *Tu saldo:* %s. (%s USD / %s LKR)""" +balanceErrorMessage = """🚫 No se ha podido recuperar tu saldo. Por favor, inténtalo más tarde.""" + +# TIP + +tipDidYouReplyMessage = """¿Has respondido a un mensaje para dar un tip? Para responder a cualquier mensaje, haz clic con el botón derecho del ratón -> Responder en tu ordenador o desliza el mensaje en tu teléfono. Si quieres enviar directamente a otro usuario, utiliza el comando /send.""" +tipInviteGroupMessage = """ℹ️ Por cierto, puedes invitar a este bot a cualquier grupo para empezar a dar tips allí.""" +tipEnterAmountMessage = """¿Ingresaste un monto?""" +tipValidAmountMessage = """¿Ingresaste un monto válido?""" +tipYourselfMessage = """📖 No te puedes dar tips a ti mismo.""" +tipSentMessage = """💸 %s enviado a %s.""" +tipReceivedMessage = """🏅 %s te ha dado un tip de %s.""" +tipErrorMessage = """🚫 El tip ha fallado.""" +tipUndefinedErrorMsg = """por favor, inténtalo más tarde.""" +tipHelpText = """📖 Oops, eso no funcionó. %s + +*Uso:* `/tip []` +*Ejemplo:* `/tip 1000 ¡Meme de mal gusto!`""" + +# SEND + +sendValidAmountMessage = """¿Ingresaste un monto válido?""" +sendUserHasNoWalletMessage = """🚫 El usuario %s aún no ha creado un monedero.""" +sendSentMessage = """💸 %s enviado a %s.""" +sendPublicSentMessage = """💸 %s enviado(s) de %s a %s.""" +sendReceivedMessage = """🏅 %s te envió %s.""" +sendErrorMessage = """🚫 Envío fallido.""" +confirmSendMessage = """¿Desea pagar a %s?\n\n💸 Monto: %s""" +confirmSendAppendMemo = """\n✉️ %s""" +sendCancelledMessage = """🚫 Envío cancelado.""" +errorTryLaterMessage = """🚫 Error. Por favor, inténtelo de nuevo más tarde.""" +sendSyntaxErrorMessage = """¿Has introducido un monto y un destinatario? Puedes utilizar el comando /send para enviar a usuarios de Telegram como %s o a una dirección Lightning como LightningTipBot@ln.tips.""" +sendHelpText = """📖 Oops, eso no funcionó. %s + +*Uso:* `/send []` +*Ejemplo:* `/send 1000 @LightningTipBot Me gusta el bot ❤️`. +*Ejemplo:* `/send 1234 LightningTipBot@ln.tips`""" + +# INVOICE + +invoiceReceivedMessage = """⚡️ Recibiste %s.""" +invoiceReceivedCurrencyMessage = """⚡️ Recibiste %s (%s %s).""" +invoiceEnterAmountMessage = """¿Ingresaste un monto?""" +invoiceValidAmountMessage = """¿Ingresaste un monto válido?""" +invoiceHelpText = """📖 Oops, eso no funcionó. %s + +*Uso:* `/invoice []` +*Ejemplo:* `/invoice 1000 ¡Gracias!`""" + +# PAY + +paymentCancelledMessage = """🚫 Pago cancelado.""" +invoicePaidMessage = """⚡️ Pago enviado.""" +invoicePublicPaidMessage = """⚡️ Pago enviado por %s.""" +invalidInvoiceHelpMessage = """¿Has introducido una factura Lightning válida? Prueba con /send si quieres enviar a un usuario de Telegram o a una dirección Lightning.""" +invoiceNoAmountMessage = """🚫 No se pueden pagar las facturas sin un monto.""" +insufficientFundsMessage = """🚫 Fondos insuficientes. Tienes %s pero necesitas al menos %s.""" +feeReserveMessage = """⚠️ El envío de todo el saldo puede fallar debido a las tarifas de la red. Si falla, intenta enviar un poco menos.""" +invoicePaymentFailedMessage = """🚫 Pago fallido: %s""" +invoiceUndefinedErrorMessage = """No se pudo pagar la factura.""" +confirmPayInvoiceMessage = """¿Quieres enviar este pago?\n\n💸 Monto: %s""" +confirmPayAppendMemo = """\n✉️ %s""" +payHelpText = """📖 Oops, eso no funcionó. %s + +*Uso:* `/pay ` +*Ejemplo:* `/pay lnbc20n1psscehd...`""" + +# DONATE + +donationSuccess = """🙏 Gracias por tu donación.""" +donationErrorMessage = """🚫 Oh, no. Donación fallida.""" +donationProgressMessage = """🧮 Preparando tu donación...""" +donationFailedMessage = """🚫 Donación fallida: %s""" +donateEnterAmountMessage = """¿Ingresaste un monto?""" +donateValidAmountMessage = """¿Ingresaste un monto válido?""" +donateHelpText = """📖 Oops, eso no funcionó. %s + +*Uso:* `/donate ` +*Ejemplo:* `/donate 1000`""" + +# PHOTO + +photoQrNotRecognizedMessage = """🚫 No se ha podido reconocer una factura Lightning o una LNURL. Intenta centrar el código QR, recortar la foto o ampliarla.""" +photoQrRecognizedMessage = """✅ QR code: +`%s`""" + +# LNURL + +lnurlReceiveInfoText = """👇 Puedes usar esta LNURL estática para recibir pagos.""" +lnurlResolvingUrlMessage = """🧮 Solventando la dirección...""" +lnurlGettingUserMessage = """🧮 Preparando el pago...""" +lnurlPaymentFailed = """🚫 Pago fallido: %s""" +lnurlInvalidAmountMessage = """🚫 Monto inválido.""" +lnurlInvalidAmountRangeMessage = """🚫 El monto debe estar entre %s y %s.""" +lnurlNoUsernameMessage = """🚫 Tienes que establecer un nombre de usuario de Telegram para recibir pagos a través de LNURL.""" +lnurlHelpText = """📖 Oops, eso no funcionó. %s + +*Uso:* `/lnurl [monto] ` +*Ejemplo:* `/lnurl LNURL1DP68GUR...`""" + +# LNURL LOGIN + +confirmLnurlAuthMessager = """¿Desea iniciar sesión en %s?""" +lnurlSuccessfulLogin = """✅ Inicio de sesión exitoso.""" +loginButtonMessage = """✅ Inicio de sesión""" +loginCancelledMessage = """🚫 Inicio de sesión cancelado.""" + +# LNURL WITHDRAW + +confirmLnurlWithdrawMessage = """¿Desea realizar este retiro?\n\n💸 Monto: %s""" +lnurlPreparingWithdraw = """🧮 Preparando retiro...""" +lnurlWithdrawFailed = """🚫 Retiro fallido.""" +lnurlWithdrawCancelled = """🚫 Retiro cancelado.""" +lnurlWithdrawSuccess = """✅ Retiro solicitado.""" + +# LINK + +walletConnectMessage = """🔗 *Enlaza tu monedero* + +⚠️ Nunca compartas la URL o el código QR con nadie o podrán acceder a tus fondos. + +- *BlueWallet:* Pulse *Nuevo monedero*, *Importar monedero*, *Escanear o importar un archivo*, y escanea el código QR. +- *Zeus:* Copia la URL de abajo, pulsa *Añadir un nuevo nodo*, *Importar* (la URL), *Guardar configuración del nodo*.""" +couldNotLinkMessage = """🚫 No se ha podido vincular su monedero. Por favor, inténtalo más tarde.""" + +# FAUCET + +inlineQueryFaucetTitle = """🚰 Crea un grifo.""" +inlineQueryFaucetDescription = """Uso: @%s faucet """ +inlineResultFaucetTitle = """🚰 Crear un grifo %s.""" +inlineResultFaucetDescription = """👉 Haz clic aquí para crear un grifo en este chat.""" + +inlineFaucetMessage = """Pulsa ✅ para cobrar %s de este grifo de %s. + +🚰 Restante: %d/%s (dado a %d/%d usuarios) +%s""" +inlineFaucetEndedMessage = """🚰 Grifo vacío 🍺\n\n🏅 %s dados a %d usuarios.""" +inlineFaucetAppendMemo = """\n✉️ %s""" +inlineFaucetCreateWalletMessage = """Chatea con %s 👈 para gestionar tu cartera.""" +inlineFaucetCancelledMessage = """🚫 Grifo cancelado.""" +inlineFaucetInvalidPeruserAmountMessage = """🚫 El monto por usuario no es divisor de la capacidad.""" +inlineFaucetInvalidAmountMessage = """🚫 Monto inválido.""" +inlineFaucetSentMessage = """🚰 %s enviado(s) a %s.""" +inlineFaucetReceivedMessage = """🚰 %s te envió %s.""" +inlineFaucetHelpFaucetInGroup = """Crea un grifo en un grupo con el bot dentro o utiliza el 👉 comando _inline_ (/advanced para más).""" +inlineFaucetHelpText = """📖 Oops, eso no funcionó. %s + +*Uso:* `/faucet ` +*Ejemplo:* `/faucet 210 21`""" + +# INLINE SEND + +inlineQuerySendTitle = """💸 Enviar el pago a un chat.""" +inlineQuerySendDescription = """Uso: @%s send [] []""" +inlineResultSendTitle = """💸 Enviar %s.""" +inlineResultSendDescription = """👉 Haga clic para enviar %s a este chat.""" + +inlineSendMessage = """Pulse ✅ para recibir el pago de %s.\n\n💸 Cantidad: %s""" +inlineSendAppendMemo = """\n✉️ %s""" +inlineSendUpdateMessageAccept = """💸 %s enviado de %s a %s.""" +inlineSendCreateWalletMessage = """Chatea con %s 👈 para gestionar tu cartera.""" +sendYourselfMessage = """📖 No puedes pagarte a ti mismo.""" +inlineSendFailedMessage = """🚫 Envío fallido.""" +inlineSendInvalidAmountMessage = """🚫 La cantidad debe ser mayor que 0.""" +inlineSendBalanceLowMessage = """🚫 Tu saldo es demasiado bajo.""" + +# INLINE RECEIVE + +inlineQueryReceiveTitle = """🏅 Solicita un pago en un chat.""" +inlineQueryReceiveDescription = """Uso: @%s receive [] []""" +inlineResultReceiveTitle = """🏅 Recibir %s.""" +inlineResultReceiveDescription = """👉 Haga clic para solicitar un pago de %s.""" + +inlineReceiveMessage = """Pulsa 💸 para pagar a %s.\n\n💸 Monto: %s""" +inlineReceiveAppendMemo = """\n✉️ %s""" +inlineReceiveUpdateMessageAccept = """💸 %s enviado de %s a %s.""" +inlineReceiveCreateWalletMessage = """Chatea con %s 👈 para gestionar tu cartera.""" +inlineReceiveYourselfMessage = """📖 No puedes pagarte a ti mismo.""" +inlineReceiveFailedMessage = """🚫 Recepción fallida.""" +inlineReceiveCancelledMessage = """🚫 Recepción cancelada.""" + +# TIPJAR + +inlineQueryTipjarTitle = """🍯 Crear una alcancía.""" +inlineQueryTipjarDescription = """Uso: @%s alcancía """ +inlineResultTipjarTitle = """🍯 Crear una alcancía de %s.""" +inlineResultTipjarDescription = """👉 Haz clic aquí para crear una alcancía en este chat.""" + +inlineTipjarMessage = """Pulsa 💸 para *pagar %s* a esa alcancía de %s. + +🙏 Dados: *%d*/%s (por %d usuarios) +%s""" +inlineTipjarEndedMessage = """🍯 La alcancía de %s está llena ⭐️\n\n🏅 %s dados por %d usuários.""" +inlineTipjarAppendMemo = """\n✉️ %s""" +inlineTipjarCancelledMessage = """🚫 Alcancía cancelada.""" +inlineTipjarInvalidPeruserAmountMessage = """🚫 El monto por usuario no es divisor de la capacidad.""" +inlineTipjarInvalidAmountMessage = """🚫 Monto inválido.""" +inlineTipjarSentMessage = """🍯 %s enviado(s) a %s.""" +inlineTipjarReceivedMessage = """🍯 %s te envió %s.""" +inlineTipjarHelpTipjarInGroup = """Crea una alcancía en un grupo con el bot dentro o utiliza el 👉 comando _inline_ (/advanced para más).""" +inlineTipjarHelpText = """📖 Oops, eso no funcionó. %s + +*Uso:* `/tipjar ` +*Ejemplo:* `/tipjar 210 21`""" +# CONVERT LKR TO SAT +convertEnterAmountMessage = """¿Ingresaste una cantidad?""" +convertInvalidAmountMessage = """¿Ingresaste una cantidad válida?""" +convertPriceErrorMessage = """🚫 No se pudo obtener el precio.""" +convertResultMessage = """%.2f LKR son aproximadamente %s.""" + +# CONVERT SAT TO FIAT +convertSatsResultMessage = """%s son aproximadamente %s USD / %s LKR""" diff --git a/translations/fi.toml b/translations/fi.toml new file mode 100644 index 00000000..bad67455 --- /dev/null +++ b/translations/fi.toml @@ -0,0 +1,416 @@ +# COMMANDS + +helpCommandStr = """ohje""" +basicsCommandStr = """peruskomennot""" +tipCommandStr = """tippi""" +balanceCommandStr = """saldo""" +sendCommandStr = """lähetä""" +invoiceCommandStr = """laskuta""" +payCommandStr = """maksa""" +donateCommandStr = """lahjoita""" +advancedCommandStr = """lisäkomennot""" +transactionsCommandStr = """tapahtumat""" +logCommandStr = """loki""" +listCommandStr = """lista""" + +linkCommandStr = """linkki""" +lnurlCommandStr = """lnurl""" +faucetCommandStr = """hana""" + +tipjarCommandStr = """tippikulho""" +receiveCommandStr = """vastaanota""" +hideCommandStr = """piilota""" +volcanoCommandStr = """tulivuori""" +showCommandStr = """näytä""" +optionsCommandStr = """valinnat""" +settingsCommandStr = """asetukset""" +saveCommandStr = """tallenna""" +deleteCommandStr = """poista""" +infoCommandStr = """tiedot""" + +# NOTIFICATIONS + +cantDoThatMessage = """Et voi tehdä tuota.""" +cantClickMessage = """Et voi painaa tätä nappia.""" +balanceTooLowMessage = """Saldosi on liian pieni.""" + +# BUTTONS + +sendButtonMessage = """✅ Lähetä""" +payButtonMessage = """✅ Maksa""" +payReceiveButtonMessage = """💸 Maksa""" +receiveButtonMessage = """✅ Vastaanota""" +withdrawButtonMessage = """✅ Nosta""" +cancelButtonMessage = """🚫 Peruuta""" +collectButtonMessage = """✅ Kerää""" +nextButtonMessage = """Seuraava""" +backButtonMessage = """Edellinen""" +acceptButtonMessage = """Hyväksy""" +denyButtonMessage = """Hylkää""" +tipButtonMessage = """Tippi""" +revealButtonMessage = """Paljasta""" +showButtonMessage = """Näytä""" +hideButtonMessage = """Piilota""" +joinButtonMessage = """Liity""" +optionsButtonMessage = """Valinnat""" +settingsButtonMessage = """Asetukset""" +saveButtonMessage = """Tallenna""" +deleteButtonMessage = """Poista""" +infoButtonMessage = """Tiedot""" + +cancelButtonEmoji = """🚫""" +payButtonEmoji = """💸""" + +# HELP + +helpMessage = """⚡️ *Lompakko* +_Tämä botti on Bitcoin Lightning -lompakko ja sitä käytetään Telegram:in sisällä tippien lähettämiseen. Botti on lisättävä jäseneksi ryhmäkeskusteluun, jotta se voi toimia. Tippien rahayksikkö on satoshi (sat). 100,000,000 sat = 1 Bitcoin. Kirjoita 📚 /basics lukeaksesi lisää._ + +❤️ *Lahjoitus* +_Tämä botti ei veloita kuluja, mutta sen ylläpidosta aiheutuu kuluja. Jos tykkäät botista, niin harkitsethan lahjoituksen tekemistä projektille. Lahjoituksen voit tehdä komennolla_ `/donate 1000` + +%s + +⚙️ *Komennot* +*/tip* 🏅 Lähetä tippi liittämällä vastausviestiin: `/tip []` +*/balance* 👑 Tarkista saldosi: `/balance` +*/send* 💸 Lähetä varoja käyttäjälle: `/send @user or user@ln.tips []` +*/invoice* ⚡️ Vastaanota Lightning-maksu: `/invoice []` +*/pay* ⚡️ Lähetä Lightning-maksu: `/pay ` +*/donate* ❤️ Lahjoitus LightningTipBot-tiimille: `/donate 1000` +*/advanced* 🤖 Lisäasetukset. +*/help* 📖 Lue tämä ohje.""" + +infoHelpMessage = """ℹ️ *Tiedot*""" +infoYourLightningAddress = """Lightning Address -osoitteesi on `%s`""" + +basicsMessage = """🧡 *Bitcoin* +_Bitcoin on internetin raha. Se on hajautettu ja vapaa, eikä sillä ole johtajia tai hallinnoijia. Bitcoin on kovaa rahaa. Se on myös nopeampi, turvallisempi ja laajemmin toimiva kuin perinteiset pankkijärjestelmät tai valuutat._ + +🧮 *Ekonomia* +_Bitcoinin pienin yksikkö on satoshi (sat) ja 100.000.000 sat = 1 Bitcoin. Bitcoineja tulee kostkaan olemaan enintään 21 miljoonaa kappaletta. Bitcoinin päivittäinen valuuttavaihtokurssi vaihtelee päivittän, mutta jos elät Bitcoin standardin mukaisesti on kuitenkin 1 sat aina 1 sat._ + +⚡️ *Lightning-verkko* +_Lightning-verkko on maksuprotokolla, joka mahdollistaa nopeat ja edulliset bitcoin-siirrot, eikä se juurikaan kuluta energiaa. Sen avulla miljardit ihmiset ympäri maailmaa pystyvät käyttämään Bitcoinia._ + +📲 *Lightning-lompakot* +_Tämän botin lompakosta voit lähettää, tai vastaanottaa siihen, varoja kenen tahansa omistaman Lightning-lompakon välillä. Suositeltuja muita lompakkoja mobiililaitteisiin ovat_ [Phoenix](https://phoenix.acinq.co/)_,_ [Breez](https://breez.technology/)_,_ [Muun](https://muun.com/)_ (non-custodial), tai_ [Wallet of Satoshi](https://www.walletofsatoshi.com/) _(helppo)_. + +📄 *Open Source* +_Tämä bottiohjelmisto on vapaa ja_ [open source](https://github.com/LightningTipBot/LightningTipBot) _ohjelmisto. Voit ajaa bottia omassa tietokoneessasi ja käyttää sitä yhdessä lähipiirisi kanssa._ + +✈️ *Telegram* +_Lisää tämä botti Telegram-ryhmäkeskusteluun, ja /tip -komennolla voit tipata kuita keskustelijoita. Kun määrität botin ryhmän pääkäyttäjäksi (admin), niin se myös siivoaa myös bottikomennot pois keskustelusta ja ryhmäsi sisältö pysyy siistinä._ + +🏛 *Ehdot* +_Me emme pidä varojasi hallussa. Toimimme sinun parhaaksesi ja tiedämme KYC-henkilötunnistautumisen vaatimukset. Ne ovat aika mutkikkaita, kunnes keksimme asiaan ratkaisun. Käsittelemme kaikkia lompakossasi olevia varoja lahjoituksina. Älä siis lahjoita meille kaikkia varojasi. Huomioi myös että tämä botti on ns beta-kehitysvaiheessa ja huomioit riskit botin mahdollisesti tekemistä virheistä._ + +❤️ *Lahjoitus* +_Tämä botti ei veloita kuluja, mutta sen ylläpidosta aiheutuu kuluja. Jos tykkäät botista, niin harkitsethan lahjoituksen tekemistä projektille. Lahjoituksen voit tehdä komennolla_ `/donate 1000`""" + +helpNoUsernameMessage = """👋 Ole hyvä ja määritä Telegram-käyttäjänimesi.""" + +advancedMessage = """%s + +👉 *Keskustelun komennot* +*send* 💸 Lähetä satteja keskusteluun: `%s send [] []` +*receive* 🏅 Pyydä maksua: `... receive [] []` +*faucet* 🚰 Luo hana: `... faucet []` +*tipjar* 🍯 Luo tippikulho: `... tipjar []` + +📖 Keskustelu komennot ovat käytettävissä kaikissa keskusteluissa, jopa kahdenkeskisissä. Kun olet kirjoittanut keskustelussa komennon, niin odota hetkinen vaihtoehtoja - älä paina Enteriä. + +⚙️ *Edistyneemmät komennot* +*/transactions* 📊 Maksutapahtumien listaus +*/link* 🔗 Linkitä lompakkosi [BlueWallet](https://bluewallet.io/) tai [Zeus](https://zeusln.app/) +*/lnurl* ⚡️ Vastaanota tai maksa Lnurl: `/lnurl` or `/lnurl [memo]` +*/nostr* 💜 Yhdistä nostr: `/nostr` +*/faucet* 🚰 Luo hana: `/faucet ` +*/tipjar* 🍯 Luo tippikulho: `/tipjar ` +*/group* 🎟 Ryhmäkeskustelun toiminnot: `/group` +*/shop* 🛍 Selaa kauppoja: `/shop` or `/shop ` +*/generate* 🎆 Luo DALLE-2 kuvia: `/generate `""" + +# GENERIC +enterAmountRangeMessage = """💯 Syötä määrä %s ja %s väliltä.""" +enterAmountMessage = """💯 Syötä määrä.""" +enterUserMessage = """👤 Syötä käyttäjä.""" +enterTextMessage = """⌨️ Syötä teksti.""" +errorReasonMessage = """🚫 Virhe: %s""" + +# START + +startSettingWalletMessage = """🧮 Lompakkoasi määritetään...""" +startWalletCreatedMessage = """🧮 Lompakkosi on luotu.""" +startWalletReadyMessage = """✅ *Lompakkosi on valmis.*""" +startWalletErrorMessage = """🚫 Lompakon määritys tai avaus epäonnistui. Yritä myöhemmin uudelleen.""" +startNoUsernameMessage = """☝️ Näyttäisi että sinulla ei ole vielä Telegram @username -käyttäjätunnusta. Se ei haittaa, voit kuitenkin käyttää tätä bottia. Saat kuitenkin botin käytöstä enemmän irti, kun asetat itsellesi käyttäjänimen Telegram-asetuksista. Sen tehtyäsi syötä komento /balance ja botti päivittää tietosi.""" + +# BALANCE + +balanceMessage = """👑 *Saldosi on:* %s. (%s USD / %s LKR)""" +balanceErrorMessage = """🚫 Saldosi hakeminen epäonnistui. Yritä myöhemmin uudelleen.""" + +# TIP + +tipDidYouReplyMessage = """Lähetitkö vastauksen siihen viestiin, mitä halusit tipata? Vastaa mihintahansa viestiin tietokoneella klikkaamalla oikealla hiiren napilla viestiä, ja valitse Vastaa tai Reply, mobiililaitteella voit pyyhkäistä viestiä. Jos haluat lähettää suoraan varoja toiselle käyttäjälle, niin se onnistuu /send -komennolla.""" +tipInviteGroupMessage = """ℹ️ Muuten, voit kutsua tämän botin mihin tahansa ryhmään ja käyttää bottia siellä tippien lähettämiseen.""" +tipEnterAmountMessage = """Syötithän määrän?""" +tipValidAmountMessage = """Syötithän määrän oikein?""" +tipYourselfMessage = """📖 Et voi lähettää tippiä itsellesi.""" +tipSentMessage = """💸 %s läheteettiin %s:lle.""" +tipReceivedMessage = """🏅 %s lähetti sinulle %s tipin.""" +tipErrorMessage = """🚫 Tippi epäonnistui.""" +tipUndefinedErrorMsg = """yritä myöhemmin uudelleen.""" +tipHelpText = """📖 Hups, sepä ei toiminutkaan. %s + +*Muoto:* `/tip []` +*Esimerkki:* `/tip 1000 Kiitti meemistä!`""" + +# SEND + +sendValidAmountMessage = """Syötithän määrän oikein?""" +sendUserHasNoWalletMessage = """🚫 Käyttäjällä %s ei ole lompakkoa.""" +sendSentMessage = """💸 %s lähetettiin %s:lle.""" +sendPublicSentMessage = """💸 %s lähetettiin %s:ltä to %s:lle.""" +sendReceivedMessage = """🏅 %s lähetti sinulle %s.""" +sendErrorMessage = """🚫 Lähetys epäonnistu.""" +confirmSendMessage = """Haluatko lähettää vastaanottajalle %s?\n\n💸 Määrä: %s""" +confirmSendAppendMemo = """\n✉️ %s""" +sendCancelledMessage = """🚫 Lähetys peruutettu.""" +errorTryLaterMessage = """🚫 Virhe. Yritä myöhemmin uudelleen.""" +sendSyntaxErrorMessage = """Annoithan määrän ja vastaanottajan? Lähetä /send komennolla joko Telegram-käyttäjänimelle (kuten %s) tai Lightning Address -osoitteeseen muotoa LightningTipBot@ln.tips.""" +sendHelpText = """📖 Hups, sepä ei toiminutkaan. %s + +*Muoto:* `/send []` +*Esimerkki:* `/send 1000 @LightningTipBot Rakastan bottia ❤️` +*Esimerkki:* `/send 1234 LightningTipBot@ln.tips`""" + +# INVOICE + +invoiceReceivedMessage = """⚡️ Vastaanotit %s.""" +invoiceReceivedCurrencyMessage = """⚡️ Vastaanotit %s (%s %s).""" +invoiceEnterAmountMessage = """Syötithän määrän?""" +invoiceValidAmountMessage = """Syötithän määrän oikein?""" +invoiceHelpText = """📖 Hups, sepä ei toiminutkaan. %s + +*Muoto:* `/invoice []` +*Esimerkki:* `/invoice 1000 Kiitos!`""" +invoicePaidText = """✅ Lasku maksettu.""" + +# PAY + +paymentCancelledMessage = """🚫 Maksu peruutettu.""" +invoicePaidMessage = """⚡️ Maksu lähetettiin.""" +invoicePublicPaidMessage = """⚡️ Maksun lähetti %s.""" +invalidInvoiceHelpMessage = """Syötithän Lightning-laskun oikein? Käytä /send -komentoa jos haluat lähettää Telegram-käyttäjälle tai Lightning Address -osoitteeseen.""" +invoiceNoAmountMessage = """🚫 Laskua ei voi maksaa ilman määrää.""" +insufficientFundsMessage = """🚫 Varat eivät riitä. Sinulla on %s mutta tarvitset vähintään %s.""" +feeReserveMessage = """⚠️ Koko saldosi lähettäminen ei todennäköisesti onnistu, koska maksun käsittelystä menee kuluja. Varaa vähintään 1% kuluihin.""" +invoicePaymentFailedMessage = """🚫 Maksu epäonnistui: %s""" +invoiceUndefinedErrorMessage = """Laskun maksu epäonnistui.""" +confirmPayInvoiceMessage = """Vahvista maksun lähetys?\n\n💸 Määrä: %s""" +confirmPayAppendMemo = """\n✉️ %s""" +payHelpText = """📖 Hups, sepä ei toiminutkaan. %s + +*Muoto:* `/pay ` +*Esimerkki:* `/pay lnbc20n1psscehd...`""" + +# DONATE + +donationSuccess = """🙏 Kiitoksia lahjoituksesta.""" +donationErrorMessage = """🚫 Oi ei! Lahjoitus epäonnistui.""" +donationProgressMessage = """🧮 Valmistellaan lahjoitusta...""" +donationFailedMessage = """🚫 Lahjoitus epäonnistui: %s""" +donateEnterAmountMessage = """Syötithän määrän?""" +donateValidAmountMessage = """Syötithän määrän oikein?""" +donateHelpText = """📖 Hups, sepä ei toiminutkaan. %s + +*Muoto:* `/donate ` +*Esimerkki:* `/donate 1000`""" + +# PHOTO + +photoQrNotRecognizedMessage = """🚫 Lightning- tai LNURL -laskun tunnistus epäonnistui. Koeta keskittää QR-koodi, leikkaa kuvaa tai zoomaa tarkemmaksi.""" +photoQrRecognizedMessage = """✅ QR-koodi: +`%s`""" + +# LNURL + +lnurlReceiveInfoText = """👇 Voit ottaa maksuja vastaan tämän kiinteän LNURL:in avulla.""" +lnurlResolvingUrlMessage = """🧮 Selvitetään osoitetta...""" +lnurlGettingUserMessage = """🧮 Valmistellaan maksua...""" +lnurlPaymentFailed = """🚫 Maksu epäonnistui: %s""" +lnurlInvalidAmountMessage = """🚫 Virheellinen määrä.""" +lnurlInvalidAmountRangeMessage = """🚫 Määrän tulee olla %s ja %s väliltä.""" +lnurlNoUsernameMessage = """🚫 LNURL:in avulla maksujen vastaanotto edellyttää että, sinulla on Telegram-käyttäjätunnus määriteltynä.""" +lnurlHelpText = """📖 Hups, sepä ei toiminutkaan. %s + +*Muoto:* `/lnurl [amount] ` +*Esimerkki:* `/lnurl LNURL1DP68GUR...`""" + +# LNURL LOGIN + +confirmLnurlAuthMessager = """Haluatko kirjautua %s?""" +lnurlSuccessfulLogin = """✅ Kirjautuminen onnistui.""" +loginButtonMessage = """✅ Kirjautuminen""" +loginCancelledMessage = """🚫 Kirjautuminen peruutettiin.""" + +# LNURL WITHDRAW + +confirmLnurlWithdrawMessage = """Haluatko toteuttaa noston?\n\n💸 Määrä: %s""" +lnurlPreparingWithdraw = """🧮 Valmistellaan nostoa...""" +lnurlWithdrawFailed = """🚫 Nosto epäonnistui.""" +lnurlWithdrawCancelled = """🚫 Nosto peruutettiin.""" +lnurlWithdrawSuccess = """✅ Nosto käsitelty.""" + +# LINK + +walletConnectMessage = """🔗 *Linkitä lompakkosi* + +⚠️ Älä koskaan paljasta seuraavia URL-osoitetta tai QR-koodia kenellekään. Niiden avulla pääsee käsiksi botissa oleviin varoihisi. jos tarvitse API-avaimia, käytä /api -komentoa. + +- *BlueWallet:* Paina *New wallet*, *Import wallet*, *Scan or import a file*, ja lue QR-koodi. +- *Zeus:* Kopioi alla oleva URL, valitse *Add a new node* ja Node interface kohdasta *LNDHub*, syötä URL ja paina *Save Node Config*.""" +couldNotLinkMessage = """🚫 Lompakon linkitys epäonnistui. Yritä myöhemmin uudelleen.""" +linkHiddenMessage = """🔍 Linkki on piilotettu. Käytä /link -komentoa nähdäksesi sen uudelleen.""" + +# API + +apiConnectMessage = """🔗 *Sinun API avaimet* + +⚠️ Älä koskaan paljasta API-avaimia kenellekään. Niiden avulla pääsee käsiksi botissa oleviin varoihisi. Käytä /link -komentoa yhdistääksesi ulkoiseen lompakkoon. + +- *Admin key:* `%s` +- *Invoice key:* `%s`""" +apiHiddenMessage = """🔍 Avaimet on piilotettu. Käytä /api -komentoa nähdäksesi ne uudelleen.""" + +# FAUCET + +inlineQueryFaucetTitle = """🚰 Luo hana.""" +inlineQueryFaucetDescription = """Muoto: @%s faucet """ +inlineResultFaucetTitle = """🚰 Luo hana %s kapasiteetilla.""" +inlineResultFaucetDescription = """👉 Paina tästä luodaksesi hanan tähän keskusteluun.""" + +inlineFaucetMessage = """Painamalla ✅ nostat %s %s:n tekemästä hanasta. + +🚰 Jäljellä: %d/%s (nostettu %d/%d käyttäjälle) +%s""" +inlineFaucetEndedMessage = """🚰 Hana on tyhjä 🍺\n\n🏅 %s annettiin %d käyttäjälle.""" +inlineFaucetAppendMemo = """\n✉️ %s""" +inlineFaucetCreateWalletMessage = """Hallinnoi lompakkoasti viestillä %s:lle 👈 .""" +inlineFaucetCancelledMessage = """🚫 Hana suljettu.""" +inlineFaucetInvalidPeruserAmountMessage = """🚫 Hanan käyttäjäkohtainen kapasiteetti ei jakaudu tasan tai se on liian pieni (min 5 sat).""" +inlineFaucetInvalidAmountMessage = """🚫 Virheellinen määrä.""" +inlineFaucetSentMessage = """🚰 %s lähetetty %s.""" +inlineFaucetReceivedMessage = """🚰 %s lähetti sinulle %s.""" +inlineFaucetHelpFaucetInGroup = """Luo botin avulla ryhmäkeskusteluun hana tai käytä 👉 keskustelun komentoja (/advanced lisätoimintoja).""" +inlineFaucetAlreadyTookMessage = """🚫 Olet jo nostanut varoja tästä hanasta.""" +inlineFaucetHelpText = """📖 Hups, sepä ei toiminutkaan. %s + +*Muoto:* `/faucet ` +*Esimerkki:* `/faucet 210 21`""" + +# INLINE SEND + +inlineQuerySendTitle = """💸 Lähetä maksu keskusteluun.""" +inlineQuerySendDescription = """Muoto: @%s send [] []""" +inlineResultSendTitle = """💸 Lähetä %s.""" +inlineResultSendDescription = """👉 Paina tästä lähettääksesi %s tähän keskusteluun.""" + +inlineSendMessage = """Paina ✅ vastaanottaaksesi maksun %s:ltä.\n\n💸 Määrä: %s""" +inlineSendAppendMemo = """\n✉️ %s""" +inlineSendUpdateMessageAccept = """💸 %s lähetty %s:ltä %s:lle.""" +inlineSendCreateWalletMessage = """Avaa keskustelu %s :lle 👈 ja hallinnoi lompakkoasi.""" +sendYourselfMessage = """📖 Et voi maksaa itsellesi.""" +inlineSendFailedMessage = """🚫 Lähetys epäonnistui.""" +inlineSendInvalidAmountMessage = """🚫 Määrän pitää olla nollaa suurempi.""" +inlineSendBalanceLowMessage = """🚫 Saldosi on liian pieni.""" + +# INLINE RECEIVE + +inlineQueryReceiveTitle = """🏅 Pyydä maksua keskustelussa.""" +inlineQueryReceiveDescription = """Muoto: @%s receive [] []""" +inlineResultReceiveTitle = """🏅 Vastaanota %s.""" +inlineResultReceiveDescription = """👉 Paina tästä vastaanottaaksesi %s.""" + +inlineReceiveMessage = """Paina 💸 maksaaksesi %s:lle.\n\n💸 Määrä: %s""" +inlineReceiveAppendMemo = """\n✉️ %s""" +inlineReceiveUpdateMessageAccept = """💸 %s lähetetty %s:ltä %s:lle.""" +inlineReceiveCreateWalletMessage = """Avaa keskustelu %s :lle 👈 ja hallinnoi lompakkoasi.""" +inlineReceiveYourselfMessage = """📖 Et voi maksaa itsellesi.""" +inlineReceiveFailedMessage = """🚫 Vastaanotto epäonnistui.""" +inlineReceiveCancelledMessage = """🚫 Vastaanotto peruutettu.""" + +# TIPJAR + +inlineQueryTipjarTitle = """🍯 Luo tippikulho.""" +inlineQueryTipjarDescription = """Muoto: @%s tipjar """ +inlineResultTipjarTitle = """🍯 Luo %s tippikulho.""" +inlineResultTipjarDescription = """👉 Paina tästä luodaksesi tippikulhon tähän keskusteluun.""" + +inlineTipjarMessage = """Paina 💸 *maksaaksesi %s* %s:n luomaan tippikulhoon. + +🙏 Tippikulhossa: *%d*/%s (%d käyttäjältä) +%s""" +inlineTipjarEndedMessage = """🍯 %s:n tippikulho on täynnä⭐️\n\n🏅 %s saatin %d käyttäjältä.""" +inlineTipjarAppendMemo = """\n✉️ %s""" +inlineTipjarCancelledMessage = """🚫 Tippikulho poistettu.""" +inlineTipjarInvalidPeruserAmountMessage = """🚫 Käyttäjäkohtainen määrä ei ole jaollinen kapasiteetilla.""" +inlineTipjarInvalidAmountMessage = """🚫 Virheellinen määrä.""" +inlineTipjarSentMessage = """🍯 %s lähetty %s:lle.""" +inlineTipjarReceivedMessage = """🍯 %s lähetti sinulle %s.""" +inlineTipjarHelpTipjarInGroup = """Luo tippikulho ryhmään, jossa botti on jäsenenä tai käytä 👉 keskustelun komentoa (/advanced lisätoimintoja).""" +inlineTipjarHelpText = """📖 Hups, sepä ei toiminutkaan. %s + +*Muoto:* `/tipjar ` +*Esimerkki:* `/tipjar 210 21`""" + +# GROUP TICKETS +groupAddGroupHelpMessage = """📖 Hups, sepä ei toiminutkaan. Tämä toiminto on käytettävissä vain ryhmäkeskusteluissa ja sitä voi käyttää ainoastaan ryhmän omistaja.\nMuoto: `/group add []`\nExample: `/group add TheBestBitcoinGroup 1000`""" +groupJoinGroupHelpMessage = """📖 Hups, sepä ei toiminutkaan. Yritä uudelleen.\nMuoto: `/join `\nExample: `/join TheBestBitcoinGroup`""" +groupClickToJoinMessage = """🎟 [Paina tästä](%s) 👈 liittyäksesi `%s`.""" +groupTicketIssuedGroupMessage = """🎟 Käyttäjä %s on saanut pääsyn tähän ryhmään.""" +groupPayInvoiceMessage = """🎟 Maksa yllä oleva lasku, jotta voit liittyä ryhmään %s.""" +groupBotIsNotAdminMessage = """🚫 Hups, sepä ei toiminutkaan. Sinun pitää lisätä minut ryhmän pääkäyttäjäksi ja antaa minulle oikeudet lisätä ja bannata käyttäjiä.""" +groupNameExists = """🚫 Tämän niminen ryhmä on jo olemassa. Valitse ryhmälle uusi nimi.""" +groupAddedMessagePrivate = """🎟 Ryhmään `%s` on lisätty pääsymaksut.\n\nAlias: `%s` Hinta: %s\n\nKun haluat pääsyn tähän ryhmään, aloita keskustelu %s:n kanssa ja anna komento `/join %s`.""" +groupAddedMessagePublic = """🎟 Pääsymaksut otettu käyttöön.""" +groupNotFoundMessage = """🚫 Tämän nimistä ryhmää ei löydy.""" +groupReceiveTicketInvoiceCommission = """🎟 Vastaanotit *%s* (mistä on vähennetty %s komissio) käyttäjän %s pääsymaksuna ryhmään `%s` .""" +groupReceiveTicketInvoice = """🎟 Vastaanotit *%s* käyttäjän %s pääsymaksuna ryhmään `%s`.""" +commandPrivateMessage = """Käytä tätä komentoa %s:n kanssa kahdenkeskisessä keskustelussa.""" +groupHelpMessage = """👥 *Ryhmäkomennot* + +🎟 *Julkiset pääsymaksut* + +Pääkäyttäjille (ryhmäkeskustelussa): `/group ticket `\nExample: `/group ticket 1000` + +🎟 *Yksityiset pääsymaksut* + +Veloita pääsymaksuja sinun _yksityiseen ryhmään_ ja pidät spammibotit poissa. + +*Ohjeet ryhmän pääkäyttäjille:* + +1) Kutsu ryhmääsi jäseneksi %s ja anna sille pääkäyttäjä (admin) oikeudet. +2) Muuta ryhmäsi yksityiseksi (private). +3) Ryhmässä, sinun (ryhmän omistaja) pitää antaa komento `/group add []`. + +_Kulut: botti veloittaa 10%% +10 sat komission edullisista pääsymaksuista. Jos pääsymaksu on >= 1000 sat, niin komissio on 2%% + 100 sat._ + +*Ohjeet ryhmän jäsenille:* + +Liittyäksesi ryhmään, aloita keskustelu %s:n kanssa ja kirjoita kahdenkeskiseen keskusteluun viesti `/join `. + +📖 *Muoto:* +Pääkäyttäjille (ryhmissä): `/group add []`\nEsimerkki: `/group add TheBestBitcoinGroup 1000` +Käyttäjille (yksityskeskusteluissa): `/join `\nEsimerkki: `/join TheBestBitcoinGroup`""" + +# DALLE GENERATE +generateDalleHelpMessage = """Luo OpenAI DALLE 2. -kuvia\nUsage: `/generate `\nPrice: 1000 sat""" +generateDallePayInvoiceMessage = """Maksa tämä lasku ja neljä kuvaa luotaan 👇""" +generateDalleGeneratingMessage = """Kuvia luodaan. Odota hetkinen..."""# CONVERT LKR TO SAT +convertEnterAmountMessage = """Syötithän määrän?""" +convertInvalidAmountMessage = """Syötithän määrän oikein?""" +convertPriceErrorMessage = """🚫 Hintaa ei saatu haettua.""" +convertResultMessage = """%.2f LKR on noin %s.""" diff --git a/translations/fr.toml b/translations/fr.toml new file mode 100644 index 00000000..e2c6a11a --- /dev/null +++ b/translations/fr.toml @@ -0,0 +1,315 @@ +# COMMANDS + +helpCommandStr = """Aide""" +basicsCommandStr = """basics""" +tipCommandStr = """tip""" +balanceCommandStr = """Solde""" +sendCommandStr = """Envoyer""" +invoiceCommandStr = """invoice""" +payCommandStr = """Payer""" +donateCommandStr = """Donation""" +advancedCommandStr = """Avancée""" +transactionsCommandStr = """Transactions""" +logCommandStr = """log""" +listCommandStr = """list""" + +linkCommandStr = """link""" +lnurlCommandStr = """lnurl""" +faucetCommandStr = """faucet""" + +tipjarCommandStr = """tipjar""" +receiveCommandStr = """Recevoir""" +hideCommandStr = """masquer""" +volcanoCommandStr = """volcano""" +showCommandStr = """montrer""" +optionsCommandStr = """options""" +settingsCommandStr = """réglages""" +saveCommandStr = """sauvegarder""" +deleteCommandStr = """supprimer""" +infoCommandStr = """info""" + +# NOTIFICATIONS + +cantDoThatMessage = """Vous ne pouvez pas faire ça.""" +cantClickMessage = """Ce bouton n'est pas cliquable.""" +balanceTooLowMessage = """Votre solde est trop bas.""" + +# BUTTONS + +sendButtonMessage = """✅ Envoyer""" +payButtonMessage = """✅ Payer""" +payReceiveButtonMessage = """💸 Payé""" +receiveButtonMessage = """✅ Reçu""" +cancelButtonMessage = """🚫 Annuler""" +collectButtonMessage = """✅ Collect""" +nextButtonMessage = """Suivant""" +backButtonMessage = """Précédent""" +acceptButtonMessage = """Accepter""" +denyButtonMessage = """Refuser""" +tipButtonMessage = """Tip""" +revealButtonMessage = """Révéler""" +showButtonMessage = """Afficher""" +hideButtonMessage = """Masquer""" +joinButtonMessage = """Joindre""" +optionsButtonMessage = """Options""" +settingsButtonMessage = """Réglages""" +saveButtonMessage = """Sauvegarder""" +deleteButtonMessage = """Supprimer""" +infoButtonMessage = """Info""" + +# AIDE + +helpMessage = """⚡️ *Wallet* +_Ce bot est un Bitcoin Lightning wallet qui permet d'envoyer des pourboires sur Telegram. Pour envoyer des pourboires, ajoutez le bot à un groupe. L'unité de base des pourboires est les Satoshis (sat). 100,000,000 sat = 1 Bitcoin. Tapez 📚 /basics pour plus d'info._ + +❤️ *Donation* +_Ce bot ne vous facture pas de frais mais nous coûte des Satoshis à opérerer. Si vous aimez ce bot, n'hésitez pas à faire un don pour soutenir le développement. Pour donner, utilisez_ `/donate 1000` + +%s + +⚙️ *Commandes* +*/tip* 🏅 Répondre à un message pour envoyer un pourboire : `/tip []` +*/balance* 👑 Afficher votre solde: `/balance` +*/send* 💸 Envoyer un pourboire à un utilisateur: `/send @user ou user@ln.tips []` +*/invoice* ⚡️ Recevoir avec Lightning : `/invoice []` +*/pay* ⚡️ Payer avec Lightning : `/pay ` +*/donate* ❤️ Faire une donation au projet : `/donate 1000` +*/advanced* 🤖 Fonctionnalités avancées. +*/help* 📖 Aide.""" + +infoHelpMessage = """ℹ️ *Info*""" +infoYourLightningAddress = """Votre adresse Lightning est `%s`""" + +basicsMessage = """🧡 *Bitcoin* +_Bitcoin est la monnaie d'internet. Aucune authorité ne contrôle ce réseau décentralisé et accessible à tous. Bitcoin est une monnaie saine qui est rapide, fiable et plus inclusive que le système financier traditionnel._ + +🧮 *Économie* +_La plus petite unité du Bitcoin est un Satoshis (sat). 100,000,000 sat = 1 Bitcoin. Il n'existe que 21 millions de Bitcoin. La valeur du Bitcoin fluctue tous les jours. Néanmoins, si vous vivez avec le standard du Bitcoin, 1 sat sera toujours égal à 1 sat._ + +⚡️ *Le Lightning Network* +_Le Lightning Network est un protocole de paiement qui permet d'envoyer et de recevoir rapidement du Bitcoin, à moindre coût et sans grande consommation d'énergie. Ce réseau rend accessible Bitcoin à des milliards de personnes._ + +📲 *Lightning Wallets* +_Vos fonds sur ce bot peuvent être envoyés à n'importe quel Lightning Wallet. Nous recommandons de télécharger sur votre téléphone les wallets suivant_ [Phoenix](https://phoenix.acinq.co/)_,_ [Breez](https://breez.technology/)_,_ [Muun](https://muun.com/)_ (non-custodial), ou_ [Wallet of Satoshi](https://www.walletofsatoshi.com/) _(facile)_. + +📄 *Logiciel libre* +_Ce bot est gratuit et_ [libre de droits](https://github.com/LightningTipBot/LightningTipBot)_. Vous pouvez éxecuter le bot sur votre ordinateur et l'utiliser dans votre communauté._ + +✈️ *Telegram* +_Ajoutez ce bot à un groupe Telegram pour envoyer des pourboires en répondant à des messages. Si vous faites de ce bot un adminstrateur d'un groupe, il va réorganiser les commandes pour plus de lisibilité._ + +🏛 *Conditions d'utilisation* +_Ce bot n'assure pas la garde de vos fonds. Nous agissons toujours dans votre intérêt et nous sommes consciens de devoir faire des compromis en raison de l'absence de KYC. Les fonds de votre portefeuille sont aujourd'hui considérés comme une donation. Ne nous donnez pas tout votre argent. Ce bot est une version beta, faites attention s'il vous plaît. À utiliser à vos risques et périls._ + +❤️ *Donation* +_Ce bot ne vous facture pas de frais mais coûte des Satoshis à opérer. Si vous aimez ce bot, n'hésitez pas à faire un don pour soutenir le développement. Pour donner, utilisez_ `/donate 1000`""" + +helpNoUsernameMessage = """👋 Définissez un nom d'utilisateur sur Telegram afin d'utiliser ce bot.""" + +advancedMessage = """%s + +👉 *Inline commands* +*send* 💸 Envoyer des sats : `%s send [] []` +*receive* 🏅 Demander un paiement : `... receive [] []` +*faucet* 🚰 Créer un faucet: `... faucet []` +*tipjar* 🍯 Créer un tipjar: `... tipjar []` + +📖 Vous pouvez utiliser ces commandes dans tous les chats et même dans les conversations privées. Attendez une seconde après avoir tapé une commandé puis *click* sur le résultat, n'appuyez pas sur entrée. + +⚙️ *Commandes avancées* +*/transactions* 📊 List transactions +*/link* 🔗 Lier votre wallet à [BlueWallet](https://bluewallet.io/) ou [Zeus](https://zeusln.app/) +*/lnurl* ⚡️ Lnurl recevoir ou payer: `/lnurl` ou `/lnurl [memo]` +*/nostr* 💜 Connect to Nostr: `/nostr` +*/faucet* 🚰 Créer un faucet: `/faucet ` +*/tipjar* 🍯 Créer un tipjar: `/tipjar ` +*/group* 🎟 Create group tickets: `/group add []` +*/shop* 🛍 Browse shops: `/shop` or `/shop ` +*/generate* 🎆 Generate DALLE-2 images: `/generate `""" + +# GENERIC +enterAmountRangeMessage = """💯 Choisissez un montant entre %s et %s.""" +enterAmountMessage = """💯 Choisissez un montant.""" +enterUserMessage = """👤 Choisissez un utilisateur.""" +errorReasonMessage = """🚫 Erreur: %s""" + +# START + +startSettingWalletMessage = """🧮 Création de votre wallet...""" +startWalletCreatedMessage = """🧮 Wallet créé.""" +startWalletReadyMessage = """✅ *Votre wallet est prêt.*""" +startWalletErrorMessage = """🚫 Erreur lors de la création de votre wallet. Essayez de nouveau.""" +startNoUsernameMessage = """☝️ Vous n'avez pas encore de nom d'utilisateur sur Telegram. Vous n'en n'avez pas besoin pour utiliser ce bot mais l'expérience utilisateur est meilleure avec un nom d'utilisateur. Créez un nom puis tapez /balance pour actualiser le bot.""" + +# BALANCE + +balanceMessage = """👑 *Votre solde:* %s. (%s USD / %s LKR)""" +balanceErrorMessage = """🚫 Impossible de récupérer votre solde. Essayez à nouveau.""" + +# TIP + +tipDidYouReplyMessage = """Avez-vous répondu à un message pour envoyer un pourboire ? Pour répondre à un message, faites un clic droit puis cliquez sur "répondre". Si vous voulez envoyer des fonds directement à un utilisateur, utilisez la commande /send .""" +tipInviteGroupMessage = """ℹ️ Vous pouvez ajouter ce bot à n'importe quel groupe.""" +tipEnterAmountMessage = """Avez-vous choisi un montant ?""" +tipValidAmountMessage = """Avez-vous choisi un montant correct ?""" +tipYourselfMessage = """📖 Vous ne pouvez pas vous envoyer de pourboire.""" +tipSentMessage = """💸 %s envoyé à %s.""" +tipReceivedMessage = """🏅 %s vous a envoyé un pourboire de %s.""" +tipErrorMessage = """🚫 Erreur lors de l'envoi.""" +tipUndefinedErrorMsg = """réessayez s'il vous plaît.""" +tipHelpText = """📖 Oops, cela n'a pas fonctionné. %s + +*Usage:* `/tip []` +*Exemple:* `/tip 1000 Dank meme!`""" + +# SEND + +sendValidAmountMessage = """Avez-vous choisi un montant correct ?""" +sendUserHasNoWalletMessage = """🚫 L'utilisateur %s n'a pas encore crée de wallet.""" +sendSentMessage = """💸 %s envoyé à %s.""" +sendPublicSentMessage = """💸 %s envoyé de %s à %s.""" +sendReceivedMessage = """🏅 %s vous a envoyé %s.""" +sendErrorMessage = """🚫 Erreur lors de l'envoi.""" +confirmSendMessage = """Voulez-vous envoyer à %s?\n\n💸 Montant : %s""" +confirmSendAppendMemo = """\n✉️ %s""" +sendCancelledMessage = """🚫 Envoi annulé.""" +errorTryLaterMessage = """🚫 Erreur. Merci de réésayer.""" +sendSyntaxErrorMessage = """Avez-vous ajouté un montant et un destinataire ? Utilisez la commande /send pour envoyer à un utilisateur Telegram comme %s ou bien à une adresse Lightning comme LightningTipBot@ln.tips.""" +sendHelpText = """📖 Oops, cela n'a pas fonctionné. %s + +*Usage:* `/send []` +*Exemple:* `/send 1000 @LightningTipBot J'adore le bot ! ❤️` +*Exemple:* `/send 1234 LightningTipBot@ln.tips`""" + +# INVOICE + +invoiceReceivedMessage = """⚡️ Vous avez reçu %s.""" +invoiceReceivedCurrencyMessage = """⚡️ Vous avez reçu %s (%s %s).""" +invoiceEnterAmountMessage = """Avez-vous choisi un montant ?""" +invoiceValidAmountMessage = """Avez-vous choisi un montant correct ?""" +invoiceHelpText = """📖 Oops, cela n'a pas fonctionné. %s + +*Usage:* `/invoice []` +*Exemple:* `/invoice 1000 Merci beaucoup !`""" + +# PAY + +paymentCancelledMessage = """🚫 Paiement annulé.""" +invoicePaidMessage = """⚡️ Paiement envoyé.""" +invoicePublicPaidMessage = """⚡️ Paiement envoyé par %s.""" +invalidInvoiceHelpMessage = """Avez-vous ajouté un Lightning invoice valide ? Essayez /send pour envoyer à un utilisateur Telegram ou à une adresse Lightning.""" +invoiceNoAmountMessage = """🚫 Vous ne pouvez pas payer une facture sans montant.""" +insufficientFundsMessage = """🚫 Fonds insuffisants. Vous avez %s et vous avez besoin d'un minimum de %s.""" +feeReserveMessage = """⚠️ Envoyer tous vos fonds peut ne pas fonctionner en raison de la nécessité de payer des frais de réseau. Si cela échoue, essayez d'envoyer un peu moins.""" +invoicePaymentFailedMessage = """🚫 Le paiement a échoué : %s""" +invoiceUndefinedErrorMessage = """Impossible de payer la facture.""" +confirmPayInvoiceMessage = """Voulez-vous envoyer ce paiement ?\n\n💸 Montant: %s""" +confirmPayAppendMemo = """\n✉️ %s""" +payHelpText = """📖 Oops, cela n'a pas fonctionné. %s + +*Usage:* `/pay ` +*Exemple:* `/pay lnbc20n1psscehd...`""" + +# DONATE + +donationSuccess = """🙏 Merci pour votre donation !.""" +donationErrorMessage = """🚫 Oh non, le paiement de la donation a échoué.""" +donationProgressMessage = """🧮 Préparation de votre donation...""" +donationFailedMessage = """🚫 Echec de la donation: %s""" +donateEnterAmountMessage = """Avez-vous choisi un montant ?""" +donateValidAmountMessage = """Avez-vous choisi un montant correct ?""" +donateHelpText = """📖 Oops, cela n'a pas fonctionné. %s + +*Usage:* `/donate ` +*Exemple:* `/donate 1000`""" + +# PHOTO + +photoQrNotRecognizedMessage = """🚫 Impossible de reconnaître une facture Lightning ou un LNRUL. Essayez de centrer le QR code ou de zoomer.""" +photoQrRecognizedMessage = """✅ QR code: +`%s`""" + +# LNURL + +lnurlReceiveInfoText = """👇 Vous pouvez utiliser cet LNURL pour recevoir des paiements.""" +lnurlResolvingUrlMessage = """🧮 Recherche de l'adresse...""" +lnurlGettingUserMessage = """🧮 Préparation du paiement...""" +lnurlPaymentFailed = """🚫 Echec du paiement : %s""" +lnurlInvalidAmountMessage = """🚫 Montant incorrect.""" +lnurlInvalidAmountRangeMessage = """🚫 Le montant doit être entre %s et %s.""" +lnurlNoUsernameMessage = """🚫 Vous devez avoir un nom d'utilisateur Telegram pour recevoir un paiement via LNURL.""" +lnurlHelpText = """📖 Oops, cela n'a pas fonctionné. %s + +*Usage:* `/lnurl [montant] ` +*Exemple:* `/lnurl LNURL1DP68GUR...`""" + +# LINK + +walletConnectMessage = """🔗 *Lier votre wallet* + +⚠️ Ne partagez jamais cet URL ou ce QR code au risque de donner accès à vos fonds à un tiers. + +- *BlueWallet:* Appuyez sur *New wallet*, *Import wallet*, *Scan or import a file*, puis scanner le QR code. +- *Zeus:* Copier l'URL, appuyez sur *Add a new node*, *Import* (l'URL), *Save Node Config*.""" +couldNotLinkMessage = """🚫 Impossible de lier votre wallet. Merci de réésayer.""" + +# FAUCET + +inlineQueryFaucetTitle = """🚰 Création d'un faucet.""" +inlineQueryFaucetDescription = """Usage: @%s faucet """ +inlineResultFaucetTitle = """🚰 Création d'un %s faucet.""" +inlineResultFaucetDescription = """👉 Appuyez ici pour créer un faucet dans ce chat.""" + +inlineFaucetMessage = """Appuyez sur ✅ pour collecter %s de ce faucet de %s. + +🚰 Restant: %d/%s (envoyé à %d/%d utilisateurs) +%s""" +inlineFaucetEndedMessage = """🚰 Faucet vide 🍺\n\n🏅 %s envoyé à %d utilisateurs.""" +inlineFaucetAppendMemo = """\n✉️ %s""" +inlineFaucetCreateWalletMessage = """Chat avec %s 👈 pour gérer votre wallet.""" +inlineFaucetCancelledMessage = """🚫 Faucet annulé.""" +inlineFaucetInvalidPeruserAmountMessage = """🚫 Le montant de l'utilisateur n'est pas suffisament divisdable.""" +inlineFaucetInvalidAmountMessage = """🚫 Montant incorrect.""" +inlineFaucetSentMessage = """🚰 %s envoyé à %s.""" +inlineFaucetReceivedMessage = """🚰 %s vous a envoyé %s.""" +inlineFaucetHelpFaucetInGroup = """Créez un faucet dans un groupe avec le bot ou utilisez 👉 les commandes (/advanced pour plus).""" +inlineFaucetHelpText = """📖 Oops, cela n'a pas fonctionné. %s + +*Usage:* `/faucet ` +*Exemple:* `/faucet 210 21`""" + +# INLINE SEND + +inlineQuerySendTitle = """💸 Envoyez des paiements dans un chat.""" +inlineQuerySendDescription = """Usage: @%s send [] []""" +inlineResultSendTitle = """💸 Envoyé %s.""" +inlineResultSendDescription = """👉 Clique pour envoyer %s sur ce chat.""" + +inlineSendMessage = """Appuyez sur ✅ pour recevoir le paiement de %s.\n\n💸 Montant: %s""" +inlineSendAppendMemo = """\n✉️ %s""" +inlineSendUpdateMessageAccept = """💸 %s sent from %s to %s.""" +inlineSendCreateWalletMessage = """Chat avec %s 👈 pour gérer votre wallet.""" +sendYourselfMessage = """📖 Vous ne pouvez pas vous envoyer de pourboires.""" +inlineSendFailedMessage = """🚫 Echec de l'envoi.""" +inlineSendInvalidAmountMessage = """🚫 Le montant doit être supérieure à 0.""" +inlineSendBalanceLowMessage = """🚫 Votre solde est trop bas.""" + +# INLINE RECEIVE + +inlineQueryReceiveTitle = """🏅 Demander un paiement dans un chat.""" +inlineQueryReceiveDescription = """Usage: @%s receive [] []""" +inlineResultReceiveTitle = """🏅 Recevoir %s.""" +inlineResultReceiveDescription = """👉 Cliquez ici pour recevoir un paiement de %s.""" + +inlineReceiveMessage = """Appuyez sur 💸 pour payer %s.\n\n💸 Montant: %s""" +inlineReceiveAppendMemo = """\n✉️ %s""" +inlineReceiveUpdateMessageAccept = """💸 %s envoyé de %s à %s.""" +inlineReceiveCreateWalletMessage = """Chat avec %s 👈 pour gérer votre wallet.""" +inlineReceiveYourselfMessage = """📖 Vous ne pouvez pas vous envoyer de pourboires.""" +inlineReceiveFailedMessage = """🚫 La réception a échoué.""" +inlineReceiveCancelledMessage = """🚫 Réception annulée.""" +# CONVERT LKR TO SAT +convertEnterAmountMessage = """Avez-vous saisi un montant ?""" +convertInvalidAmountMessage = """Avez-vous saisi un montant valide ?""" +convertPriceErrorMessage = """🚫 Impossible de récupérer le prix.""" +convertResultMessage = """%.2f LKR équivaut à environ %s.""" diff --git a/translations/id.toml b/translations/id.toml new file mode 100644 index 00000000..227266c6 --- /dev/null +++ b/translations/id.toml @@ -0,0 +1,323 @@ +# COMMANDS + +helpCommandStr = """bantuan""" +basicsCommandStr = """dasar""" +tipCommandStr = """tip""" +balanceCommandStr = """saldo""" +sendCommandStr = """kirim""" +invoiceCommandStr = """invoice""" +payCommandStr = """bayar""" +donateCommandStr = """donasi""" +advancedCommandStr = """lanjutan""" +transactionsCommandStr = """transaksi""" +logCommandStr = """log""" +listCommandStr = """daftar""" + +linkCommandStr = """link""" +lnurlCommandStr = """lnurl""" +faucetCommandStr = """keran""" + +tipjarCommandStr = """tipjar""" +receiveCommandStr = """terima""" +hideCommandStr = """sembunyikan""" +volcanoCommandStr = """volcano""" +showCommandStr = """munculkan""" +optionsCommandStr = """pilihan""" +settingsCommandStr = """pengaturan""" +saveCommandStr = """simpan""" +deleteCommandStr = """hapus""" +infoCommandStr = """info""" + +# NOTIFICATIONS + +cantDoThatMessage = """Kamu tidak bisa melakukan itu.""" +cantClickMessage = """Kamu tidak dapat menekan tombol itu.""" +balanceTooLowMessage = """Saldo mu terlalu rendah.""" + +# BUTTONS + +sendButtonMessage = """✅ Kirim""" +payButtonMessage = """✅ Bayar""" +payReceiveButtonMessage = """💸 Bayar""" +receiveButtonMessage = """✅ Terima""" +cancelButtonMessage = """🚫 Batal""" +collectButtonMessage = """✅ Ambil""" +nextButtonMessage = """Lanjut""" +backButtonMessage = """Kembali""" +acceptButtonMessage = """Setuju""" +denyButtonMessage = """Tolak""" +tipButtonMessage = """Tip""" +revealButtonMessage = """Perlihatkan""" +showButtonMessage = """Munculkan""" +hideButtonMessage = """Sembunyikan""" +joinButtonMessage = """Bergabung""" +optionsButtonMessage = """Pilihan""" +settingsButtonMessage = """Pengaturan""" +saveButtonMessage = """Simpan""" +deleteButtonMessage = """Hapus""" +infoButtonMessage = """Info""" + +# HELP + +helpMessage = """⚡️ *Dompet* +_Bot ini adalah sebuah dompet Bitcoin Lightning yang dapat mengirimkan tip melalui Telegram. Untuk memberi tip, tambahkan bot ke chat grup. Unit dasar dari tip adalah Satoshi (sat). 100,000,000 sat = 1 Bitcoin. Ketik 📚 /basics untuk informasi lebih banyak._ + +❤️ *Donasi* +_Bot ini tidak memungut biaya tapi memerlukan Satoshi untuk bekerja. Kalau kamu menyukai bot ini, tolong pertimbangkan mendukung proyek ini dengan donasi. Untuk melakukan donasi, pakai_ `/donate 1000` + +%s + +⚙️ *Commands* +*/tip* 🏅 Membalas sebuah pesan untuk memberi tip: `/tip []` +*/balance* 👑 Memeriksa saldo mu: `/balance` +*/send* 💸 Mengirim dana ke seorang pengguna: `/send @user or user@ln.tips []` +*/invoice* ⚡️ Menerima menggunakan Lightning: `/invoice []` +*/pay* ⚡️ Membayar dengan Lightning: `/pay ` +*/donate* ❤️ Memberi donasi ke proyek: `/donate 1000` +*/advanced* 🤖 Fitur lanjutan. +*/help* 📖 Membaca daftar bantuan.""" + +infoHelpMessage = """ℹ️ *Info*""" +infoYourLightningAddress = """Alamat Lightning mu adalah `%s`""" + +basicsMessage = """🧡 *Bitcoin* +_Bitcoin adalah mata uang dari internet. Tidak memerlukan ijin dan terdesentralisasi dan tidak memiliki penguasa dan tidak ada otoritas yang mengatur. Bitcoin adalah uang yang tangguh yang lebih cepat, lebih aman, dan lebih inklusif dibanding sistem keuangan tradisional._ + +🧮 *Ekonomi* +_Unit terkecil dari Bitcoin adalah Satoshi (sat) dan 100,000,000 sat = 1 Bitcoin. Hanya akan pernah ada 21 Juta Bitcoin. Nilai Bitcoin dalam mata uang fiat dapat berubah tiap hari. Namun, jika kamu hidup dalam sebuah standar Bitcoin maka 1 sat akan selalu sama dengan 1 sat._ + +⚡️ *The Lightning Network* +_Lightning Network adalah sebuah protokol pembayaran yang memungkinkan pembayaran Bitcoin dengan cepat dan murah yang memerlukan hampir tidak ada energi. Ini lah yang membesarkan skala Bitcoin ke milyaran orang diseluruh dunia._ + +📲 *Lightning Wallets* +_Dana mu di bot ini dapat dikirim ke dompet Lightning manapun lainnya dan kebalikannya. Rekomendasi dompet Lightning untuk handphone mu adalah_ [Phoenix](https://phoenix.acinq.co/)_,_ [Breez](https://breez.technology/)_,_ [Muun](https://muun.com/)_ (non-custodial), or_ [Wallet of Satoshi](https://www.walletofsatoshi.com/) _(easy)_. + +📄 *Open Source* +_Bot ini gratis dan merupakan perangkat lunak_ [open source](https://github.com/LightningTipBot/LightningTipBot) _. Kamu dapat menjalankannya di komputer mu sendiri dan menggunakannya di komunitas mu._ + +✈️ *Telegram* +_Tambahkan bot ini ke percakapan grup Telegram mu untuk /tip postingan. Jika kamu membuat bot ini sebagai admin maka akan membersihkan perintah untuk agar percakapan tetap rapi._ + +🏛 *Persyaratan* +_Kami tidak menyimpan dana mu. Kami akan bertindak yang terbaik untuk kamu namun kami juga sadar bahwa situasi tanpa KYC agak sulit sampai kami bisa memikirkan sesuatu. Berapa pun jumlah yang kamu masukkan ke dompet mu akan dianggap sebagai donasi. Jangan kirim ke kami semua uang mu. Tolong sadar bahwa bot ini masih dalam perkembangan beta. Pergunakan lah dengan resiko mu sendiri._ + +❤️ *Donasi* +_Bot ini tidak memungut biaya tapi memerlukan Satoshi untuk bekerja. Kalau kamu menyukai bot ini, tolong pertimbangkan mendukung proyek ini dengan donasi. Untuk melakukan donasi, pakai_ `/donate 1000`""" + +helpNoUsernameMessage = """👋 Silakan mengatur nama pengguna untuk Telegram.""" + +advancedMessage = """%s + +👉 *Sebaris perintah* +*send* 💸 Kirim sats ke percakapan: `%s send []` +*receive* 🏅 Permintaan pembayaran: `... receive []` +*faucet* 🚰 Membuat sebuah keran: `... faucet ` +*tipjar* 🍯 Create a tipjar: `... tipjar []` + +📖 Kamu dapat menggunakan sebaris perintah di tiap percakapan, bahkan di percakapan privat. Tunggu sejenak setelah memasukkan sebaris perintah lalu *pencet* hasilnya, jangan tekan enter. + +⚙️ *Perintah lanjutan* +*/transactions* 📊 List transactions +*/link* 🔗 Menghubungkan dompet mu ke [BlueWallet](https://bluewallet.io/) atau [Zeus](https://zeusln.app/) +*/lnurl* ⚡️ Lnurl menerima atau membayar: `/lnurl` atau `/lnurl [memo]` +*/nostr* 💜 Connect to Nostr: `/nostr` +*/faucet* 🚰 Membuat sebuah keran `/faucet ` +*/tipjar* 🍯 Create a tipjar: `/tipjar ` +*/group* 🎟 Create group tickets: `/group add []` +*/shop* 🛍 Browse shops: `/shop` or `/shop ` +*/generate* 🎆 Generate DALLE-2 images: `/generate `""" + +# GENERIC +enterAmountRangeMessage = """💯 Masukkan jumlah diantara %s dan %s.""" +enterAmountMessage = """💯 Masukkan jumlah.""" +enterUserMessage = """👤 Masukkan user.""" +errorReasonMessage = """🚫 Error: %s""" + +# START + +startSettingWalletMessage = """🧮 Sedang menyiapkan dompet mu...""" +startWalletCreatedMessage = """🧮 Dompet sudah dibuat.""" +startWalletReadyMessage = """✅ *Dompet mu sudah siap.*""" +startWalletErrorMessage = """🚫 Ada kesalahan dalam pembuatan dompet mu. Silakan coba lagi nanti.""" +startNoUsernameMessage = """☝️ Kelihatannya kamu belum memiliki sebuah @username Telegram. Tidak apa-apa, kamu tidak memerlukan nya untuk menggunakan bot ini. Namun, agar dompet mu bisa lebih berguna, atur nama pengguna di pengaturan Telegram. Lalu, ketik /balance agar bot dapat memperbaharui catatan nya mengenai kamu.""" + +# BALANCE + +balanceMessage = """👑 *Saldo mu:* %s. (%s USD / %s LKR)""" +balanceErrorMessage = """🚫 Tidak dapat memeriksa saldo mu. Tolong coba lagi nanti.""" + +# TIP + +tipDidYouReplyMessage = """Apakah kamu tadi membalas pesan untuk memberikan tip? Untuk membalas pesan mana pun, klik-kanan -> Ketik balasan pada komputer mu atau geser pesan yang ada di handphone mu. Jika kamu mau mengirimkan secara langsung, gunakan perintah /send .""" +tipInviteGroupMessage = """ℹ️ Ngomong-ngomong, kamu bisa mengundang bot ini ke grup mana saja untuk mulai memberikan tip disana.""" +tipEnterAmountMessage = """Apakah kamu sudah memasukkan jumlah?""" +tipValidAmountMessage = """Apakah kamu memasukkan jumlah yang benar?""" +tipYourselfMessage = """📖 Kamu tidak dapat memberi tip ke diri sendiri.""" +tipSentMessage = """💸 %s terkirim ke %s.""" +tipReceivedMessage = """🏅 %s telah memberi tip sebanyak %s.""" +tipErrorMessage = """🚫 Pengiriman tip gagal.""" +tipUndefinedErrorMsg = """Silakan coba lagi nanti.""" +tipHelpText = """📖 Waduh, itu tidak berhasil. %s + +*Usage:* `/tip []` +*Example:* `/tip 1000 Makasih!`""" + +# SEND + +sendValidAmountMessage = """Apakah kamu memasukkan jumlah yang benar?""" +sendUserHasNoWalletMessage = """🚫 Pengguna %s belum membuat dompet.""" +sendSentMessage = """💸 %s terkirim ke %s.""" +sendPublicSentMessage = """💸 %s terkirim dari %s ke %s.""" +sendReceivedMessage = """🏅 %s mengirimkan kamu %s.""" +sendErrorMessage = """🚫 Pengiriman gagal.""" +confirmSendMessage = """Apakah kamu ingin membayar ke %s?\n\n💸 Jumlah: %s""" +confirmSendAppendMemo = """\n✉️ %s""" +sendCancelledMessage = """🚫 Pengiriman dibatalkan.""" +errorTryLaterMessage = """🚫 Error. Silakan coba lagi nanti.""" +sendSyntaxErrorMessage = """Apakah kamu sudah memasukkan jumlah dan penerima? Kamu dapat menggunakan perintah /send untuk ke pengguna Telegram seperti %s atau ke alamat Lightning seperti LightningTipBot@ln.tips.""" +sendHelpText = """📖 Waduh, itu tidak berhasil. %s + +*Usage:* `/send []` +*Example:* `/send 1000 @LightningTipBot Gampang botnya dipakai ❤️` +*Example:* `/send 1234 LightningTipBot@ln.tips`""" + +# INVOICE + +invoiceReceivedMessage = """⚡️ Kamu menerima %s.""" +invoiceReceivedCurrencyMessage = """⚡️ Kamu menerima %s (%s %s).""" +invoiceEnterAmountMessage = """Apakah kamu sudah memasukkan jumlah?""" +invoiceValidAmountMessage = """Apakah kamu memasukkan jumlah yang benar?""" +invoiceHelpText = """📖 Waduh, itu tidak berhasil. %s + +*Usage:* `/invoice []` +*Example:* `/invoice 1000 Makasih!`""" + +# PAY + +paymentCancelledMessage = """🚫 Pembayaran dibatalkan.""" +invoicePaidMessage = """⚡️ Pembayaran terkirim.""" +invoicePublicPaidMessage = """⚡️ Pembayaran dikirim oleh %s.""" +invalidInvoiceHelpMessage = """Apakah kamu memasukan invoice Lightning yang benar? Coba /send jika kamu mau mengirimkan ke pengguna Telegram atau alamat Lightning.""" +invoiceNoAmountMessage = """🚫 Tidak dapat membayar invoice tanpa jumlah.""" +insufficientFundsMessage = """🚫 Kekurangan dana. Kamu memiliki %s namun kamu memerlukan setidaknya %s.""" +feeReserveMessage = """⚠️ Mengirimkan seluruh saldo bisa gagal karena adanya biaya jaringan. Jika gagal, coba jumlah kirimnya dikurangi sedikit.""" +invoicePaymentFailedMessage = """🚫 Pembayaran gagal: %s""" +invoiceUndefinedErrorMessage = """Tidak dapat membayar invoice.""" +confirmPayInvoiceMessage = """Apakah kamu mau mengirimkan pembayaran ini?\n\n💸 Jumlah: %s""" +confirmPayAppendMemo = """\n✉️ %s""" +payHelpText = """📖 Waduh, itu tidak berhasil. %s + +*Usage:* `/pay ` +*Example:* `/pay lnbc20n1psscehd...`""" + +# DONATE + +donationSuccess = """🙏 Terima kasih untuk donasi mu.""" +donationErrorMessage = """🚫 Waduh. Donasi gagal.""" +donationProgressMessage = """🧮 Menyiapkan donasi mu...""" +donationFailedMessage = """🚫 Donasi gagal: %s""" +donateEnterAmountMessage = """Apakah kamu sudah memasukkan jumlah?""" +donateValidAmountMessage = """Apakah kamu memasukkan jumlah yang benar?""" +donateHelpText = """📖 Waduh, itu tidak berhasil. %s + +*Usage:* `/donate ` +*Example:* `/donate 1000`""" + +# PHOTO + +photoQrNotRecognizedMessage = """🚫 Tidak dapat mengenali sebuah invoice Lightning invoice atau sebuah LNURL. Coba arahkan ke tengah QR code, potong foto kodenya, atau zoom in.""" +photoQrRecognizedMessage = """✅ QR code: +`%s`""" + +# LNURL + +lnurlReceiveInfoText = """👇 Kamu dapat menggunakan LNURL statis ini untuk menerima pembayaran.""" +lnurlResolvingUrlMessage = """🧮 Menyelesaikan alamat...""" +lnurlGettingUserMessage = """🧮 Mempersiapkan pembayaran...""" +lnurlPaymentFailed = """🚫 Pembayaran gagal: %s""" +lnurlInvalidAmountMessage = """🚫 Jumlah tidak benar.""" +lnurlInvalidAmountRangeMessage = """🚫 Jumlah harus diantara %s dan %s.""" +lnurlNoUsernameMessage = """🚫 Kamu harus mengatur nama pengguna Telegram untuk menerima pembayaran melalui LNURL.""" +lnurlHelpText = """📖 Waduh, itu tidak berhasil. %s + +*Usage:* `/lnurl [jumlah] ` +*Example:* `/lnurl LNURL1DP68GUR...`""" + +# LNURL PENARIKAN + +confirmLnurlWithdrawMessage = """Apakah anda ingin melakukan penarikan ini?\n\n💸 Jumlah: %s""" +lnurlPreparingWithdraw = """🧮 Mempersiapkan penarikan...""" +lnurlWithdrawFailed = """🚫 Penarikan gagal.""" +lnurlWithdrawCancelled = """🚫 Penarikan dibatalkan.""" +lnurlWithdrawSuccess = """✅ Penarikan diajukan.""" + +# LINK + +walletConnectMessage = """🔗 *Hubungkan dompet mu* + +⚠️ Jangan pernah membagikan URL atau kode QR nya dengan siapa pun karena mereka akan bisa mengakses dana mu. + +- *BlueWallet:* Tekan *New wallet*, *Import wallet*, *Scan atau import a file*, lalu scan kode QR nya. +- *Zeus:* Salin URL dibawah, tekan *Add a new node*, *Import* (URL nya), *Save Node Config*.""" +couldNotLinkMessage = """🚫 Tidak dapat menghubungkan dompet mu. Silakan coba lagi nanti.""" + +# FAUCET + +inlineQueryFaucetTitle = """🚰 Membuat sebuah keran.""" +inlineQueryFaucetDescription = """Penggunaan: @%s faucet """ +inlineResultFaucetTitle = """🚰 Buat sebuah keran %s.""" +inlineResultFaucetDescription = """👉 Pencet disini untuk membuat sebuah keran di percakapan ini.""" + +inlineFaucetMessage = """Tekan ✅ untuk mengambil %s dari keran ini %s. + +🚰 Tersisa: %d/%s (diberikan kepada %d/%d pengguna) +%s""" +inlineFaucetEndedMessage = """🚰 Keran kosong 🍺\n\n🏅 %s diberikan kepada %d pengguna.""" +inlineFaucetAppendMemo = """\n✉️ %s""" +inlineFaucetCreateWalletMessage = """Bercakap dengan %s 👈 untuk mengelola dompet mu.""" +inlineFaucetCancelledMessage = """🚫 Keran dibatalkan.""" +inlineFaucetInvalidPeruserAmountMessage = """🚫 Jumlah untuk tiap pengguna tidak terbagi dari kapasitas.""" +inlineFaucetInvalidAmountMessage = """🚫 Jumlah tidak benar.""" +inlineFaucetSentMessage = """🚰 %s terkirim ke %s.""" +inlineFaucetReceivedMessage = """🚰 %s mengirimkan kamu %s.""" +inlineFaucetHelpFaucetInGroup = """Buat sebuah keran dalam sebuah grup dengan bot nya didalam atau gunakan 👉 perintah sebaris (/advanced untuk lebih lagi).""" +inlineFaucetHelpText = """📖 Waduh, itu tidak berhasil. %s + +*Usage:* `/faucet ` +*Example:* `/faucet 210 21`""" + +# INLINE SEND + +inlineQuerySendTitle = """💸 Kirimkan pembayaran ke sebuah percakapan.""" +inlineQuerySendDescription = """Penggunaan: @%s send [] []""" +inlineResultSendTitle = """💸 Kirim %s.""" +inlineResultSendDescription = """👉 Pencet untuk mengirim %s ke percakapan ini.""" + +inlineSendMessage = """Tekan ✅ untuk menerima pembayaran dari %s.\n\n💸 Jumlah: %s""" +inlineSendAppendMemo = """\n✉️ %s""" +inlineSendUpdateMessageAccept = """💸 %s terkirim dari %s ke %s.""" +inlineSendCreateWalletMessage = """Bercakap dengan %s 👈 untuk mengelola dompet mu.""" +sendYourselfMessage = """📖 Kamu tidak dapat membayar diri mu sendiri.""" +inlineSendFailedMessage = """🚫 Pengiriman tidak berhasil.""" +inlineSendInvalidAmountMessage = """🚫 Jumlah harus lebih besar dari 0.""" +inlineSendBalanceLowMessage = """🚫 Saldo mu terlalu rendah.""" + +# INLINE RECEIVE + +inlineQueryReceiveTitle = """🏅 Meminta pembayaran dalam sebuah percakapan.""" +inlineQueryReceiveDescription = """Penggunaan: @%s receive [] []""" +inlineResultReceiveTitle = """🏅 Menerima %s.""" +inlineResultReceiveDescription = """👉 Pencet untuk permintaan pembayaran sebesar %s.""" + +inlineReceiveMessage = """Tekan 💸 untuk membayar ke %s.\n\n💸 Jumlah: %s""" +inlineReceiveAppendMemo = """\n✉️ %s""" +inlineReceiveUpdateMessageAccept = """💸 %s terkirim oleh %s ke %s.""" +inlineReceiveCreateWalletMessage = """Bercakap dengan %s 👈 untuk mengelola dompet mu.""" +inlineReceiveYourselfMessage = """📖 Kamu tidak dapat membayar diri mu sendiri.""" +inlineReceiveFailedMessage = """🚫 Penerimaan gagal.""" +inlineReceiveCancelledMessage = """🚫 Penerimaan dibatalkan.""" +# CONVERT LKR TO SAT +convertEnterAmountMessage = """Apakah kamu sudah memasukkan jumlah?""" +convertInvalidAmountMessage = """Apakah kamu memasukkan jumlah yang benar?""" +convertPriceErrorMessage = """🚫 Tidak dapat mengambil harga.""" +convertResultMessage = """%.2f LKR sekitar %s.""" diff --git a/translations/it.toml b/translations/it.toml new file mode 100644 index 00000000..008b421f --- /dev/null +++ b/translations/it.toml @@ -0,0 +1,318 @@ +# COMMANDS + +helpCommandStr = """aiuto""" +basicsCommandStr = """informazioni""" +tipCommandStr = """mancia""" +balanceCommandStr = """saldo""" +sendCommandStr = """invia""" +invoiceCommandStr = """invoice""" +payCommandStr = """paga""" +donateCommandStr = """dona""" +advancedCommandStr = """avanzate""" +transactionsCommandStr = """traduzioni""" +logCommandStr = """log""" +listCommandStr = """lista""" + +linkCommandStr = """link""" +lnurlCommandStr = """lnurl""" +faucetCommandStr = """distribuzione""" + +tipjarCommandStr = """salvadanaio""" +receiveCommandStr = """ricevi""" +hideCommandStr = """nascondi""" +volcanoCommandStr = """vulcano""" +showCommandStr = """mostra""" +optionsCommandStr = """opzioni""" +settingsCommandStr = """impostazioni""" +saveCommandStr = """salva""" +deleteCommandStr = """cancella""" +infoCommandStr = """info""" + +# NOTIFICATIONS + +cantDoThatMessage = """Funzione non disponibile.""" +cantClickMessage = """Pulsante non selezionabile.""" +balanceTooLowMessage = """Saldo non sufficiente.""" + +# BUTTONS + +sendButtonMessage = """✅ Invia""" +payButtonMessage = """✅ Paga""" +payReceiveButtonMessage = """💸 Paga""" +receiveButtonMessage = """✅ Ricevi""" +cancelButtonMessage = """🚫 Cancella""" +collectButtonMessage = """✅ Incassa""" +nextButtonMessage = """Prossimo""" +backButtonMessage = """Indietro""" +acceptButtonMessage = """Consenti""" +denyButtonMessage = """Rifiuta""" +tipButtonMessage = """Mancia""" +revealButtonMessage = """Rivela""" +showButtonMessage = """Mostra""" +hideButtonMessage = """Nascondi""" +joinButtonMessage = """Unisciti""" +optionsButtonMessage = """Opzioni""" +settingsButtonMessage = """Impostazioni""" +saveButtonMessage = """Salva""" +deleteButtonMessage = """Cancella""" +infoButtonMessage = """Info""" + +# HELP + +helpMessage = """⚡️ *Wallet* +_Questo bot è un Wallet Bitcoin Lightning con cui puoi inviare ricompense via Telegram. Per inviare, aggiungi il bot come partecipante a una chat di gruppo. L'unità di conto per questi invii di piccolo importo è il Satoshi (sat). 100,000,000 sat = 1 Bitcoin. Digita 📚 /basics per maggiori informazioni._ + +❤️ *Dona* +_Questo bot non applica commissioni agli utenti, ma costa alcuni Satoshi gestirlo. Se ti piace, valuta se sostenere questo progetto con una donazione. Per donare, usa il comando_ `/donate 1000` + +%s + +⚙️ *Comandi* +*/tip* 🏅 Rispondi così a un messaggio per inviare una mancia: `/tip []` +*/balance* 👑 Verifica il tuo saldo residuo: `/balance` +*/send* 💸 Invia fondi a un utente: `/send @utente o utente@ln.tips []` +*/invoice* ⚡️ Ricevi attraverso Lightning: `/invoice []` +*/pay* ⚡️ Paga attraverso Lightning: `/pay ` +*/donate* ❤️ Dona per supportare questo progetto: `/donate 1000` +*/advanced* 🤖 Funzioni avanzate. +*/help* 📖 Richiama questo elenco.""" + +infoHelpMessage = """ℹ️ *Info*""" +infoYourLightningAddress = """Il tuo indirizzo Lightning è `%s`""" + +basicsMessage = """🧡 *Bitcoin* +_Bitcoin è la valuta di internet. È una rete libera e decentralizzata a cui tutti possono partecipare, senza una autorità di controllo centrale. Bitcoin è una moneta ma anche un sistema di pagamento più veloce, sicuro e inclusivo del sistema finanziario tradizionale._ + +🧮 *Economnics* +_La più piccola unità in cui è divisibile un Bitcoin è un Satoshi (sat) e 100,000,000 sat equivalgono a 1 Bitcoin. Esistono solamente 21 milioni di Bitcoin. Il valore di Bitcoin espresso in valuta tradizionale può variare ogni giorno. In ogni caso, se la tua valuta di riferimento è Bitcoin, 1 sat varrà sempre 1 sat._ + +⚡️ *Lightning Network* +_Lightning Network è un protocollo di pagamento che consente pagamenti in Bitcoin veloci ed economici, praticamente senza consumare energia. È il sistema che consentirà di diffondere Bitcoin a miliardi di persone nel mondo._ + +📲 *Lightning Wallets* +_I tuoi fondi conservati in questo bot possono essere mandati a un altro wallet Lightning e viceversa. Altri wallet Lightning per il tuo smartphone sono_ [Phoenix](https://phoenix.acinq.co/)_,_ [Breez](https://breez.technology/)_,_ [Muun](https://muun.com/)_ (non-custodial), oppure_ [Wallet of Satoshi](https://www.walletofsatoshi.com/) _(semplice da usare)_. + +📄 *Open Source* +_Questo bot è software libero e_ [open source](https://github.com/LightningTipBot/LightningTipBot) _. Puoi farlo girare sul tuo computer e usarlo per la tua comunità._ + +✈️ *Telegram* +_Aggiungi questo bot a una chat di gruppo Telegram per inviare una /tip. Se concedi al bot i privilegi di amministratore della chat, il bot si occuperà automaticamente di eliminare i comandi inviati per tenere la chat pulita._ + +🏛 *Termini e condizioni* +_Noi non siamo custodi dei tuoi fondi. Agiremo sempre nel tuo migliore interesse, ma siamo consapevoli che operare senza il riconoscimento formale dei Clienti (KYC) è un terreno accidentato finché non troviamo qualche soluzione alternativa. Qualsiasi ammontare caricato nel wallet sarà formalmente considerato una donazione. Non inviarci tutto il tuo denaro! Sii consapevole che questo bot è ancora in fase di sviluppo, e puoi usarlo a tuo rischio e pericolo._ + +❤️ *Dona* +_Questo bot non applica commissioni agli utenti, ma costa alcuni Satoshi gestirlo. Se ti piace, valuta se sostenere questo progetto con una donazione. Per donare, usa il comando_ `/donate 1000`""" + +helpNoUsernameMessage = """👋 Per favore imposta un nome utente Telegram.""" + +advancedMessage = """%s + +👉 *Comandi in linea* +*send* 💸 Invia alcuni sat a una chat: `%s send [] []` +*receive* 🏅 Richiedi un pagamento: `... receive [] []` +*faucet* 🚰 Crea una distribuzione: `... faucet []` +*tipjar* 🍯 Crea un tipjar: `... tipjar []` + +📖 Puoi usare i comandi in linea in ogni chat, anche nelle conversazioni private. Attendi un secondo dopo aver inviato un comando in linea e *clicca* sull'azione desiderata, non premere invio. + +⚙️ *Comandi avanzati* +*/transactions* 📊 List transactions +*/link* 🔗 Crea un collegamento al tuo wallet [BlueWallet](https://bluewallet.io/) o [Zeus](https://zeusln.app/) +*/lnurl* ⚡️ Ricevi o paga un Lnurl: `/lnurl` or `/lnurl [memo]` +*/nostr* 💜 Connect to Nostr: `/nostr` +*/faucet* 🚰 Crea una distribuzione: `/faucet ` +*/tipjar* 🍯 Crea un tipjar: `/tipjar ` +*/group* 🎟 Create group tickets: `/group add []` +*/shop* 🛍 Browse shops: `/shop` or `/shop ` +*/generate* 🎆 Generate DALLE-2 images: `/generate `""" + +# GENERIC +enterAmountRangeMessage = """💯 Imposta un ammontare tra %s e %s.""" +enterAmountMessage = """💯 Imposta un ammontare.""" +enterUserMessage = """👤 Imposta un utente.""" +errorReasonMessage = """🚫 Errore: %s""" + +# START + +startSettingWalletMessage = """🧮 Sto creando il tuo wallet...""" +startWalletCreatedMessage = """🧮 Wallet creato.""" +startWalletReadyMessage = """✅ *Il tuo wallet è pronto.*""" +startWalletErrorMessage = """🚫 Errore di inizializzazione del wallet. Riprova più tardi.""" +startNoUsernameMessage = """☝️ Sembra che tu non abbia un nome utente Telegram @username. Non è obbligatorio per utilizzare questo bot, ma consente di abilitare ulteriori funzioni. Per usare al meglio il tuo wallet, imposta un nome utente nelle impostazioni Telegram e poi inserisci /balance in modo che il bot possa aggiornarsi.""" + +# BALANCE + +balanceMessage = """👑 *Il tuo saldo è:* %s. (%s USD / %s LKR)""" +balanceErrorMessage = """🚫 Non riesco a recupare il tuo saldo. Per favore riprova più tardi.""" + +# TIP + +tipDidYouReplyMessage = """Hai risposto a un messaggio per inviare una mancia? Per rispondere a un messaggio, clicca con il tasto destro -> Rispondi sul tuo computer o fai swipe sul tuo smartphone. Se vuoi inviare direttamente a un altro utente, usa il comando /send.""" +tipInviteGroupMessage = """ℹ️ In ogni caso, puoi invitare questo bot in qualsiasi chat di gruppo per incominciare a inviare mance.""" +tipEnterAmountMessage = """Hai inserito un ammontare?""" +tipValidAmountMessage = """Hai inserito un ammontare valido?""" +tipYourselfMessage = """📖 Non puoi inviare una mancia a te stesso.""" +tipSentMessage = """💸 %s inviati a %s.""" +tipReceivedMessage = """🏅 %s ti ha inviato una mancia di %s.""" +tipErrorMessage = """🚫 Invio mancia non riuscito.""" +tipUndefinedErrorMsg = """Per favore riprova più tardi.""" +tipHelpText = """📖 Ops, non funziona. %s + +*Usage:* `/tip []` +*Example:* `/tip 1000 meme fantastico!`""" + +# SEND + +sendValidAmountMessage = """Hai inserito un ammontare valido?""" +sendUserHasNoWalletMessage = """🚫 L'utente %s non ha ancora creato un wallet.""" +sendSentMessage = """💸 %s inviati a %s.""" +sendPublicSentMessage = """💸 %s inviati da %s a %s.""" +sendReceivedMessage = """🏅 %s ti ha inviato %s.""" +sendErrorMessage = """🚫 Invio non riuscito.""" +confirmSendMessage = """Vuoi inviare un pagamento a %s?\n\n💸 Ammontare: %s""" +confirmSendAppendMemo = """\n✉️ %s""" +sendCancelledMessage = """🚫 Invio cancellato.""" +errorTryLaterMessage = """🚫 Errore. Per favore riprova più tardi.""" +sendSyntaxErrorMessage = """Hai specificato un ammontare e un destinatario? Puoi usare il comando /send per inviare sia a utenti Telegram come %s sia a un indirizzo Lightning del tipo LightningTipBot@ln.tips.""" +sendHelpText = """📖 Ops, non ha funzionato. %s + +*Sintassi:* `/send []` +*Esempio:* `/send 1000 @LightningTipBot Amo questo bot ❤️` +*Esempio:* `/send 1234 LightningTipBot@ln.tips`""" + +# INVOICE + +invoiceReceivedMessage = """⚡️ Hai ricevuto %s.""" +invoiceReceivedCurrencyMessage = """⚡️ Hai ricevuto %s (%s %s).""" +invoiceEnterAmountMessage = """Hai inserito un ammontare?""" +invoiceValidAmountMessage = """Hai inserito un ammontare valido?""" +invoiceHelpText = """📖 Ops, non ha funzionato. %s + +*Sintassi:* `/invoice []` +*Esempio:* `/invoice 1000 Grazie!`""" + +# PAY + +paymentCancelledMessage = """🚫 Pagamento cancellato.""" +invoicePaidMessage = """⚡️ Pagamento inviato.""" +invoicePublicPaidMessage = """⚡️ Pagamento inviato da %s.""" +invalidInvoiceHelpMessage = """Hai inserito una invoice Lightning valida? Prova /send se vuoi inviare fondi a un utente Telegram o a un indirizzo Lightning.""" +invoiceNoAmountMessage = """🚫 Non è possibile pagare questa invoice senza specificare un ammontare.""" +insufficientFundsMessage = """🚫 Fondi insufficienti. Hai in portafoglio %s ma servono almeno %s per l'invio.""" +feeReserveMessage = """⚠️ Inviare il tuo intero saldo potrebbe non essere possibile a causa della incidenza delle commissioni di rete. Se l'invio non va a buon fine, prova a inviare un ammontare leggermente inferiore.""" +invoicePaymentFailedMessage = """🚫 Pagamento non riuscito: %s""" +invoiceUndefinedErrorMessage = """Non è stato possibile pagare questa invoice.""" +confirmPayInvoiceMessage = """Vuoi inviare questo pagamento?\n\n💸 Amount: %s""" +confirmPayAppendMemo = """\n✉️ %s""" +payHelpText = """📖 Ops, non ha funzionato. %s + +*Sintassi:* `/pay ` +*Esempio:* `/pay lnbc20n1psscehd...`""" + +# DONATE + +donationSuccess = """🙏 Grazie per la donazione.""" +donationErrorMessage = """🚫 Oh no, la tua donazione non è andata a buon fine.""" +donationProgressMessage = """🧮 Sto preparando la donazione...""" +donationFailedMessage = """🚫 La donazione non è andata a buon fine: %s""" +donateEnterAmountMessage = """Hai inserito un ammontare?""" +donateValidAmountMessage = """Hai inserito un ammontare valido?""" +donateHelpText = """📖 Ops, non ha funzionato. %s + +*Sintassi:* `/donate ` +*Esepio:* `/donate 1000`""" + +# PHOTO + +photoQrNotRecognizedMessage = """🚫 Non sono riuscito a riconoscere una invoice Lightning o un LNURL. Cerca cortesemente di centrare meglio il codice QR oppure prova a ritagliare o ingrandire l'immagine.""" +photoQrRecognizedMessage = """✅ Codice QR: +`%s`""" + +# LNURL + +lnurlReceiveInfoText = """👇 Puoi usare questo LNURL statico per ricevere pagamenti.""" +lnurlResolvingUrlMessage = """🧮 Recupero indirizzo...""" +lnurlGettingUserMessage = """🧮 Preparazione pagamento...""" +lnurlPaymentFailed = """🚫 Pagamento non riuscito: %s""" +lnurlInvalidAmountMessage = """🚫 Ammontare non valido.""" +lnurlInvalidAmountRangeMessage = """🚫 L'ammontare deve essere compreso tra %s e %s.""" +lnurlNoUsernameMessage = """🚫 Devi impostare un nome utente Telegram per ricevere pagamenti tramite un LNURL.""" +lnurlHelpText = """📖 Ops, non ha funzionato. %s + +*Sintassi:* `/lnurl [ammontare] ` +*Esempio:* `/lnurl LNURL1DP68GUR...`""" + +# LINK + +walletConnectMessage = """🔗 *Collega il tuo wallet* + +⚠️ Non mostrare mai la URL il codice QR, altrimenti qualcuno potrebbe avere accesso ai tuoi fondi. + +- *BlueWallet:* Premi *+ (Aggiungi Portafoglio)*, *Importa portafoglio*, *scansionare un codice QR*, e scansiona il codice QR . +- *Zeus:* Copia la URL qui sotto, premi *Add a new node*, *Import* (incolla la URL), *Save Node Config*.""" +couldNotLinkMessage = """🚫 Non sono riuscito a collegare il tuo wallet. Per favore riprova più tardi.""" + +# FAUCET + +inlineQueryFaucetTitle = """🚰 Crea una distribuzione di fondi.""" +inlineQueryFaucetDescription = """Sintassi: @%s faucet """ +inlineResultFaucetTitle = """🚰 Crea una distribuzione per un totale di %s.""" +inlineResultFaucetDescription = """👉 Clicca qui per creare una distribuzione di fondi in questa chat.""" + +inlineFaucetMessage = """Premi ✅ per riscuotere %s da questa distribuzione da %s. + +🚰 Rimanente: %d/%s (distribuiti a %d/%d utenti) +%s""" +inlineFaucetEndedMessage = """🚰 Distribuzione completata 🍺\n\n🏅 %s distribuiti a %d utenti.""" +inlineFaucetAppendMemo = """\n✉️ %s""" +inlineFaucetCreateWalletMessage = """Chatta con %s 👈 per gestire il tuo wallet.""" +inlineFaucetCancelledMessage = """🚫 Distribuzione cancellata.""" +inlineFaucetInvalidPeruserAmountMessage = """🚫 Ammontare per utente non è una frazione intera del totale.""" +inlineFaucetInvalidAmountMessage = """🚫 Ammontare non valido.""" +inlineFaucetSentMessage = """🚰 %s inviati a %s.""" +inlineFaucetReceivedMessage = """🚰 %s ti ha inviato %s.""" +inlineFaucetHelpFaucetInGroup = """Crea una distribuzione in un gruppo in cui sia presente il bot oppure usa il 👉 comando in linea (/advanced per ulteriori funzionalità).""" +inlineFaucetHelpText = """📖 Ops, non ha funzionato. %s + +*Sintassi:* `/faucet ` +*Esempio:* `/faucet 210 21`""" + +# INLINE SEND + +inlineQuerySendTitle = """💸 Invia pagamento in una chat.""" +inlineQuerySendDescription = """Sintassi: @%s send [] []""" +inlineResultSendTitle = """💸 Invio %s.""" +inlineResultSendDescription = """👉 Clicca per inviare %s in questa chat.""" + +inlineSendMessage = """Premi ✅ per ricevere un pagamento da %s.\n\n💸 Ammontare: %s""" +inlineSendAppendMemo = """\n✉️ %s""" +inlineSendUpdateMessageAccept = """💸 %s inviati da %s a %s.""" +inlineSendCreateWalletMessage = """Chatta con %s 👈 per gestire il tuo wallet.""" +sendYourselfMessage = """📖 Non puoi inviare un pagamento a te stesso.""" +inlineSendFailedMessage = """🚫 Invio non riuscito.""" +inlineSendInvalidAmountMessage = """🚫 L'ammontare deve essere maggiore di 0.""" +inlineSendBalanceLowMessage = """🚫 Il tuo saldo è insufficiente (%s).""" + +# INLINE RECEIVE + +inlineQueryReceiveTitle = """🏅 Richiedi un pagamento in una chat.""" +inlineQueryReceiveDescription = """Sintassi: @%s receive [] []""" +inlineResultReceiveTitle = """🏅 Ricevi %s.""" +inlineResultReceiveDescription = """👉 Clicca per richiedere un pagamento di %s.""" + +inlineReceiveMessage = """Premi 💸 per inviare un pagamento a %s.\n\n💸 Ammontare: %s""" +inlineReceiveAppendMemo = """\n✉️ %s""" +inlineReceiveUpdateMessageAccept = """💸 %s inviati da %s a %s.""" +inlineReceiveCreateWalletMessage = """Chatta con %s 👈 per gestire il tuo wallet.""" +inlineReceiveYourselfMessage = """📖 Non puoi inviare un pagamento a te stesso.""" +inlineReceiveFailedMessage = """🚫 Pagamento non riuscito.""" +inlineReceiveCancelledMessage = """🚫 Pagamento cancellato.""" +# CONVERT LKR TO SAT +convertEnterAmountMessage = """Hai inserito un importo?""" +convertInvalidAmountMessage = """Hai inserito un importo valido?""" +convertPriceErrorMessage = """🚫 Impossibile recuperare il prezzo.""" +convertResultMessage = """%.2f LKR sono circa %s.""" + +# CONVERT SAT TO FIAT +convertSatsResultMessage = """%s sono circa %s USD / %s LKR""" diff --git a/translations/nl.toml b/translations/nl.toml new file mode 100644 index 00000000..488fcc9d --- /dev/null +++ b/translations/nl.toml @@ -0,0 +1,326 @@ +# COMMANDS + +helpCommandStr = """help""" +basicsCommandStr = """basis""" +tipCommandStr = """tip""" +balanceCommandStr = """saldo""" +sendCommandStr = """verzenden""" +invoiceCommandStr = """factuur""" +payCommandStr = """betalen""" +donateCommandStr = """donatie""" +advancedCommandStr = """geavanceerd""" +transactionsCommandStr = """transacties""" +logCommandStr = """log""" +listCommandStr = """lijst""" + +linkCommandStr = """connect""" +lnurlCommandStr = """lnurl""" +faucetCommandStr = """kraan""" + +tipjarCommandStr = """donatiebox""" +receiveCommandStr = """ontvangen""" +hideCommandStr = """verbergen""" +volcanoCommandStr = """vulkaan""" +showCommandStr = """toon""" +optionsCommandStr = """opties""" +settingsCommandStr = """instellingen""" +saveCommandStr = """save""" +deleteCommandStr = """verwijderen""" +infoCommandStr = """info""" + +# NOTIFICATIONS + +cantDoThatMessage = """Dat kun je niet doen.""" +cantClickMessage = """Je kunt niet op deze knop klikken.""" +balanceTooLowMessage = """Uw saldo is te laag.""" + +# BUTTONS + +sendButtonMessage = """✅ Verzenden""" +payButtonMessage = """✅ Betalen""" +payReceiveButtonMessage = """💸 Betalen""" +receiveButtonMessage = """✅ Ontvangen""" +cancelButtonMessage = """🚫 Annuleren""" +collectButtonMessage = """✅ Verzamelen""" +nextButtonMessage = """Volgende""" +backButtonMessage = """Terug""" +acceptButtonMessage = """Accepteren""" +denyButtonMessage = """Weigeren""" +tipButtonMessage = """Tip""" +revealButtonMessage = """Laat zijn""" +showButtonMessage = """Toon""" +hideButtonMessage = """Verbergen""" +joinButtonMessage = """Deelnemen""" +optionsButtonMessage = """Opties""" +settingsButtonMessage = """Instellingen""" +saveButtonMessage = """Save""" +deleteButtonMessage = """Verwijderen""" +infoButtonMessage = """Info""" + +# HELP + +helpMessage = """⚡️ *Wallet* +_Deze bot is een Bitcoin Lightning wallet die tips kan sturen op Telegram. Om tips te geven, voeg de bot toe aan een groepschat. De basiseenheid van tips zijn Satoshis (sat). 100.000.000 sat = 1 Bitcoin. Type 📚 /basics voor meer._ + +❤️ *Doneren* +_Deze bot brengt geen kosten in rekening maar kost Satoshis om te werken. Als je de bot leuk vindt, overweeg dan om dit project te steunen met een donatie. Om te doneren, gebruik_ `/donate 1000` + +%s + +⚙️ *opdrachten* +*/tip* 🏅 Antwoord op een bericht aan tip: `/tip []` +*/balance* 👑 Controleer uw saldo: `/balance` +*/send* 💸 Stuur geld naar een gebruiker: `/send @user of user@ln.tips []` +*/invoice* ⚡️ Ontvang met Lightning: `/invoice []` +*/pay* ⚡️ Betaal met Lightning: `/pay ` +*/donate* ❤️ Doneer aan het project: `/donate 1000` +*/advanced* 🤖 Geavanceerde functies. +*/help* 📖 Lees deze hulp.""" + +infoHelpMessage = """ℹ️ *Info*""" +infoYourLightningAddress = """Uw Lightning adres is `%s`""" + +basicsMessage = """🧡 *Bitcoin* +_Bitcoin is de munteenheid van het internet. Het functioneert zonder toestemming, is gedecentraliseerd en heeft geen meesters of een controlerende autoriteit. Bitcoin is gezond geld dat sneller, veiliger en inclusiever is dan het oude financiële systeem. + +🧮 *Economie* +_De kleinste eenheid van Bitcoin zijn Satoshis (sat) en 100.000.000 sat = 1 Bitcoin. Er zullen ooit maar 21 miljoen Bitcoin zijn. De fiatvalutawaarde van Bitcoin kan dagelijks veranderen. Echter, als u leeft op een Bitcoin-standaard zal 1 sat altijd gelijk zijn aan 1 sat._ + +⚡️ *Het Lightning Network* +_Het Lightning Network is een betalingsprotocol dat snelle en goedkope Bitcoin-betalingen mogelijk maakt die bijna geen energie vergen. Het is wat Bitcoin schaalt naar de miljarden mensen over de hele wereld._ + +📲 *Lightning Wallets* +_U kunt uw geld via deze bot naar elke andere Lightning Wallet in de wereld sturen. Aanbevolen Lightning wallets voor uw telefoon zijn_ [Phoenix](https://phoenix.acinq.co/)_,_ [Breez](https://breez.technology/)_,_ [Muun](https://muun.com/)_ (niet-custodiaal), of_ [Wallet van Satoshi](https://www.walletofsatoshi.com/) _(gemakkelijk)_. + +📄 *Open Source* +_Deze bot is gratis en_ [open source](https://github.com/LightningTipBot/LightningTipBot) _software. U kunt het op uw eigen computer draaien en in uw eigen gemeenschap gebruiken._ + +✈️ *Telegram* +_Voeg deze bot toe aan je Telegram groep chat om berichten te /tip. Als je de bot admin maakt van de groep zal hij ook commando's opruimen om de chat netjes te houden._ + +🏛 *Voorwaarden* +_Wij zijn geen bewaarder van uw fondsen. Wij zullen in uw belang handelen, maar we zijn ons er ook van bewust dat de situatie zonder KYC lastig is totdat we iets hebben uitgezocht. Elk bedrag dat u in uw portemonnee laadt, wordt beschouwd als een donatie. Geef ons niet al uw geld. Wees ervan bewust dat deze bot in beta ontwikkeling is. Gebruik op eigen risico. + +❤️ *Doneren* +_Deze bot brengt geen kosten in rekening maar kost satoshis om te werken. Als je de bot leuk vindt, overweeg dan om dit project te steunen met een donatie. Om te doneren, gebruik_ `/donate 1000`""" + +helpNoUsernameMessage = """👋 Stel alstublieft een Telegram gebruikersnaam in.""" + +advancedMessage = """%s + +👉 *Inline commands* +*send* 💸 Stuur sats naar chat: `%s send [] []` +*receive* 🏅 Verzoek om betaling: `... receive [] []` +*faucet* 🚰 Maak een kraan: `... faucet []` +*tipjar* 🍯 Maak een tipjar: `... tipjar []` + +📖 Je kunt inline commando's in elke chat gebruiken, zelfs in privé gesprekken. Wacht een seconde na het invoeren van een inline commando en *klik* op het resultaat, druk niet op enter. + +⚙️ *Geavanceerde opdrachten* +*/transactions* 📊 List transactions +*/link* 🔗 Koppel uw wallet aan [BlueWallet](https://bluewallet.io/) of [Zeus](https://zeusln.app/) +*/lnurl* ⚡️ Lnurl ontvangen of betalen: `/lnurl` of `/lnurl [memo]` +*/nostr* 💜 Connect to Nostr: `/nostr` +*/faucet* 🚰 Maak een kraan: `/faucet ` +*/tipjar* 🍯 Maak een tipjar: `/tipjar ` +*/group* 🎟 Create group tickets: `/group add []` +*/shop* 🛍 Browse shops: `/shop` or `/shop ` +*/generate* 🎆 Generate DALLE-2 images: `/generate `""" + +# GENERIC +enterAmountRangeMessage = """💯 Voer een bedrag in tussen %s en %s.""" +enterAmountMessage = """💯 Voer een bedrag.""" +enterUserMessage = """👤 Voer een gebruiker.""" +errorReasonMessage = """🚫 Fout: %s""" + +# START + +startSettingWalletMessage = """🧮 Uw wallet instellen...""" +startWalletCreatedMessage = """🧮 Wallet gemaakt.""" +startWalletReadyMessage = """✅ *Uw wallet is klaar.*""" +startWalletErrorMessage = """🚫 Fout bij het initialiseren van uw wallet. Probeer later opnieuw.""" +startNoUsernameMessage = """☝️ Het lijkt erop dat je nog geen Telegram @ gebruikersnaam hebt. Dat is niet erg, je hebt er geen nodig om deze bot te gebruiken. Echter, om beter gebruik te maken van je wallet, stel je een gebruikersnaam in bij de Telegram instellingen. Voer vervolgens /balance in, zodat de bot zijn gegevens over jou kan bijwerken.""" + +# BALANCE + +balanceMessage = """👑 *Uw saldo:* %s. (%s USD / %s LKR)""" +balanceErrorMessage = """🚫 Kon uw saldo niet ophalen. Probeer het later nog eens.""" + +# TIP + +tipDidYouReplyMessage = """Hebt u een bericht aan tip beantwoord? Om te antwoorden op een bericht, klik met de rechtermuisknop -> Beantwoorden op uw computer of veeg over het bericht op uw telefoon. Als je direct naar een andere gebruiker wilt sturen, gebruik dan het /send commando.""" +tipInviteGroupMessage = """ℹ️ Trouwens, je kan deze bot uitnodigen voor elke groep om daar te beginnen tippen.""" +tipEnterAmountMessage = """Heb je een bedrag ingevoerd?""" +tipValidAmountMessage = """Heb je een geldig bedrag ingevoerd?""" +tipYourselfMessage = """📖 Je kunt jezelf geen tip geven.""" +tipSentMessage = """💸 %s gestuurd naar %s.""" +tipReceivedMessage = """🏅 %s heeft je getipt %s.""" +tipErrorMessage = """🚫 Tip mislukt.""" +tipUndefinedErrorMsg = """probeer het later nog eens.""" +tipHelpText = """📖 Oeps, dat werkte niet. %s + +*Usage:* `/tip []` +*Example:* `/tip 1000 Dank meme!`""" + +# SEND + +sendValidAmountMessage = """Heb je een geldig bedrag ingevoerd?""" +sendUserHasNoWalletMessage = """🚫 Gebruiker %s heeft nog geen portemonnee aangemaakt.""" +sendSentMessage = """💸 %s verstuurd naar %s.""" +sendPublicSentMessage = """💸 %s verstuurd van %s naar %s.""" +sendReceivedMessage = """🏅 %s stuurde u %s.""" +sendErrorMessage = """🚫 Verzenden mislukt.""" +confirmSendMessage = """Wilt u betalen aan %s? bedrag: %s""" +confirmSendAppendMemo = """\n✉️ %s""" +sendCancelledMessage = """🚫 Geannuleerd verzenden.""" +errorTryLaterMessage = """🚫 Fout. Probeer het later nog eens.""" +sendSyntaxErrorMessage = """Heb je een bedrag en een ontvanger ingevoerd? U kunt het /send commando gebruiken om ofwel naar Telegram gebruikers te sturen zoals %s of naar een Lightning adres zoals LightningTipBot@ln.tips.""" +sendHelpText = """📖 Oeps, dat werkte niet. %s + +*Usage:* `/send []` +*Example:* `/send 1000 @LightningTipBot Ik hou gewoon van de bot ❤️` +*Example:* `/send 1234 LightningTipBot@ln.tips`""" + +# INVOICE + +invoiceReceivedMessage = """⚡️ Je hebt %s ontvangen .""" +invoiceReceivedCurrencyMessage = """⚡️ Je hebt %s (%s %s) ontvangen.""" +invoiceEnterAmountMessage = """Heb je een bedrag ingevoerd?""" +invoiceValidAmountMessage = """Heeft u een geldig bedrag ingevoerd?""" +invoiceHelpText = """📖 Oeps, dat werkte niet. %s + +*Usage:* `/invoice []` +*Example:* `/invoice 1000 Dank je!`""" + +# PAY + +paymentCancelledMessage = """🚫 Betaling geannuleerd.""" +invoicePaidMessage = """⚡️ Betaling verzonden.""" +invoicePublicPaidMessage = """⚡️ Betaling verzonden door %s.""" +invalidInvoiceHelpMessage = """Heeft u een geldige Lightning factuur ingevoerd? Probeer /send als u wilt verzenden naar een Telegram gebruiker of Lightning address.""" +invoiceNoAmountMessage = """🚫 Kan facturen niet betalen zonder een bedrag.""" +insufficientFundsMessage = """🚫 Onvoldoende middelen. Je hebt %s maar je hebt minstens %s nodig.""" +feeReserveMessage = """⚠️ Het versturen van uw volledige saldo kan mislukken vanwege netwerkkosten. Als het mislukt, probeer dan een beetje minder te verzenden.""" +invoicePaymentFailedMessage = """🚫 Betaling mislukt: %s""" +invoiceUndefinedErrorMessage = """Kon de factuur niet betalen.""" +confirmPayInvoiceMessage = """Wilt u deze betaling sturen?\n\n💸 bedrag: %s""" +confirmPayAppendMemo = """\n✉️ %s""" +payHelpText = """📖 Oeps, dat werkte niet. %s + +*Usage:* `/pay ` +*Example:* `/pay lnbc20n1psscehd...`""" + +# DONATE + +donationSuccess = """🙏 Dank u voor uw donatie.""" +donationErrorMessage = """🚫 Oh nee. Donatie mislukt.""" +donationProgressMessage = """🧮 Preparing your donation...""" +donationFailedMessage = """🚫 Donatie mislukt: %s""" +donateEnterAmountMessage = """Heeft u een bedrag ingevoerd?""" +donateValidAmountMessage = """Heeft u een geldig bedrag ingevoerd?""" +donateHelpText = """📖 Oeps, dat werkte niet. %s + +*Usage:* `/donate ` +*Example:* `/donate 1000`""" + +# PHOTO + +photoQrNotRecognizedMessage = """🚫 Kon een Lightning factuur of een LNURL niet herkennen. Probeer de QR code te centreren, de foto bij te snijden, of in te zoomen.""" +photoQrRecognizedMessage = """✅ QR code: +`%s`""" + +# LNURL + +lnurlReceiveInfoText = """👇 U kunt deze statische LNURL gebruiken om betalingen te ontvangen.""" +lnurlResolvingUrlMessage = """🧮 Opzoeken adres...""" +lnurlGettingUserMessage = """🧮 Voorbereiding van betaling...""" +lnurlPaymentFailed = """🚫 Betaling mislukt: %s""" +lnurlInvalidAmountMessage = """🚫 Ongeldig bedrag.""" +lnurlInvalidAmountRangeMessage = """🚫 Bedrag moet liggen tussen %s en %s.""" +lnurlNoUsernameMessage = """🚫 U moet een Telegram gebruikersnaam instellen om betalingen te ontvangen via LNURL.""" +lnurlHelpText = """📖 Oeps, dat werkte niet. %s + +*Usage:* `/lnurl [bedrag] ` +*Example:* `/lnurl LNURL1DP68GUR...`""" + +# LNURL WITHDRAW + +confirmLnurlWithdrawMessage = """Wilt u deze opname maken?💸 Bedrag: %s""" +lnurlPreparingWithdraw = """🧮 Opname aan het voorbereiden...""" +lnurlWithdrawFailed = """🚫 Opname mislukt.""" +lnurlWithdrawCancelled = """🚫 Opname geannuleerd.""" +lnurlWithdrawSuccess = """✅ Opname aangevraagd.""" + +# LINK + +walletConnectMessage = """🔗 *Koppel uw wallet* + +⚠️ Deel nooit de URL of de QR code met iemand anders of zij zullen toegang krijgen tot uw fondsen. + +- *BlueWallet:* Druk op *Nieuwe wallet*, *Import wallet*, *Scan of importeer een bestand*, en scan de QR code. +- *Zeus:* Kopieer de URL hieronder, druk op *Voeg een nieuw knooppunt toe*, *Import* (de URL), *Save Node Config*.""" +couldNotLinkMessage = """🚫 Uw wallet kon niet gelinkt worden. Probeer het later opnieuw.""" + +# FAUCET + +inlineQueryFaucetTitle = """🚰 Maak een kraan.""" +inlineQueryFaucetDescription = """Gebruik: @%s faucet """ +inlineResultFaucetTitle = """🚰 Maak een %s kraan.""" +inlineResultFaucetDescription = """👉 Klik hier om een kraan in deze chat te maken..""" + +inlineFaucetMessage = """Druk op ✅ om %s te verzamelen van deze kraan van %s. + +🚰 Remaining: %d/%s (given to %d/%d users) +%s""" +inlineFaucetEndedMessage = """🚰 kraan leeg 🍺🏅 %s gegeven aan %d gebruikers.""" +inlineFaucetAppendMemo = """\n✉️ %s""" +inlineFaucetCreateWalletMessage = """Chat met %s 👈 om uw wallet te beheren.""" +inlineFaucetCancelledMessage = """🚫 kraan geannuleerd.""" +inlineFaucetInvalidPeruserAmountMessage = """🚫 Per gebruiker bedrag niet deelbaar van capaciteit.""" +inlineFaucetInvalidAmountMessage = """🚫 Ongeldig bedrag.""" +inlineFaucetSentMessage = """🚰 %s gestuurd naar %s.""" +inlineFaucetReceivedMessage = """🚰 %s stuurde je %s.""" +inlineFaucetHelpFaucetInGroup = """Maak een kraan in een groep met de bot erin of gebruik 👉 inline commando (/advanced voor meer).""" +inlineFaucetHelpText = """📖 Oeps, dat werkte niet. %s + +*Usage:* `/faucet ` +*Example:* `/faucet 210 21`""" + +# INLINE VERZENDEN + +inlineQuerySendTitle = """💸 Stuur betaling naar een chat.""" +inlineQuerySendDescription = """Gebruik: @%s send [] []""" +inlineResultSendTitle = """💸 Stuur %s.""" +inlineResultSendDescription = """👉 Klik om %s naar deze chat te sturen.""" + +inlineSendMessage = """Druk op ✅ om de betaling van %s te ontvangen.\n💸 Bedrag: %s""" +inlineSendAppendMemo = """\n✉️ %s""" +inlineSendUpdateMessageAccept = """💸 %d verzonden van %s naar %s.""" +inlineSendCreateWalletMessage = """Chat met %s 👈 om uw wallet te beheren.""" +sendYourselfMessage = """📖 Je kunt niet aan jezelf betalen.""" +inlineSendFailedMessage = """🚫 verzenden mislukt.""" +inlineSendInvalidAmountMessage = """🚫 Bedrag moet groter zijn dan 0.""" +inlineSendBalanceLowMessage = """🚫 Uw saldo is te laag.""" + +# INLINE RECEIVE + +inlineQueryReceiveTitle = """🏅 Vraag een betaling in een chat.""" +inlineQueryReceiveDescription = """Gebruik: @%s receive [] []""" +inlineResultReceiveTitle = """🏅 Ontvang %s.""" +inlineResultReceiveDescription = """👉 Klik om een betaling van %s aan te vragen.""" + +inlineReceiveMessage = """Druk op 💸 om te betalen aan %s.\n💸 Bedrag: %s""" +inlineReceiveAppendMemo = """\n✉️ %s""" +inlineReceiveUpdateMessageAccept = """💸 %s verstuurd van %s naar %s.""" +inlineReceiveCreateWalletMessage = """Chat met %s 👈 om uw wallet te beheren.""" +inlineReceiveYourselfMessage = """📖 Je kunt niet aan jezelf betalen.""" +inlineReceiveFailedMessage = """🚫 Ontvangst mislukt.""" +inlineReceiveCancelledMessage = """🚫 Ontvangen geannuleerd.""" +# CONVERT LKR TO SAT +convertEnterAmountMessage = """Did you enter an amount?""" +convertInvalidAmountMessage = """Did you enter a valid amount?""" +convertPriceErrorMessage = """🚫 Couldn't fetch price.""" +convertResultMessage = """%.2f LKR is about %s.""" + +# CONVERT SAT TO FIAT +convertSatsResultMessage = """%s is about %s USD / %s LKR""" diff --git a/translations/pl.toml b/translations/pl.toml new file mode 100644 index 00000000..2c0f184b --- /dev/null +++ b/translations/pl.toml @@ -0,0 +1,411 @@ +# COMMANDS + +helpCommandStr = """pomoc""" +basicsCommandStr = """podstawy""" +tipCommandStr = """tip""" +balanceCommandStr = """saldo""" +sendCommandStr = """wyślij""" +invoiceCommandStr = """faktura""" +payCommandStr = """pay""" +donateCommandStr = """donate""" +advancedCommandStr = """advanced""" +transactionsCommandStr = """transactions""" +logCommandStr = """log""" +listCommandStr = """list""" + +linkCommandStr = """link""" +lnurlCommandStr = """lnurl""" +faucetCommandStr = """faucet""" + +tipjarCommandStr = """tipjar""" +receiveCommandStr = """receive""" +hideCommandStr = """hide""" +volcanoCommandStr = """volcano""" +showCommandStr = """show""" +optionsCommandStr = """options""" +settingsCommandStr = """settings""" +saveCommandStr = """save""" +deleteCommandStr = """delete""" +infoCommandStr = """info""" + +# NOTIFICATIONS + +cantDoThatMessage = """Nie możesz tego zrobić.""" +cantClickMessage = """Nie możesz kliknąć tego przycisku.""" +balanceTooLowMessage = """Twoje saldo jest za niskie.""" + +# BUTTONS + +sendButtonMessage = """✅ Wyślij""" +payButtonMessage = """✅ Zapłać""" +payReceiveButtonMessage = """💸 Zapłać""" +receiveButtonMessage = """✅ Otrzymaj""" +withdrawButtonMessage = """✅ Wypłać""" +cancelButtonMessage = """🚫 Anuluj""" +collectButtonMessage = """✅ Odbierz""" +nextButtonMessage = """Dalej""" +backButtonMessage = """Cofnij""" +acceptButtonMessage = """Potwierdź""" +denyButtonMessage = """Odmów""" +tipButtonMessage = """Tip""" +revealButtonMessage = """Ujawnij""" +showButtonMessage = """Pokaż""" +hideButtonMessage = """Ukryj""" +joinButtonMessage = """Dołącz""" +optionsButtonMessage = """Opcje""" +settingsButtonMessage = """Ustawienia""" +saveButtonMessage = """Zapisz""" +deleteButtonMessage = """Skasuj""" +infoButtonMessage = """Informacje""" + +cancelButtonEmoji = """🚫""" +payButtonEmoji = """💸""" + +# HELP + +helpMessage = """⚡️ *Portfel* +_Ten bot jest portfelem Bitcoin Lightning, który potrafi wysyłać napiwki na Telegramie. Aby zacząć dodaj tego bota do chatu grupowego. Podstawową jednostką dla napiwków są Satoshi (sat). 100 000 000 sat = 1 Bitcoin. Napisz 📚 /basics aby uzyskać więcej informacji._ + +❤️ *Wsparcie* +_Ten bot nie pobiera opłat, ale jego funkcjonowanie kosztuje. Jeśli podoba Ci się, rozważ proszę wsparcie tego projektu darowizną. Aby to zrobić użyj_ `/donate 1000` + +%s + +⚙️ *Komendy* +*/tip* 🏅 Odpowiedz na wiadomość aby dać napiwek: `/tip []` +*/balance* 👑 Sprawdź swoje saldo: `/balance` +*/send* 💸 Wyślij środki do użytkownika: `/send @użytkownik lub użytkownik@ln.tips []` +*/invoice* ⚡️ Otrzymaj przy pomocy Lightning: `/invoice []` +*/pay* ⚡️ Zapłać przy pomocy Lightning: `/pay ` +*/donate* ❤️ Wesprzyj projekt darowizną: `/donate 1000` +*/advanced* 🤖 Funkcje zaawansowane. +*/help* 📖 Przeczytaj tę stronę o pomocy.""" + +infoHelpMessage = """ℹ️ *Informacje*""" +infoYourLightningAddress = """Twój adres Lightning to `%s`""" + +basicsMessage = """🧡 *Bitcoin* +_Bitcoin jest walutą internetu. Nie wymaga pozwolenia, jest zdecentralizowany i nie ma władców ani kontrolujących go instytucji. Bitcoin jest twardym pieniądzem, który jest szybszy, bezpieczniejszy i inkluzywny niż dotychczasowy system finansowy._ + +🧮 *Ekonomia* +_Najmniejszą jednostką Bitcoina są Satoshi (sat) a 100 00 00 sat = 1 Bitcoin. Zawsze będzie tylko 21 milionów Bitcoinów. Ich cena w walucie fiducjarnego może zmieniać się codziennie. Jednak jeśli żyjesz na standardziej Bitcoina 1 sat zawsze będzie równy 1 sat._ + +⚡️ *Sieć Lightning* +_Sieć Lightning jest protokołem, który umożliwia szybkie i tanie płatności w Bitcoinie, nie potrzebujące do tego prawie żadnej energii. Dzięki temu umożliwia dotarcie do miliardów ludzi na całym świecie._ + +📲 *Portfele Lightning* +_Twoje środki na tym bocie mogą być wysłane na inne portfele Lightning i vice versa. Rekomendowane portfele dla Twojego telefonu to_ [Phoenix](https://phoenix.acinq.co/)_,_ [Breez](https://breez.technology/)_,_ [Muun](https://muun.com/)_ (niepowierniczy), lub_ [Wallet of Satoshi](https://www.walletofsatoshi.com/) _(łatwy)_. + +📄 *Open Source* +_Ten bot to wolne oprogramowanie [open source](https://github.com/LightningTipBot/LightningTipBot)_. Możesz go uruchomić na własnym komputerze i używać we własnej społeczności._ + +✈️ *Telegram* +_Dodaj tego bota do swojego chatu grupowego na Telegramie aby dawać napiwki za wiadomości. Jeśli uczynisz bota adminem grupy będzie czyścił komendy aby chat był uporządkowany._ + +🏛 *Warunki* +_Nie jesteśmy powiernikiem Twoich środków. Będziemy działać w Twoim najlepszym interesie ale jesteśmy świadomi tego, że sytuacja bez KYC jest trudna dopóki czegoś nie wymyślimy. +Jakakolwiek kwota, którą załadujesz na portfel będzie traktowana jako darowizna. Nie dawaj nam wszystkich swoich pieniędzy. Wiedz, że ten bota jest w fazie rozwoju beta. Używaj go na własne ryzko._ + +❤️ *Wesprzyj darowizną* +_Ten bot nie pobiera opłat, ale jego funkcjonowanie kosztuje. Jeśli podoba Ci się, rozważ proszę wsparcie tego projektu darowizną. Aby to zrobić użyj `/donate 1000`""" + +helpNoUsernameMessage = """👋 Podaj proszę nazwę swojego użytkownika w Telegramie.""" + +advancedMessage = """%s + +👉 *Komendy inline* +*send* 💸 Wysyła sats na chat: `%s send [] []` +*receive* 🏅 Prosi o płatność: `... receive [] []` +*faucet* 🚰 Tworzy kranik (_faucet_): `... faucet []` +*tipjar* 🍯 Tworzy słoik na napiwki (_tipjar_): `... tipjar []` + +📖 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. + +⚙️ *Komendy zaawansowane* +*/transactions* 📊 Lista transakcji +*/link* 🔗 Połączenie Twojego portfela z [BlueWallet](https://bluewallet.io/) lub [Zeus](https://zeusln.app/) +*/lnurl* ⚡️ Przyjmij płatność lub zapłać za pomocą Lnurl: `/lnurl` lub `/lnurl [memo]` +*/nostr* 💜 Connect to Nostr: `/nostr` +*/faucet* 🚰 Stwórz kranik (_faucet_): `/faucet ` +*/tipjar* 🍯 Stwórz słoik na napiwki (_tipjar_): `/tipjar ` +*/group* 🎟 Stwórz bilety na wejście do grupy: `/group add []` +*/shop* 🛍 Przeglądaj sklepy: `/shop` lub `/shop ` +*/generate* 🎆 Generuj obrazy przy użyciu DALLE-2: `/generate `""" + +# GENERIC +enterAmountRangeMessage = """💯 Wprowadź kwotę między %s a %s.""" +enterAmountMessage = """💯 Wprowadź kwotę.""" +enterUserMessage = """👤 Podaj użytkownika.""" +enterTextMessage = """⌨️ Wprowadź tekst.""" +errorReasonMessage = """🚫 Błąd: %s""" + +# START + +startSettingWalletMessage = """🧮 Tworzę Twój portfel...""" +startWalletCreatedMessage = """🧮 Portfel utworzony.""" +startWalletReadyMessage = """✅ *Twój portfel jest gotowy.*""" +startWalletErrorMessage = """🚫 Błąd przy inicjowaniu Twojego portfela. Spróbuj ponownie później.""" +startNoUsernameMessage = """☝️ Wygląda na to, że nie masz jeszcze nazwy @użytkownika w Telegramie. Nic nie szkodzi, nie potrzebujesz jej by używać tego bota. Jednak aby lepiej go wykorzystać, ustaw nazwę w ustawieniach aplikacji Telegram. Następnie napisz /balance aby bot mógł zaktualizować swoje dane o Tobie.""" + +# BALANCE + +balanceMessage = """👑 *Twoje saldo:* %s. (%s USD / %s LKR)""" +balanceErrorMessage = """🚫 Pobranie Twojego salda nie powiodło się. Spróbuj ponownie później.""" + +# TIP + +tipDidYouReplyMessage = """Czy odpowiadasz na wiadomość aby dać napiwek? Aby odpowiedzieć na jakąkolwiek wiadomość na komputerze, naciśnij prawy przycisk myszy i wybierz Odpowiedz, a na telefonie przesuń palcem. Jeśli chcesz wysłać bezpośrednio do innego użytkownika użyj komendy /send.""" +tipInviteGroupMessage = """ℹ️ Przy okazji, możesz zaprosić tego bota do dowolnej grupy aby zacząć dawać napiwki tam.""" +tipEnterAmountMessage = """Czy wprowadzono kwotę?""" +tipValidAmountMessage = """Czy wprowadzono poprawną kwotę?""" +tipYourselfMessage = """📖 Nie możesz dać sobie napiwku.""" +tipSentMessage = """💸 %s wysłano do %s.""" +tipReceivedMessage = """🏅 %s daje Ci napiwek w wysokości %s.""" +tipErrorMessage = """🚫 Napiwek się nie powiódł.""" +tipUndefinedErrorMsg = """spróbuj proszę później.""" +tipHelpText = """📖 Ojej, to nie zadziałało. %s + +*Użycie:* `/tip []` +*Przykład:* `/tip 1000 pojechany mem!`""" + +# SEND + +sendValidAmountMessage = """Czy podano poprawną kwotę?""" +sendUserHasNoWalletMessage = """🚫 Użytkownik %s jeszcze nie utworzył portfela.""" +sendSentMessage = """💸 %s wysłano do %s.""" +sendPublicSentMessage = """💸 %s wysłano od %s do %s.""" +sendReceivedMessage = """🏅 %s przysyła Ci %s.""" +sendErrorMessage = """🚫 Wysłanie nie powiodło się.""" +confirmSendMessage = """Czy chcesz zapłacić %s?\n\n💸 Kwota: %s""" +confirmSendAppendMemo = """\n✉️ %s""" +sendCancelledMessage = """🚫 Wysłanie anulowane.""" +errorTryLaterMessage = """🚫 Błąd. Spróbuj ponownie później.""" +sendSyntaxErrorMessage = """Czy wpropwadzono kwotę i odbiorcę? Możesz użyć komendy /send albo żeby wysłać do użytkownika takiego jak %s lub pod adres Lightning taki jak LightningTipBot@ln.tips.""" +sendHelpText = """📖 Ojej, to nie zadziałało. %s + +*Użycie:* `/send []` +*Przykład:* `/send 1000 @LightningTipBot Po prostu lubię tego bota ❤️` +*Przykład:* `/send 1234 LightningTipBot@ln.tips`""" + +# INVOICE + +invoiceReceivedMessage = """⚡️ Otrzymujesz %s.""" +invoiceReceivedCurrencyMessage = """⚡️ Otrzymujesz %s (%s %s).""" +invoiceEnterAmountMessage = """Czy wprowadzono kwotę?""" +invoiceValidAmountMessage = """Czy wprowadzono poprawną kwotę?""" +invoiceHelpText = """📖 Ojej, to nie zadziałało. %s + +*Użycie:* `/invoice []` +*Przykład:* `/invoice 1000 Dziękuję!`""" +invoicePaidText = """✅ Faktura załacona.""" + +# PAY + +paymentCancelledMessage = """🚫 Płatność anulowana.""" +invoicePaidMessage = """⚡️ Płatność wysłana.""" +invoicePublicPaidMessage = """⚡️ Płatność wysłana przez %s.""" +invalidInvoiceHelpMessage = """Czy podajesz poprawną fakturę Lightning? Spróbuj /send jeśli chcesz wysłać do użytkownika na Telegramie lub pod adres Lightning.""" +invoiceNoAmountMessage = """🚫 Nie można opłacić faktury bez kwoty.""" +insufficientFundsMessage = """🚫 Niewystarczające środki. Masz %s a potrzebujesz conajmniej %s.""" +feeReserveMessage = """⚠️ Wysyłanie swojego całkowitego salda może się nie powieść z powodu opłat sieciowych. Zarezerwuj conajmniej 1% na opłaty.""" +invoicePaymentFailedMessage = """🚫 Płatność nie powiodła się: %s""" +invoiceUndefinedErrorMessage = """Nie można było zapłacić faktury.""" +confirmPayInvoiceMessage = """Czy chcesz wysłać tę płatność?\n\n💸 Kwota: %s""" +confirmPayAppendMemo = """\n✉️ %s""" +payHelpText = """📖 Ojej, to nie zadziałało. %s + +*Użycie:* `/pay ` +*Przykład:* `/pay lnbc20n1psscehd...`""" + +# DONATE + +donationSuccess = """🙏 Dziękuję za twoją darowiznę.""" +donationErrorMessage = """🚫 O nie! Darowizna nie powiodła się.""" +donationProgressMessage = """🧮 Przygotowuję Twoją darowiznę...""" +donationFailedMessage = """🚫 Darowizna nie powiodła się: %s""" +donateEnterAmountMessage = """Czy podano kwotę?""" +donateValidAmountMessage = """Czy podano poprawną kwotę?""" +donateHelpText = """📖 Ojej, to nie zadziałało. %s + +*Użycie:* `/donate ` +*Przykład:* `/donate 1000`""" + +# PHOTO + +photoQrNotRecognizedMessage = """🚫 Nie udało się rozpoznać faktury Lightning ani LNURL. Wypośrodkuj kod QR, przytnij zdjęcie lub powiększ.""" +photoQrRecognizedMessage = """✅ Kod QR: +`%s`""" + +# LNURL + +lnurlReceiveInfoText = """👇 Możesz użyć teego statycznego kodu LNRUL aby otrzymywać płatności.""" +lnurlResolvingUrlMessage = """🧮 Rozwiązuje adres...""" +lnurlGettingUserMessage = """🧮 Przygotowuję płatność...""" +lnurlPaymentFailed = """🚫 Płatność się nie powiodła: %s""" +lnurlInvalidAmountMessage = """🚫 Nieprawidłowa kwota.""" +lnurlInvalidAmountRangeMessage = """🚫 Kwota musi być między %s a %s.""" +lnurlNoUsernameMessage = """🚫 Musisz ustawić nazwę użytkownika na Telegramie aby otrzymywać płatności poprzez LNURL.""" +lnurlHelpText = """📖 Ojej, to nie zadziałało. %s + +*Użycie:* `/lnurl [kwota] ` +*Przykład:* `/lnurl LNURL1DP68GUR...`""" + +# LNURL LOGIN + +confirmLnurlAuthMessager = """Czy chcesz się zalogować do %s?""" +lnurlSuccessfulLogin = """✅ Logowanie udało się.""" +loginButtonMessage = """✅ Zaloguj się""" +loginCancelledMessage = """🚫 Logowanie anulowane.""" + +# LNURL WITHDRAW + +confirmLnurlWithdrawMessage = """Czy chcesz dokonąć tej wypłaty?\n\n💸 Kwota: %s""" +lnurlPreparingWithdraw = """🧮 Przygotowywanie wypłaty...""" +lnurlWithdrawFailed = """🚫 Wypłata nie powiodła się.""" +lnurlWithdrawCancelled = """🚫 Wypłata anulowana.""" +lnurlWithdrawSuccess = """✅ Zażądano wypłaty.""" + +# LINK + +walletConnectMessage = """🔗 *Połącz swój portfel* + +⚠️ Nigdy nie ujawniaj adresu URL lub kodu QR nikomu, gdyż będą w stanie uzyskać dostęp do Twoich środków. Użyj /api aby uzyskać dostęp do swoich kluczy API. + +- *BlueWallet:* Naciśnij *Dodaj porfel*, *Import portfela*, *Skanuj lub importuj plik*, i przeskanuj kod QR. +- *Zeus:* Skopiuj poniższy adres URL, naciśnij *Dodaj nowy węzeł*, wybierz *LNDHub* jako interfejs węzła, wprowadź adres URL, *Zapisz konfigurację węzła*.""" +couldNotLinkMessage = """🚫 Nie udało się połączyć Twojego portfela. Spróbuj ponownie później.""" +linkHiddenMessage = """🔍 Połączenie ukryto. Napisz /link aby zobaczyć je ponownie.""" + +# API + +apiConnectMessage = """🔗 *Twoje klucze API* + +⚠️ Nigdy nie ujawniaj tych kluczy nikomu, gdyż będą w stanie uzyskać dostęp do Twoich środków. Użyj /link aby połączyć swój portfel. + +- *Klucz Admin:* `%s` +- *Klucz Invoice:* `%s`""" +apiHiddenMessage = """🔍 Klucze ukryte. Napisz /api aby zobaczyć je ponownie.""" + +# FAUCET + +inlineQueryFaucetTitle = """🚰 Utwórz kranik (_faucet_).""" +inlineQueryFaucetDescription = """Użycie: @%s faucet """ +inlineResultFaucetTitle = """🚰 Tworzenie kranika o pojemności %s.""" +inlineResultFaucetDescription = """👉 Kliknij tu aby utworzyć kranik w tym chacie.""" + +inlineFaucetMessage = """Naciśnij ✅ aby zebrać %s z kranika %s. + +🚰 Pozostało: %d/%s (rozdanych %d użytkownikom z %d) +%s""" +inlineFaucetEndedMessage = """🚰 Kranik pusty 🍺\n\n🏅 %s rozdano do %d użytkowników.""" +inlineFaucetAppendMemo = """\n✉️ %s""" +inlineFaucetCreateWalletMessage = """Chatuj z %s 👈 aby zarządzać swoim portfelem.""" +inlineFaucetCancelledMessage = """🚫 Kranik anulowany.""" +inlineFaucetInvalidPeruserAmountMessage = """🚫 Wartość na_użytkownika nie jest dzielnikiem pojemności lub jest za mała (min 5 sat).""" +inlineFaucetInvalidAmountMessage = """🚫 Nieprawidłowa kwota.""" +inlineFaucetSentMessage = """🚰 %s wysłano do %s.""" +inlineFaucetReceivedMessage = """🚰 %s wysyła Ci %s.""" +inlineFaucetHelpFaucetInGroup = """Utwórz w grupie z botem kranik (_facuet_) lub użyj 👉 komendy inline (zobacz /advanced aby uzyskać więcej informacji).""" +inlineFaucetAlreadyTookMessage = """🚫 Już brałeś z tego kranika.""" +inlineFaucetHelpText = """📖 Ojej, to nie zadziałało. %s + +*Użycie:* `/faucet ` +*Przykład:* `/faucet 210 21`""" + +# INLINE SEND + +inlineQuerySendTitle = """💸 Wyślij płatność do tego chatu.""" +inlineQuerySendDescription = """Użycie: @%s send [] []""" +inlineResultSendTitle = """💸 Wysłano %s.""" +inlineResultSendDescription = """👉 Kliknij aby wysłać %s do tego chatu.""" + +inlineSendMessage = """Naciśnij ✅ aby otrzymać płatność od %s.\n\n💸 Kwota: %s""" +inlineSendAppendMemo = """\n✉️ %s""" +inlineSendUpdateMessageAccept = """💸 %s wysłano od %s do %s.""" +inlineSendCreateWalletMessage = """Chatuj z %s 👈 aby zarządzać swoim portfelem.""" +sendYourselfMessage = """📖 Nie możesz zapłacić sobie.""" +inlineSendFailedMessage = """🚫 Wysłanie nie powiodło się.""" +inlineSendInvalidAmountMessage = """🚫 Kwota musi być większa niż 0.""" +inlineSendBalanceLowMessage = """🚫 Twoje saldo jest za niskie.""" + +# INLINE RECEIVE + +inlineQueryReceiveTitle = """🏅 Poproś o płatność na chacie.""" +inlineQueryReceiveDescription = """Użycie: @%s receive [] []""" +inlineResultReceiveTitle = """🏅 Otrzymaj %s.""" +inlineResultReceiveDescription = """👉 Kliknij aby poprosić o płatność %s.""" + +inlineReceiveMessage = """Naciśnij 💸 aby zapłacić %s.\n\n💸 Kwota: %s""" +inlineReceiveAppendMemo = """\n✉️ %s""" +inlineReceiveUpdateMessageAccept = """💸 %s wysłano od %s do %s.""" +inlineReceiveCreateWalletMessage = """Chatuj z %s 👈 aby zarządzać swoim portfelem.""" +inlineReceiveYourselfMessage = """📖 Nie możesz zapłacić sobie.""" +inlineReceiveFailedMessage = """🚫 Przyjmowanie środków nie powiodło się.""" +inlineReceiveCancelledMessage = """🚫 Przyjmowanie środków anulowane.""" + +# TIPJAR + +inlineQueryTipjarTitle = """🍯 Utwórz słoik na napiwki (_tipjar_).""" +inlineQueryTipjarDescription = """Użycie: @%s tipjar """ +inlineResultTipjarTitle = """🍯 Tworzenie słoika na napiwki (_tipjar_) o pojemności %s.""" +inlineResultTipjarDescription = """👉 Kliknij tu aby utworzyć w tym chacie słoik na napiwki (_tipjar_).""" + +inlineTipjarMessage = """Naciśnij 💸 aby *wpłacić %s* do tego słoika na napiwki (_tipjar_) przez %s. + +🙏 Przekazano: *%d*/%s (przez %d użytkowników) +%s""" +inlineTipjarEndedMessage = """🍯 Słoik na napiwki %s jest pełen ⭐️\n\n🏅 %s przekazanych przez %d użytkowników.""" +inlineTipjarAppendMemo = """\n✉️ %s""" +inlineTipjarCancelledMessage = """🚫 Słoik na napiwki anulowany.""" +inlineTipjarInvalidPeruserAmountMessage = """🚫 Kwota na_użytkownika nie jest dzielnikiem pojemności.""" +inlineTipjarInvalidAmountMessage = """🚫 Nieprawidłowa kwota.""" +inlineTipjarSentMessage = """🍯 %s wysłano do %s.""" +inlineTipjarReceivedMessage = """🍯 %s wysyła Ci %s.""" +inlineTipjarHelpTipjarInGroup = """Stwórz w grupie z tym botem słoik na napiwki (_tipjar_) lub użyj 👉 komendy inline (zobacz /advanced aby uzyskać więcej informacji).""" +inlineTipjarHelpText = """📖 Ojej, to nie zadziałało. %s + +*Użycie:* `/tipjar ` +*Przykład:* `/tipjar 210 21`""" + +# GROUP TICKETS +groupAddGroupHelpMessage = """📖 Ojej, to nie zadziałało. Ta komenda działa tylko w chacie grupowym. Tylko właściciele grupy mogą użyć tej komendy.\nUżycie: `/group add []`\nPrzykład: `/group add NajlepszaGrupaBitcoinowa 1000`""" +groupJoinGroupHelpMessage = """📖 Ojej, to nie zadziałało. Spróbuj ponownie później.\nUżycie: `/join `\nPrzykład: `/join NajlepszaGrupaBitcoinowa`""" +groupClickToJoinMessage = """🎟 [Kliknij tu](%s) 👈 aby dołączyć do `%s`.""" +groupTicketIssuedGroupMessage = """🎟 Użytkownik %s otrzymuje bilet do tej grupy.""" +groupPayInvoiceMessage = """🎟 Aby dołączyć do grupy %s, opłać powyższą fakturę.""" +groupBotIsNotAdminMessage = """🚫 Ojej, to nie zadziałało. Musisz uczynić mnie administratorem i nadać prawo do zapraszania użytkowników.""" +groupNameExists = """🚫 Grupa o tej nazwie już istnieje. Wybierz inną nazwę.""" +groupAddedMessage = """🎟 Bilety dla grupy `%s` dodane.\nAlias: `%s` Cena: %s\n\nAby poprosić o bilet na tę grupę, rozpocznij prywatny czat z %s i napisz `/join %s`.""" +groupNotFoundMessage = """🚫 Nie znaleziono grupy o tej nazwie.""" +groupReceiveTicketInvoiceCommission = """🎟 Otrzymujesz *%s* (wyłączając. %s prowizji) za bilet na grupę `%s` zapłacony przez %s.""" +groupReceiveTicketInvoice = """🎟 Otrzymujesz *%s* za bilet na grupę `%s` zapłacony przez %s.""" +commandPrivateMessage = """Użyj proszę tej komendy na czacie prywatnym z %s.""" +groupHelpMessage = """🎟 Bilety na prywatne grupy 🎟 + +Sprzedawaj bilety na swoją _prywatną grupę_ i pozbądź się spamujących botów. + +*Instrukcje dla administratorów grup:* + +1) Zaproś %s do swojej grupy i nadaj uprawnienia administratora. +2) Uczyń swoją grupę prywatną. +3) W grupie, napisz (będąc właścicielem grupy) napisz `/group add []`. + +_Opłaty: Bot bierze prowizję w wysokości 10%% + 10 sat za tanie bilety. Jeśli cena bilety >= 1000 sat, prowizja wynosi 2%% + 100 sat._ + +*Instrukcje dla członków grupy:* + +Aby dołączyć do grupy, napisz do %s i napisz w prywatnej wiadomości `/join `. + +📖 *Użycie:* +*Dla adminów (w czacie grupowym):* `/group add []`\nPrzykład: `/group add NajlepszaGrupaBitcoinowa 1000` +*Dla użytkowników (w czacie prywatnym):* `/join `\nPrzykład: `/join NajlepszaGrupaBitcoinowa`""" + +# DALLE GENERATE +generateDalleHelpMessage = """Generuj obrazy przy użyciu OpenAI DALLE 2.\nUżycie: `/generate `\nCena: 1000 sat""" +generateDallePayInvoiceMessage = """Opłać tę fakturę by wygenerować 4 obrazy 👇""" +generateDalleGeneratingMessage = """Twoje obrazy są generowane. Proszę czekaj...""" +# CONVERT LKR TO SAT +convertEnterAmountMessage = """Czy wprowadzono kwotę?""" +convertInvalidAmountMessage = """Czy wprowadzono poprawną kwotę?""" +convertPriceErrorMessage = """🚫 Nie udało się pobrać ceny.""" +convertResultMessage = """%.2f LKR to około %s.""" diff --git a/translations/pt-br.toml b/translations/pt-br.toml new file mode 100644 index 00000000..ec03e1ea --- /dev/null +++ b/translations/pt-br.toml @@ -0,0 +1,354 @@ +# COMMANDS + +helpCommandStr = """ajuda""" +basicsCommandStr = """conceitos""" +tipCommandStr = """gorjeta""" +balanceCommandStr = """saldo""" +sendCommandStr = """enviar""" +invoiceCommandStr = """fatura""" +payCommandStr = """pagar""" +donateCommandStr = """doar""" +advancedCommandStr = """avançado""" +transactionsCommandStr = """transações""" +logCommandStr = """log""" +listCommandStr = """lista""" + +linkCommandStr = """enlace""" +lnurlCommandStr = """lnurl""" +faucetCommandStr = """torneira""" + +tipjarCommandStr = """cofrinho""" +receiveCommandStr = """receber""" +hideCommandStr = """ocultar""" +volcanoCommandStr = """vulcão""" +showCommandStr = """mostrar""" +optionsCommandStr = """opções""" +settingsCommandStr = """configurações""" +saveCommandStr = """salvar""" +deleteCommandStr = """apagar""" +infoCommandStr = """info""" + +# NOTIFICATIONS + +cantDoThatMessage = """Não se pode fazer isso.""" +cantClickMessage = """Não se pode clicar neste botão.""" +balanceTooLowMessage = """Seu saldo é muito baixo.""" + +# BUTTONS + +sendButtonMessage = """✅ Enviar""" +payButtonMessage = """✅ Pagar""" +payReceiveButtonMessage = """💸 Pagar""" +receiveButtonMessage = """✅ Receber""" +cancelButtonMessage = """🚫 Cancelar""" +collectButtonMessage = """✅ Cobrar""" +nextButtonMessage = """Seguinte""" +backButtonMessage = """Voltar""" +acceptButtonMessage = """Aceitar""" +denyButtonMessage = """Negar""" +tipButtonMessage = """Gorjeta""" +revealButtonMessage = """Revelar""" +showButtonMessage = """Mostrar""" +hideButtonMessage = """Ocultar""" +joinButtonMessage = """Unir""" +optionsButtonMessage = """Opções""" +settingsButtonMessage = """Configurações""" +saveButtonMessage = """Salvar""" +deleteButtonMessage = """Apagar""" +infoButtonMessage = """Info""" + +# HELP + +helpMessage = """⚡️ *Carteira* +_Este bot é uma carteira Bitcoin Lightning que pode enviar gorjetas via Telegram. Para dar gorjetas, adicione o bot a um bate-papo em grupo. A unidade básica das gorjetas é Satoshis (sat). 100.000.000 sat = 1 Bitcoin. Digite 📚 /basics para saber mais._ + +❤️ *Doar* +_Este bot não cobra nenhuma comissão, mas custa Satoshis para funcionar. Se você gosta do bot, por favor, considere apoiar este projeto com uma doação. Para doar, utilize_ `/donate 1000`. + +%s + +⚙️ *Comandos* +*/tip* 🏅 Responder uma mensagem para dar uma gorjeta: `/tip []` +*/balance* 👑 Confira seu saldo: `/balance`. +*/send* 💸 Enviar dinheiro para um usuário: `/send @user ou user@ln.tips []` +*/invoice* ⚡️ Receber com Lightning: `/invoice []` +*/pay* ⚡️ Pagar com Lightning: `/pay ` +*/donate* ❤️ Doar ao projeto: `/donate 1000` +*/advanced* 🤖 Funções avançadas. +*/help* 📖 Ler esta ajuda.""" + +infoHelpMessage = """ℹ️ *Info*""" +infoYourLightningAddress = """Seu Lightning address _(endereço lightning)_ é `%s`.""" + +basicsMessage = """🧡 *Bitcoin* +_Bitcoin é a moeda da Internet. É uma moeda sem permissão, descentralizada, sem proprietários e sem autoridade de controle. Bitcoin é dinheiro sólido, mais rápido, mais seguro e mais inclusivo do que o sistema financeiro fiat._ + +🧮 *Economia* +_A menor unidade de Bitcoin é Satoshis (sat) e 100.000.000 sat = 1 Bitcoin. Haverá apenas 21 milhões de Bitcoin. O valor do Bitcoin em moeda fiduciária pode mudar diariamente. No entanto, se você vive no padrão Bitcoin, 1 sat será sempre 1 sat._ + +⚡️ *A Rede Lightning (Relâmpago)* +_A rede Lightning é um protocolo de pagamento que permite que os pagamentos Bitcoin sejam feitos de forma rápida e econômica, com consumo mínimo de energia. É o que faz o Bitcoin escalar para bilhões de pessoas em todo o mundo._ + +📲 *Carteiras Lightning (Relâmpago)* +_Seu dinheiro neste bot pode ser enviado para qualquer outra carteira da Lightning e vice versa. As carteiras Lightning recomendadas para seu telefone são_ [Phoenix](https://phoenix.acinq.co/)_,_ [Breez](https://breez.technology/)_,_ [Muun](https://muun.com/)_ (não-custodial), ou_ [Wallet of Satoshi](https://www.walletofsatoshi.com/) _(fácil)_. + +📄 *Código aberto* +_Este bot é gratuito e de_ [Código aberto](https://github.com/LightningTipBot/LightningTipBot)_. Você pode executá-lo no seu próprio computador e utilizá-lo na sua própria comunidade._ + +✈️ *Telegram* +_Adicione este bot ao seu bate-papo de grupo de Telegram para enviar dinheiro usando o comando /tip. Se você fizer do bot o administrador do grupo, ele também limpará os comandos para manter o bate-papo arrumado._ + +🏛 *Termos* +_Nós não somos custódios do seu dinheiro. Atuaremos no seu melhor interesse, mas também estamos conscientes de que a situação sem KYC é complicada até encontrarmos uma solução ótima. Qualquer quantia que você colocar na sua carteira será considerada uma doação. Não ponha todo o seu dinheiro. Favor observar que este bot está em desenvolvimento beta. Use-o pela sua própria conta e risco._ + +❤️ *Doar* +_Este bot não cobra nenhuma comissão, mas custa satoshis para funcionar. Se você gosta do bot, por favor, considere apoiar este projeto com uma doação. Para doar, utilize_ `/donate 1000`.""" + +helpNoUsernameMessage = """👋 Por favor, digite um nome de usuário do Telegram.""" + +advancedMessage = """%s + +👉 *Comandos Inline* +*send* 💸 Enviar sats ao bate-papo: `%s send [] []` +*receive* 🏅 Solicite um pagamento: `... receive [] []` +*faucet* 🚰 Criar uma torneira: `... faucet []` +*tipjar* 🍯 Criar uma tipjar: `... tipjar []` + +📖 Você pode usar comandos _inline_ em todas as conversas, mesmo em conversas privadas. Espere um segundo após inserir um comando _inline_ e *clique* no resultado, não pressione enter. + +⚙️ *Comandos avançados* +*/transactions* 📊 List transactions +*/link* 🔗 Vincule sua carteira a [ BlueWallet ](https://bluewallet.io/) ou [ Zeus ](https://zeusln.app/) +*/lnurl* ⚡️ Receber ou pagar com lnurl: `/lnurl` o `/lnurl [memo]` +*/nostr* 💜 Connect to Nostr: `/nostr` +*/faucet* 🚰 Criar uma torneira: `/faucet ` +*/tipjar* 🍯 Criar uma tipjar: `/tipjar ` +*/group* 🎟 Create group tickets: `/group add []` +*/shop* 🛍 Browse shops: `/shop` or `/shop ` +*/generate* 🎆 Generate DALLE-2 images: `/generate `""" + +# GENERIC +enterAmountRangeMessage = """💯 Insira uma quantia entre %s e %s.""" +enterAmountMessage = """💯 Insira uma quantia.""" +enterUserMessage = """👤 Insira um usuário.""" +errorReasonMessage = """🚫 Erro: %s""" + +# START + +startSettingWalletMessage = """🧮 Preparando sua carteira...""" +startWalletCreatedMessage = """🧮 Carteira criada.""" +startWalletReadyMessage = """✅ *Sua carteira está pronta.*""" +startWalletErrorMessage = """🚫 Erro ao iniciar sua carteira. Por favor, tente novamente mais tarde.""" +startNoUsernameMessage = """☝️ Parece que você ainda não tem um @nomedeusuário no Telegram. Tudo bem, você não precisa de um para usar este bot. Porém, para fazer melhor uso de sua carteira, defina um nome de usuário nas suas configurações de Telegram. Depois, entre /balance para que o bot possa atualizar seu registro de você.""" + +# BALANCE + +balanceMessage = """👑 *Seu saldo:* %s. (%s USD / %s LKR)""" +balanceErrorMessage = """🚫 Seu saldo não pôde ser recuperado. Por favor, tente novamente mais tarde.""" + +# TIP + +tipDidYouReplyMessage = """Você já respondeu em uma mensagem para dar gorjeta? Para responder qualquer mensagem, clique com o botão direito do mouse -> Responder no seu computador ou deslize a mensagem no seu telefone. Se você quiser enviar diretamente para outro usuário, use o comando /send.""" +tipInviteGroupMessage = """ℹ️ A propósito, você pode convidar este bot a qualquer grupo para começar a dar gorjetas lá.""" +tipEnterAmountMessage = """Você inseriu uma quantia?""" +tipValidAmountMessage = """Você inseriu uma quantia válida?""" +tipYourselfMessage = """📖 Você não pode se dar gorjetas.""" +tipSentMessage = """💸 %s enviado a %s.""" +tipReceivedMessage = """🏅 %s lhe deu uma gorjeta de %s.""" +tipErrorMessage = """🚫 A gorjeta falhou.""" +tipUndefinedErrorMsg = """Por favor, tente mais tarde""" +tipHelpText = """📖 Opa, isso não funcionou. %s + +*Uso:* `/tip []` +*Exemplo:* `/tip 1000 Meme ruim!`""" + +# SEND + +sendValidAmountMessage = """Você inseriu uma quantia válida?""" +sendUserHasNoWalletMessage = """🚫 O usuário %s ainda não criou uma carteira.""" +sendSentMessage = """💸 %s enviado a %s.""" +sendPublicSentMessage = """💸 %s enviado(s) de %s a %s.""" +sendReceivedMessage = """🏅 %s lhe enviou %s.""" +sendErrorMessage = """🚫 Envio sem sucesso.""" +confirmSendMessage = """Deseja pagar %s?\n\n💸 Quantia: %s""" +confirmSendAppendMemo = """\n✉️ %s""" +sendCancelledMessage = """🚫 Envio cancelado.""" +errorTryLaterMessage = """🚫 Erro. Por favor, tente mais tarde.""" +sendSyntaxErrorMessage = """Você já inseriu uma quantia e um destinatário? Você pode usar o comando /send para enviar aos usuários de Telegram como %s ou para um Endereço Lightning como LightningTipBot@ln.tips.""" +sendHelpText = """📖 Opa, isso não funcionou. %s + +*Uso:* `/send []` +*Exemplo:* `/send 1000 @LightningTipBot Gosto do bot ❤️`. +*Exemplo:* `/send 1234 LightningTipBot@ln.tips`""" + +# INVOICE + +invoiceReceivedMessage = """⚡️ Recebeu %s.""" +invoiceReceivedCurrencyMessage = """⚡️ Recebeu %s (%s %s).""" +invoiceEnterAmountMessage = """Você inseriu uma quantia?""" +invoiceValidAmountMessage = """Você inseriu uma quantia válida?""" +invoiceHelpText = """📖 Opa, isso não funcionou. %s + +*Uso:* `/invoice []` +*Exemplo:* `/invoice 1000 Obrigado!`""" + +# PAY + +paymentCancelledMessage = """🚫 Pagamento cancelado.""" +invoicePaidMessage = """⚡️ Pagamento enviado.""" +invoicePublicPaidMessage = """⚡️ Pagamento enviado por %s.""" +invalidInvoiceHelpMessage = """Você já inseriu uma fatura Lightning válida? Tente /send se você quiser enviar a um usuário de Telegram ou a um endereço de Lightning.""" +invoiceNoAmountMessage = """🚫 As contas não podem ser pagas sem um valor.""" +insufficientFundsMessage = """🚫 Dinheiro insuficiente. Tem %s mas precisa pelo menos %s.""" +feeReserveMessage = """⚠️ O envio de todo o saldo pode falhar devido às taxas de rede. Se falhar, tente enviar um pouco menos.""" +invoicePaymentFailedMessage = """🚫 Falha no pagamento: %s""" +invoiceUndefinedErrorMessage = """A fatura não pôde ser paga.""" +confirmPayInvoiceMessage = """Você quer enviar este pagamento?\n\n💸 Quantia: %s""" +confirmPayAppendMemo = """\n✉️ %s""" +payHelpText = """📖 Opa, isso não funcionou. %s + +*Uso:* `/pay ` +*Exemplo:* `/pay lnbc20n1psscehd...`""" + +# DONATE + +donationSuccess = """🙏 Obrigado pela sua doação.""" +donationErrorMessage = """🚫 Ó, não. Doação sem sucesso.""" +donationProgressMessage = """🧮 Preparando sua doação...""" +donationFailedMessage = """🚫 Doação sem sucesso: %s""" +donateEnterAmountMessage = """Você inseriu uma quantia?""" +donateValidAmountMessage = """Você inseriu uma quantia válida?""" +donateHelpText = """📖 Opa, isso não funcionou. %s + +*Uso:* `/donate ` +*Exemplo:* `/donate 1000`""" + +# PHOTO + +photoQrNotRecognizedMessage = """🚫 Uma fatura de Lightning ou LNURL não pôde ser reconhecida. Tente centralizar o código QR, cortando ou aumentando a foto.""" +photoQrRecognizedMessage = """✅ Código QR: +`%s`""" + +# LNURL + +lnurlReceiveInfoText = """👇 Você pode usar este LNURL estático para receber pagamentos.""" +lnurlResolvingUrlMessage = """🧮 Solucionando o endereço...""" +lnurlGettingUserMessage = """🧮 Preparando o pagamento...""" +lnurlPaymentFailed = """🚫 Falha no pagamento: %s""" +lnurlInvalidAmountMessage = """🚫 Quantia inválida.""" +lnurlInvalidAmountRangeMessage = """🚫 A quantia deve estar entre %s e %s.""" +lnurlNoUsernameMessage = """🚫 Você precisa configurar um nome de usuário de Telegram para receber pagamentos através do LNURL.""" +lnurlHelpText = """📖 Opa, isso não funcionou. %s + +*Uso:* `/lnurl [quantidade] ` +*Exemplo:* `/lnurl LNURL1DP68GUR...`""" + +# LNURL LOGIN + +confirmLnurlAuthMessager = """Você quer fazer login em %s?""" +lnurlSuccessfulLogin = """✅ Login com sucesso.""" +loginButtonMessage = """✅ Login""" +loginCancelledMessage = """🚫 Login cancelado.""" + +# LNURL WITHDRAW + +confirmLnurlWithdrawMessage = """Você quer fazer esse saque?\n💸 Quantia: %s""" +lnurlPreparingWithdraw = """🧮 Preparando o saque...""" +lnurlWithdrawFailed = """🚫 Saque falhou.""" +lnurlWithdrawCancelled = """🚫 Saque cancelado.""" +lnurlWithdrawSuccess = """✅ Saque solicitado.""" + +# LINK + +walletConnectMessage = """🔗 *Vincule sua carteira* + +⚠️ Nunca compartilhe a URL ou o código QR com ninguém ou eles poderão acessar seus fundos.. + +- *BlueWallet:* Toque em *Nova carteira*, *Importar carteira*, *Escanear ou importar um arquivo*, e escanear o código QR. +- *Zeus:* Copie a URL abaixo, clique *Adicionar um novo nodo*, *Importar* (a URL), *Salvar configuração do nodo*.""" +couldNotLinkMessage = """🚫 Sua carteira não poderia ser ligada. Por favor, tente novamente mais tarde.""" + +# FAUCET + +inlineQueryFaucetTitle = """🚰 Crie uma torneira.""" +inlineQueryFaucetDescription = """Uso: @%s faucet """ +inlineResultFaucetTitle = """🚰 Criar uma torneira %s.""" +inlineResultFaucetDescription = """👉 Aperte aqui para criar uma torneira neste chat.""" + +inlineFaucetMessage = """Aperte ✅ para coletar %s desta torneira de %s. + +🚰 Restante: %d/%s (para %d/%d usuários) +%s""" +inlineFaucetEndedMessage = """🚰 Torneira vazia 🍺\n\n🏅 %s para %d usuários.""" +inlineFaucetAppendMemo = """\n✉️ %s""" +inlineFaucetCreateWalletMessage = """Bate-papo com %s 👈 para administrar sua carteira.""" +inlineFaucetCancelledMessage = """🚫 Torneira cancelada.""" +inlineFaucetInvalidPeruserAmountMessage = """🚫 A quantia por usuário não é divisória da capacidade.""" +inlineFaucetInvalidAmountMessage = """🚫 Quantia inválida.""" +inlineFaucetSentMessage = """🚰 %s enviado(s) a %s.""" +inlineFaucetReceivedMessage = """🚰 %s lhe enviou %s.""" +inlineFaucetHelpFaucetInGroup = """Criar uma torneira em grupo com o bot dentro ou usar o 👉 comando_inline_ (/advanced para mais).""" +inlineFaucetHelpText = """📖 Opa, isso não funcionou. %s + +*Uso:* `/faucet ` +*Exemplo:* `/faucet 210 21`""" + +# INLINE SEND + +inlineQuerySendTitle = """💸 Enviar pagamento para um bate-papo.""" +inlineQuerySendDescription = """Uso: @%s send [] []""" +inlineResultSendTitle = """💸 Enviar %s.""" +inlineResultSendDescription = """👉 Clique para enviar %s neste bate-papo.""" + +inlineSendMessage = """Clique ✅ para receber o pagamento de %s.\n\n💸 Quantidade: %s""" +inlineSendAppendMemo = """\n✉️ %s""" +inlineSendUpdateMessageAccept = """💸 %s enviado de %s a %s.""" +inlineSendCreateWalletMessage = """Bate-papo com %s 👈 para administrar sua carteira.""" +sendYourselfMessage = """📖 Você não pode pagar a si mesmo.""" +inlineSendFailedMessage = """🚫 Envio sem sucesso.""" +inlineSendInvalidAmountMessage = """🚫 A quantia deve ser maior do que 0.""" +inlineSendBalanceLowMessage = """🚫 Seu saldo é muito baixo.""" + +# INLINE RECEIVE + +inlineQueryReceiveTitle = """🏅 Solicite um pagamento em um bate-papo.""" +inlineQueryReceiveDescription = """Uso: @%s receive [] []""" +inlineResultReceiveTitle = """🏅 Receber %s.""" +inlineResultReceiveDescription = """👉 Clique para solicitar um pagamento de %s.""" + +inlineReceiveMessage = """Clique 💸 para pagar a %s.\n\n💸 Quantia: %s""" +inlineReceiveAppendMemo = """\n✉️ %s""" +inlineReceiveUpdateMessageAccept = """💸 %s enviado de %s a %s.""" +inlineReceiveCreateWalletMessage = """Bate-papo com %s 👈 para administrar sua carteira.""" +inlineReceiveYourselfMessage = """📖 Você não pode pagar a si mesmo.""" +inlineReceiveFailedMessage = """🚫 O recebimento falhou.""" +inlineReceiveCancelledMessage = """🚫 Recepção cancelada.""" + +# TIPJAR + +inlineQueryTipjarTitle = """🍯 Criar um cofrinho.""" +inlineQueryTipjarDescription = """Uso: @%s cofrinho """ +inlineResultTipjarTitle = """🍯 Criar um cofrinho de %s.""" +inlineResultTipjarDescription = """👉 Clique aqui para criar um cofrinho neste chat.""" + +inlineTipjarMessage = """Pressione 💸 para *pagar %s* a esse cofrinho de %s. + +🙏 Doados: *%d*/%s (por %d usuários) +%s""" +inlineTipjarEndedMessage = """🍯 O cofrinho de %s está cheio ⭐️\n\n🏅 %s doados por %d usuários.""" +inlineTipjarAppendMemo = """\n✉️ %s""" +inlineTipjarCancelledMessage = """🚫 Cofrinho cancelado.""" +inlineTipjarInvalidPeruserAmountMessage = """🚫 Quantidade por usuário não divisor da capacidade.""" +inlineTipjarInvalidAmountMessage = """🚫 Quantia inválida.""" +inlineTipjarSentMessage = """🍯 %s enviado(s) a %s.""" +inlineTipjarReceivedMessage = """🍯 %s lhe enviou %s.""" +inlineTipjarHelpTipjarInGroup = """Criar uma cofrinho em grupo com o bot dentro ou usar o 👉 comando_inline_ (/advanced para mais).""" +inlineTipjarHelpText = """📖 Opa, isso não funcionou. %s + +*Uso:* `/tipjar ` +*Exemplo:* `/tipjar 210 21`""" +# CONVERT LKR TO SAT +convertEnterAmountMessage = """Você inseriu uma quantia?""" +convertInvalidAmountMessage = """Você inseriu uma quantia válida?""" +convertPriceErrorMessage = """🚫 Não foi possível obter o preço.""" +convertResultMessage = """%.2f LKR são cerca de %s.""" diff --git a/translations/ru.toml b/translations/ru.toml new file mode 100644 index 00000000..6c9ee6c9 --- /dev/null +++ b/translations/ru.toml @@ -0,0 +1,350 @@ +# COMMANDS + +helpCommandStr = """справка""" +basicsCommandStr = """основы""" +tipCommandStr = """tip""" +balanceCommandStr = """баланс""" +sendCommandStr = """отправить""" +invoiceCommandStr = """инвойс""" +payCommandStr = """оплатить""" +donateCommandStr = """пожертвовать""" +advancedCommandStr = """детально""" +transactionsCommandStr = """транзакции""" +logCommandStr = """лог""" +listCommandStr = """список""" + +linkCommandStr = """link""" +lnurlCommandStr = """lnurl""" +faucetCommandStr = """faucet""" + +tipjarCommandStr = """tipjar""" +receiveCommandStr = """получить""" +hideCommandStr = """спрятать""" +volcanoCommandStr = """volcano""" +showCommandStr = """показать""" +optionsCommandStr = """опции""" +settingsCommandStr = """настройки""" +saveCommandStr = """сохранить""" +deleteCommandStr = """удалить""" +infoCommandStr = """инфо""" + +# NOTIFICATIONS + +cantDoThatMessage = """Вы не можете это сделать.""" +cantClickMessage = """Вы не можете нажать на эту кнопку.""" +balanceTooLowMessage = """Недостаточно средств.""" + +# BUTTONS + +sendButtonMessage = """✅ Отправить""" +payButtonMessage = """✅ Оплатить""" +payReceiveButtonMessage = """💸 Оплатить""" +receiveButtonMessage = """✅ Получить""" +withdrawButtonMessage = """✅ Вывести""" +cancelButtonMessage = """🚫 Отмена""" +collectButtonMessage = """✅ Забрать""" +nextButtonMessage = """Вперед""" +backButtonMessage = """Назад""" +acceptButtonMessage = """Принять""" +denyButtonMessage = """Запретить""" +tipButtonMessage = """Tip""" +revealButtonMessage = """Развернуть""" +showButtonMessage = """Показать""" +hideButtonMessage = """Спрятать""" +joinButtonMessage = """Присоединиться""" +optionsButtonMessage = """Опции""" +settingsButtonMessage = """Настройки""" +saveButtonMessage = """Сохранить""" +deleteButtonMessage = """Удалить""" +infoButtonMessage = """Инфо""" + +cancelButtonEmoji = """🚫""" +payButtonEmoji = """💸""" + +# HELP + +helpMessage = """⚡️ *Wallet* +_Этот бот представляет собой кошелек Bitcoin Lightning, который может отправлять Tip (небольшие сумму в Сатоши) через Telegram. Чтобы отправить Tip, добавьте бота в групповой чат. Основной единицей чаевых являются сатоши (sat). 100 000 000 сат = 1 биткоин. Введите 📚 /basics для получения дополнительной информации._ + +❤️ *Donate* +_Этот бот не взимает комиссии, но его работа обходится в сатоши. Если вам нравится бот, пожалуйста, поддержите этот проект пожертвованием. Чтобы сделать пожертвование, воспользуйтесь командой_ `/donate 1000` + +%s + +⚙️ *Команды* +*/tip* 🏅 Ответьте на сообщение чтобы отправить Tip (небольшую сумму в Сатоши): `/tip []` +*/balance* 👑 Проверить баланс: `/balance` +*/send* 💸 Отправить ссредства пользователю: `/send <количество> @ или @ln.tips []` +*/invoice* ⚡️ Получить через Lightning: `/invoice <количество> []` +*/pay* ⚡️ Оплатить через Lightning: `/pay <инвойс>` +*/donate* ❤️ Отправить пожертвование проекту: `/donate 1000` +*/advanced* 🤖 Продвинутые возможности. +*/help* 📖 Прочитайте справку.""" + +infoHelpMessage = """ℹ️ *Info*""" +infoYourLightningAddress = """Ваш адрес в системе Lightning - `%s`""" + +basicsMessage = """🧡 *Биткоин* +_Биткоин - это валюта интернета. Она не имеет ограничений и децентрализована, у нее нет хозяев и контролирующих органов. Биткойн - это разумные деньги, которые быстрее, надежнее и всеохватнее, чем традиционная финансовая система._ + +🧮 *Экономика* +_Наименьшей единицей Биткоина являются "Сатоши" (sat), а 100 000 000 sat = 1 Биткойн. В мире существует только 21 миллион биткоинов. Стоимость Биткойна выражаемая в фиатной валюте может меняться ежедневно. Однако, если вы живете по стандарту Биткойна, то 1 sat всегда будет равен 1 sat._ + +⚡️ *The Lightning Network* +_The Lightning Network - это платежный протокол, который обеспечивает быстрые и дешевые платежи Биткоинами, практически не требующие энергии. Именно благодаря ему Биткоин доступен миллиардам людей по всему миру._ + +📲 *Lightning-кошельки* +_Ваши средства на этом боте могут быть отправлены на любой другой Lightning-кошелек и наоборот. Рекомендуемые Lightning-кошельки для вашего телефона:_ [Phoenix](https://phoenix.acinq.co/)_,_ [Breez](https://breez.technology/)_,_ [Muun](https://muun.com/)_ (не требующие обслуживания), или_ [Wallet of Satoshi](https://www.walletofsatoshi.com/) _(простой)_. + +📄 *Open Source* +_Этот бот является бесплатным и_ [с открытым исходным кодом](https://github.com/LightningTipBot/LightningTipBot) _. Вы можете запустить его на своем компьютере и использовать в своем сообществе._ + +✈️ *Telegram* +_Добавьте этого бота в чат вашей группы Telegram для /tip сообщений. Если вы сделаете бота администратором группы, он также будет очищать команды для поддержания чистоты в чате._ + +🏛 *Условия* +_Мы не являемся хранителями ваших средств. Мы будем действовать в ваших интересах, но мы также понимаем, что ситуация без поддержки стандарта KYC - немного сложная, до тех пор, пока мы не придумаем что-нибудь. Любая сумма, которую вы загрузите на свой кошелек, будет считаться пожертвованием. Не отдавайте нам все свои деньги. Имейте в виду, что этот бот находится в стадии бета-разработки. Используйте на свой страх и риск._ + +❤️ *Пожертвования* +_Этот бот не взимает комиссии, но его работа обходится в сатоши. Если вам нравится бот, пожалуйста, поддержите этот проект пожертвованием. Чтобы сделать пожертвование, воспользуйтесь командой_ `/donate 1000`""" + +helpNoUsernameMessage = """👋 Пожалуйста, установите имя пользователя в Telegram.""" + +advancedMessage = """%s + +👉 *Команды* +*send* 💸 Отправить Сатоши в чате: `%s send <количество> [] []` +*receive* 🏅 Запросить оплату: `... receive <количество> [] []` +*faucet* 🚰 Создать криптораздачу: `... faucet <ёмкость> <на_пользователя> []` +*tipjar* 🍯 Создать копилку: `... tipjar <ёмкость> <на_пользователя> []` + +📖 Вы можете использовать команды в любом чате, даже в личных беседах. Подождите секунду после ввода команды и *щелкните* результат, не нажимайте Enter.. + +⚙️ *Продвинутые команды* +*/transactions* 📊 List transactions +*/link* 🔗 Link your wallet to [BlueWallet](https://bluewallet.io/) or [Zeus](https://zeusln.app/) +*/lnurl* Получить или оплатить через ⚡️Lnurl: `/lnurl` or `/lnurl [memo]` +*/nostr* 💜 Connect to Nostr: `/nostr` +*/faucet* 🚰 Создать криптораздачу: `/faucet <ёмкость> <на_пользователя>` +*/tipjar* 🍯 Создать копилку: `/tipjar <ёмкость> <на_пользователя>` +*/group* 🎟 Create group tickets: `/group add []` +*/shop* 🛍 Browse shops: `/shop` or `/shop `""" + +# GENERIC +enterAmountRangeMessage = """💯 Введите количество между %s и %s.""" +enterAmountMessage = """💯 Введите количество.""" +enterUserMessage = """👤 введите пользователя.""" +errorReasonMessage = """🚫 Ошибка: %s""" + +# START + +startSettingWalletMessage = """🧮 Настраиваю кошелёк для вас...""" +startWalletCreatedMessage = """🧮 Кошелёк создан.""" +startWalletReadyMessage = """✅ *Кошелёк готов к использованию.*""" +startWalletErrorMessage = """🚫 Ошибка инициализации кошелька. Попробуйте позже.""" +startNoUsernameMessage = """☝️ Похоже, что у вас еще нет @имени_пользователя в Telegram. Это нормально, оно не обязательно для использования этого бота. Однако, чтобы лучше использовать свой кошелек, установите имя пользователя в настройках Telegram. Затем введите /balance, чтобы бот мог обновить свои данные о вас.""" + +# BALANCE + +balanceMessage = """👑 *Ваш баланс:* %s. (%s USD / %s LKR)""" +balanceErrorMessage = """🚫 Не удалось получить ваш баланс. Пожалуйста, повторите попытку позже.""" + +# TIP + +tipDidYouReplyMessage = """Вы ответили на сообщение чтобы отправить Сатоши? Чтобы ответить на любое сообщение, нажмите правой кнопкой мыши -> Ответить на компьютере или проведите пальцем по сообщению на телефоне. Если вы хотите отправить Сатоши непосредственно другому пользователю, используйте команду /send.""" +tipInviteGroupMessage = """ℹ️ Кстати, вы можете пригласить этого бота в любую группу, чтобы можно было отправлять чаевые там.""" +tipEnterAmountMessage = """Ввели ли вы сумму?""" +tipValidAmountMessage = """Ввели ли вы правильную сумму?""" +tipYourselfMessage = """📖 Вы не можете отправлять самому себе.""" +tipSentMessage = """💸 %s отправлено %s.""" +tipReceivedMessage = """🏅 %s отправил(а) вам %s.""" +tipErrorMessage = """🚫 Отправка не удалась.""" +tipUndefinedErrorMsg = """пожалуйста попробуйте позжа.""" +tipHelpText = """📖 Упс, это не сработало. %s + +*Использование:* `/tip <количество> []` +*Пример:* `/tip 1000 Спасибо!`""" + +# SEND + +sendValidAmountMessage = """Ввели ли вы правильную сумму?""" +sendUserHasNoWalletMessage = """🚫 Пользователь %s не создал кошелёк.""" +sendSentMessage = """💸 %s отправлено %s.""" +sendPublicSentMessage = """💸 %s отправил(а) %s для %s.""" +sendReceivedMessage = """🏅 %s отправил(а) вам %s.""" +sendErrorMessage = """🚫 Неуспешная отправка.""" +confirmSendMessage = """Вы хотите отправить Сатоши %s?\n\n💸 Amount: %s""" +confirmSendAppendMemo = """\n✉️ %s""" +sendCancelledMessage = """🚫 Отправка отменена.""" +errorTryLaterMessage = """🚫 Ошибка. Пожалуйста попробуйте позже.""" +sendSyntaxErrorMessage = """Вы ввели сумму и получателя? Вы можете использовать команду /send для отправки либо пользователям Telegram, например %s, либо на адрес Lightning, например LightningTipBot@ln.tips.""" +sendHelpText = """📖 Упс, это не сработало. %s + +*Использование:* `/send <количество> <пользователь> []` +*Пример:* `/send 1000 @LightningTipBot I just like the bot ❤️` +*Пример:* `/send 1234 LightningTipBot@ln.tips`""" + +# INVOICE + +invoiceReceivedMessage = """⚡️ Вы получили %s.""" +invoiceReceivedCurrencyMessage = """⚡️ Вы получили %s (%s %s).""" +invoiceEnterAmountMessage = """Ввели ли вы сумму?""" +invoiceValidAmountMessage = """Ввели ли вы правильную сумму?""" +invoiceHelpText = """📖 Упс, это не сработало. %s + +*Использование:* `/invoice <количество> []` +*Пример:* `/invoice 1000 Спасибо!`""" + +# PAY + +paymentCancelledMessage = """🚫 Платёж отменён.""" +invoicePaidMessage = """⚡️ Платёж отправлен.""" +invoicePublicPaidMessage = """⚡️ %s отправил(а) платёж.""" +invalidInvoiceHelpMessage = """Вы ввели существующий счет в Lightning? Попробуйте /send, если вы хотите отправить пользователю Telegram или по адресу Lightning.""" +invoiceNoAmountMessage = """🚫 Невозможно оплатить инвойс без указания суммы.""" +insufficientFundsMessage = """🚫 Недостаточно средств. У вас есть %s, но вам нужно не менее %s.""" +feeReserveMessage = """⚠️ Отправка всего вашего баланса может провалиться из-за сетевых сборов. Если это не удается, попробуйте отправить немного меньше.""" +invoicePaymentFailedMessage = """🚫 Платёж не прошёл: %s""" +invoiceUndefinedErrorMessage = """Невозможно оплатить счёт.""" +confirmPayInvoiceMessage = """Хотите ли вы отправить этот платеж?\n\n💸 Amount: %s""" +confirmPayAppendMemo = """\n✉️ %s""" +payHelpText = """📖 Упс, это не сработало. %s + +*Использование:* `/pay <инвойс>` +*Пример:* `/pay lnbc20n1psscehd...`""" + +# DONATE + +donationSuccess = """🙏 Спасибо за ваше пожертвование.""" +donationErrorMessage = """🚫 О нет! Пожертвование не удалось.""" +donationProgressMessage = """🧮 Подготовка вашего пожертвования...""" +donationFailedMessage = """🚫 Пожертвование не удалось: %s""" +donateEnterAmountMessage = """Ввели ли вы сумму?""" +donateValidAmountMessage = """Ввели ли вы правильную сумму?""" +donateHelpText = """📖 Упс, это не сработало. %s + +*Использование:* `/donate <количество>` +*Пример:* `/donate 1000`""" + +# PHOTO + +photoQrNotRecognizedMessage = """🚫 Не удалось распознать счет Lightning или LNURL. Попробуйте отцентрировать QR-код, обрезать фотографию или увеличить масштаб.""" +photoQrRecognizedMessage = """✅ QR код: +`%s`""" + +# LNURL + +lnurlReceiveInfoText = """👇 Вы можете использовать этот статический LNURL для приема платежей.""" +lnurlResolvingUrlMessage = """🧮 Уточнение адреса...""" +lnurlGettingUserMessage = """🧮 Подготовка платежа...""" +lnurlPaymentFailed = """🚫 Платеж не прошел: %s""" +lnurlInvalidAmountMessage = """🚫 Неверная сумма.""" +lnurlInvalidAmountRangeMessage = """🚫 Сумма должна быть от %s до %s.""" +lnurlNoUsernameMessage = """🚫 Вам нужно установить имя пользователя Telegram для получения платежей через LNURL.""" +lnurlHelpText = """📖 Упс, это не сработало. %s + +*Использование:* `/lnurl [количество] ` +*Пример:* `/lnurl LNURL1DP68GUR...`""" + +# LNURL WITHDRAW + +confirmLnurlWithdrawMessage = """Вы хотите вывести средства?\n\n💸 Сумма: %d сб""" +lnurlPreparingWithdraw = """🧮 Подготовка к выводу средств...""" +lnurlWithdrawFailed = """🚫 Вывод средств не удался.""" +lnurlWithdrawCancelled = """🚫 Вывод средств отменен.""" +lnurlWithdrawSuccess = """✅ Вывод средств запрошен.""" + +# LINK + +walletConnectMessage = """🔗 *Подключение кошелька* + +⚠️ Никогда и никому не сообщайте URL или QR-код, иначе они смогут получить доступ к вашим средствам. + +- *BlueWallet:* Нажмите *New wallet*, *Import wallet*, *Scan or import a file*, и отсканируйте QR-код. +- *Zeus:* Скопируйте URL ниже, нажмите *Add a new node*, *Import* (the URL), *Save Node Config*.""" +couldNotLinkMessage = """🚫 Не удалось связать ваш кошелек. Пожалуйста, повторите попытку позже.""" + +# FAUCET + +inlineQueryFaucetTitle = """🚰 Создать криптораздачу.""" +inlineQueryFaucetDescription = """Использование: @%s faucet """ +inlineResultFaucetTitle = """🚰 Создать %s криптораздачу.""" +inlineResultFaucetDescription = """👉 Нажмите здесь, чтобы создать криптораздачу в этом чате.""" + +inlineFaucetMessage = """Нажмите ✅, чтобы забрать %s из этой криптораздачи от %s. + +🚰 Осталось: %d/%s (отдано %d/%d пользователям) +%s""" +inlineFaucetEndedMessage = """🚰 Криптораздача завершена 🍺\n\n🏅 %s отдано %d пользователям.""" +inlineFaucetAppendMemo = """\n✉️ %s""" +inlineFaucetCreateWalletMessage = """Напишите %s 👈 чтобы управлять своим кошельком.""" +inlineFaucetCancelledMessage = """🚫 Криптораздача отменена.""" +inlineFaucetInvalidPeruserAmountMessage = """🚫 Количество пользователей не является делителем емкости.""" +inlineFaucetInvalidAmountMessage = """🚫 Неверное количество.""" +inlineFaucetSentMessage = """🚰 %s отправлено %s.""" +inlineFaucetReceivedMessage = """🚰 %s отправил вам %s.""" +inlineFaucetHelpFaucetInGroup = """Создайте криптораздачу в группе с ботом внутри или используйте 👉 команду (/advanced для справки).""" +inlineFaucetHelpText = """📖 Упс, это не сработало. %s + +*Использование:* `/faucet <ёмкость> <на_пользователя>` +*Пример:* `/faucet 210 21`""" + +# INLINE SEND + +inlineQuerySendTitle = """💸 Отправьте платеж в чат.""" +inlineQuerySendDescription = """Использование: @%s send <количество> [<пользователь>] []""" +inlineResultSendTitle = """💸 Отправить %s.""" +inlineResultSendDescription = """👉 Нажмите, чтобы отправить %d сат в этот чат.""" + +inlineSendMessage = """Нажмите ✅ для получения платежа от %s.\n\n💸 Сумма: %s""" +inlineSendAppendMemo = """\n✉️ %s""" +inlineSendUpdateMessageAccept = """💸 %s отправлено с %s на %s.""" +inlineSendCreateWalletMessage = """Напишите %s 👈 чтобы управлять своим кошельком.""" +sendYourselfMessage = """📖 Вы не можете платить себе.""" +inlineSendFailedMessage = """🚫 Отправка не удалась.""" +inlineSendInvalidAmountMessage = """🚫 Сумма должна быть больше 0.""" +inlineSendBalanceLowMessage = """🚫 Ваш баланс слишком низкий.""" + +# INLINE RECEIVE + +inlineQueryReceiveTitle = """🏅 Запросить платёж в чате.""" +inlineQueryReceiveDescription = """Использование: @%s receive <количество> [<пользователь>] []""" +inlineResultReceiveTitle = """🏅 Получить %s.""" +inlineResultReceiveDescription = """👉 Нажмите, чтобы запросить выплату в размере %s.""" + +inlineReceiveMessage = """Нажмите 💸 чтобы отправить Сатоши %s.\n\n💸 Количество: %s""" +inlineReceiveAppendMemo = """\n✉️ %s""" +inlineReceiveUpdateMessageAccept = """💸 %s отправлено от %s, получатель - %s.""" +inlineReceiveCreateWalletMessage = """Напишите %s 👈 чтобы управлять своим кошельком.""" +inlineReceiveYourselfMessage = """📖 Вы не можете платить себе.""" +inlineReceiveFailedMessage = """🚫 Получение не удалось.""" +inlineReceiveCancelledMessage = """🚫 Получение отменено.""" + +# TIPJAR + +inlineQueryTipjarTitle = """🍯 Создать копилку.""" +inlineQueryTipjarDescription = """Использование: @%s tipjar <ёмкость> <на_пользователя>""" +inlineResultTipjarTitle = """🍯 Создать копилку на %s .""" +inlineResultTipjarDescription = """👉 Нажмите здесь, чтобы создать копилку в этом чате.""" + +inlineTipjarMessage = """Нажмите 💸 чтобы *отправить %s* в эту копилку, созданную %s. + +🙏 Накоплено: *%d*/%s (от %d пользователей) +%s""" +inlineTipjarEndedMessage = """🍯 %s's копилка заполнена ⭐️\n\n🏅 %s отправи(л/ли) %d пользовате(ль/ли).""" +inlineTipjarAppendMemo = """\n✉️ %s""" +inlineTipjarCancelledMessage = """🚫 Копилка отозвана.""" +inlineTipjarInvalidPeruserAmountMessage = """🚫 Количество пользователей не является делителем емкости.""" +inlineTipjarInvalidAmountMessage = """🚫 Отправка не удалась.""" +inlineTipjarSentMessage = """🍯 %s отправлено в пользу %s.""" +inlineTipjarReceivedMessage = """🍯 %s отправил вам %s.""" +inlineTipjarHelpTipjarInGroup = """Создайте копилку в группе с ботом внутри или используйте 👉 команду (/advanced для справки).""" +inlineTipjarHelpText = """📖 Oops, that didn't work. %s + +*Использование:* `/tipjar <ёмкость> <на_пользователя>` +*Пример:* `/tipjar 210 21`""" +# CONVERT LKR TO SAT +convertEnterAmountMessage = """Did you enter an amount?""" +convertInvalidAmountMessage = """Did you enter a valid amount?""" +convertPriceErrorMessage = """🚫 Couldn't fetch price.""" +convertResultMessage = """%.2f LKR is about %s.""" diff --git a/translations/tr.toml b/translations/tr.toml new file mode 100644 index 00000000..a362fd3c --- /dev/null +++ b/translations/tr.toml @@ -0,0 +1,314 @@ +# COMMANDS + +helpCommandStr = """yardım""" +basicsCommandStr = """temelbilgi""" +tipCommandStr = """bahşiş""" +balanceCommandStr = """kredi""" +sendCommandStr = """gönder""" +invoiceCommandStr = """fatura""" +payCommandStr = """öde""" +donateCommandStr = """bağış""" +advancedCommandStr = """gelişmiş""" +transactionsCommandStr = """işlemler""" +logCommandStr = """kayıt""" +listCommandStr = """liste""" + +linkCommandStr = """bağlan""" +lnurlCommandStr = """lnurl""" +faucetCommandStr = """fıçı""" + +tipjarCommandStr = """bağışkutusu""" +receiveCommandStr = """iste""" +hideCommandStr = """sakla""" +volcanoCommandStr = """volkan""" +showCommandStr = """göster""" +optionsCommandStr = """seçenekler""" +settingsCommandStr = """ayarlar""" +saveCommandStr = """kaydet""" +deleteCommandStr = """sil""" +infoCommandStr = """info""" + +# NOTIFICATIONS + +cantDoThatMessage = """Bunu yapamazsın.""" +cantClickMessage = """Bu butona basamazsın.""" +balanceTooLowMessage = """Kredin yetersiz.""" + +# BUTTONS + +sendButtonMessage = """✅ Gönder""" +payButtonMessage = """✅ Öde""" +payReceiveButtonMessage = """💸 Öde""" +receiveButtonMessage = """✅ İste""" +cancelButtonMessage = """🚫 İptal""" +collectButtonMessage = """✅ Topla""" +nextButtonMessage = """İleri""" +backButtonMessage = """Geri""" +acceptButtonMessage = """Onayla""" +denyButtonMessage = """Reddet""" +tipButtonMessage = """Bahşiş""" +revealButtonMessage = """Göster""" +showButtonMessage = """Göster""" +hideButtonMessage = """Sakla""" +joinButtonMessage = """Katıl""" +optionsButtonMessage = """Seçenekler""" +settingsButtonMessage = """Ayarlar""" +saveButtonMessage = """Kaydet""" +deleteButtonMessage = """Sil""" +infoButtonMessage = """Info""" + +# HELP + +helpMessage = """⚡️ *Cüzdan* +_Bitcoin Lightning Cüzdan botu ile Telegram üzerinden bahşiş gönderebilirsin. Bahşiş göndermek için botu bir grup sohbetine ekle. Temel bahşiş birimi Satoshi’dir (sat). 100,000,000 sat = 1 Bitcoin. Daha fazla bilgi için 📚 /basics gir._ + +❤️ *Bağış yap* +_Bu bot ücretsizdir, ancak çalıştırılması için Satoshi gerekir. Botu beğendiysen bağış yaparak projeye destek olabilirsin. Bağış yapmak için şunu gir: _ `/donate 1000` + +%s + +⚙️ *Komutlar* +*/tip* 🏅 Bağış yapmak için mesajı cevapla: `/tip []` +*/balance* 👑 Kredini sorgula: `/balance` +*/send* 💸 Bir kullanıcıya gönder: `/send @user veya user@ln.tips []` +*/invoice* ⚡️ Lightning ile iste: `/invoice []` +*/pay* ⚡️ Lightning ile öde: `/pay ` +*/donate* ❤️ Projeye bağış yap: `/donate 1000` +*/advanced* 🤖 Gelişmiş fonksiyonlar. +*/help* 📖 Yardım.""" + +infoHelpMessage = """ℹ️ *Info*""" +infoYourLightningAddress = """Lightning adresin `%s`""" + +basicsMessage = """🧡 *Bitcoin* +_Bitcoin internetin para birimidir. Herkese açık, merkezi olmayan, kontrol edeni olmayan, klasik finansal sistemden daha hızlı, daha güvenli ve daha adil olan sağlam bir paradır._ + +🧮 *Ekonomi* +_En küçük Bitcoin birimi Satoshi’dir (sat). 100.000.000 sat = 1 Bitcoin. Asla 21 milyondan fazla Bitcoin üretilmeyecek. Bitcoin'in itibari para değeri günlük olarak değişebilir. Ancak Bitcoin Standardı kullanıyorsan, 1 sat sonsuza kadar 1 sat değerinde olacaktır._ + +⚡️ *Lightning Network* +_Lightning Network neredeyse hiç enerji gerektirmeyen hızlı ve ucuz Bitcoin ödemelerine izin veren bir protokolüdür. Böylece Bitcoin dünya çapında milyarlarca insana ulaşır._ + +📲 *Lightning Cüzdanlar* +_Bu bottaki paranı dünyadaki herhangi bir Lightning cüzdanına gönderebilirsin. Cep telefonunun için önerilen Lightning cüzdanları şunlardır:_ [Phoenix](https://phoenix.acinq.co/)_,_ [Breez](https://breez.technology/)_,_ [Muun](https://muun.com/)_ (emanet edilmeyen), veya_ [Wallet of Satoshi](https://www.walletofsatoshi.com/) _(kolay)_. + +📄 *Açık Kaynak* +_Bu bot ücretsiz ve_ [açık kaynak](https://github.com/LightningTipBot/LightningTipBot) _yazılım. Kendi bilgisayarında çalıştırabilir ve topluluğun için kullanabilirsin._ + +✈️ *Telegram* +_/tip göndermek için botu Telegram grubunuza ekle. Bot grubun yöneticisiyse, yürütmeden sonra bazı komutları silerek sohbeti de temizleyecektir._ + +🏛 *Kullanım Şartları* +_Biz senin paranın emanetçisi değiliz. Senin faydanı gözetiyoruz. Ancak KYC'siz durumun biraz karmaşık olduğunun da farkındayız. Başka bir çözüm bulana kadar bu bottaki tüm tutarları bağış olarak kabul ediyoruz. Tüm paranı bize verme. Bu botun hala beta aşamasında olduğunu unutma. Kullanımın senin sorumluluğundadır._ + +❤️ *Bağış yap* +_Bu bot ücretsizdir, ancak çalıştırılması için Satoshi gerekir. Botu beğendiysen bağış yaparak projeye destek olabilirsin. Bağış yapmak için şunu gir:_ `/donate 1000`""" + +helpNoUsernameMessage = """👋 Ayarlarda bir Telegram kullanıcı adı seç.""" + +advancedMessage = """%s + +👉 *Inline komutlar* +*send* 💸 Bir sohbete sat gönder: `%s send [] []` +*receive* 🏅 Ödeme iste: `%s receive [] []` +*faucet* 🚰 Bir fıçı oluştur: `%s faucet []` +*tipjar* 🍯 Bir tipjar oluştur: `... tipjar []` + +📖 İnline komutları her sohbette ve hatta özel mesajlarda kullanabilirsin. Komutu yazdıktan sonra bir saniye bekle ve Enter yazmak yerine sonuca *tıkla*. + +⚙️ *Gelişmiş komutlar* +*/transactions* 📊 List transactions +*/link* 🔗 Cüzdanını bağla: [BlueWallet](https://bluewallet.io/) veya [Zeus](https://zeusln.app/) +*/lnurl* ⚡️ Lnurl iste veya gönder: `/lnurl` veya `/lnurl [memo]` +*/nostr* 💜 Connect to Nostr: `/nostr` +*/faucet* 🚰 Bir fıçı oluştur: `/faucet ` +*/tipjar* 🍯 Bir tipjar oluştur: `/tipjar ` +*/group* 🎟 Create group tickets: `/group add []` +*/shop* 🛍 Browse shops: `/shop` or `/shop `""" + +# GENERIC +enterAmountRangeMessage = """💯 %s ve %s arasında bir miktar gir.""" +enterAmountMessage = """💯 Bir miktar gir.""" +enterUserMessage = """👤 Bir kullanıcı gir.""" +errorReasonMessage = """🚫 Hata: %s""" + +# START + +startSettingWalletMessage = """🧮 Cüzdanın hazırlanıyor…""" +startWalletCreatedMessage = """🧮 Cüzdanın hazır.""" +startWalletReadyMessage = """✅ *Cüzdanın hazır.*""" +startWalletErrorMessage = """🚫 Cüzdanın hazırlanırken bir hata oluştu. Lütfen daha sonra tekrar dene.""" +startNoUsernameMessage = """☝️ Henüz bir Telegram kullanıcı @adın yok gibi görünüyor. Sorun değil, bu botu kullanmak için kullanıcı adına ihtiyacın yok. Ancak tüm fonksiyonları kullanabilmek için Telegram ayarlarında bir kullanıcı adı belirlemelisin. Ardından botun seninle ilgili bilgilerini güncelleyebilmesi için bir kez /balance gir.""" + +# BALANCE + +balanceMessage = """👑 *Kredin:* %s. (%s USD / %s LKR)""" +balanceErrorMessage = """🚫 Şu an kredini okuyamıyorum. Lütfen daha sonra tekrar dene.""" + +# TIP + +tipDidYouReplyMessage = """Birine bahşiş göndermek için bir mesajı yanıtladın mı? Yanıtlamak için bilgisayarında mesaja sağ tıklayarak Yanıtla. Telefonda ise mesajı kaydır. Ödemeyi doğrudan başka bir kullanıcıya göndermek istiyorsan /send komutunu kullan.""" +tipInviteGroupMessage = """ℹ️ Bu arada, bu botu herhangi bir grup sohbetine davet edebilir ve orada bahşiş dağıtabilirsin.""" +tipEnterAmountMessage = """Miktar girdin mi?""" +tipValidAmountMessage = """Geçerli bir miktar girdin mi?""" +tipYourselfMessage = """📖 Kendi kendine bahşiş gönderemezsin.""" +tipSentMessage = """💸 %s %s a gönderildi.""" +tipReceivedMessage = """🏅 %s sana %s bahşiş gönderdi.""" +tipErrorMessage = """🚫 Bahşiş gönderilemedi.""" +tipUndefinedErrorMsg = """lütfen daha sonra tekrar dene.""" +tipHelpText = """📖 Hoppalaa… olmadı. %s + +*Komut:* `/tip []` +*Örnek:* `/tip 1000 Çok iyi yaa!`""" + +# SEND + +sendValidAmountMessage = """Geçerli bir miktar girdin mi?""" +sendUserHasNoWalletMessage = """🚫 %s henüz bir cüzdan oluşturmadı.""" +sendSentMessage = """💸 %s %s a gönderildi.""" +sendPublicSentMessage = """💸 %s %s dan %s a gönderildi.""" +sendReceivedMessage = """🏅 %s sana %s gönderdi.""" +sendErrorMessage = """🚫 Gönderim başarılı olmadı.""" +confirmSendMessage = """%s a ödeme göndermek istiyor musun?\n\n💸 Miktar: %s""" +confirmSendAppendMemo = """\n✉️ %s""" +sendCancelledMessage = """🚫 Gönderim iptal edildi.""" +errorTryLaterMessage = """🚫 Hata. Lütfen daha sonra tekrar dene.""" +sendSyntaxErrorMessage = """Geçerli bir miktar ve kullanıcı girdin mi? /send komutu ile bir Telegram kullanıcısına, örnek %s, veya bir Lightning adrese, örnek LightningTipBot@ln.tips, gönderilebilir.""" +sendHelpText = """📖 Hoppalaa… olmadı. %s + +*Komut:* `/send []` +*Örnek:* `/send 1000 @LightningTipBot Bot harika kanka! ❤️` +*Örnek:* `/send 1234 LightningTipBot@ln.tips`""" + +# INVOICE + +invoiceReceivedMessage = """⚡️ %s sana geldi.""" +invoiceReceivedCurrencyMessage = """⚡️ %s (%s %s) sana geldi.""" +invoiceEnterAmountMessage = """Miktar girdin mi?""" +invoiceValidAmountMessage = """Geçerli bir miktar girdin mi?""" +invoiceHelpText = """📖 Hoppalaa… olmadı. %s + +*Komut:* `/invoice []` +*Örnek:* `/invoice 1000 Şimdiden teşekkürler!`""" + +# PAY + +paymentCancelledMessage = """🚫 Ödeme iptal edildi.""" +invoicePaidMessage = """⚡️ Ödeme gönderildi.""" +invoicePublicPaidMessage = """⚡️ %s ödeme gönderdi.""" +invalidInvoiceHelpMessage = """Geçerli bir Lightning Invoice girdin mi? Telegram kullanıcısına veya Lightning adrese ödeme yapmak için /send komutunu kullan.""" +invoiceNoAmountMessage = """🚫 Miktar belirtilmemiş Invoice ödenemez.""" +insufficientFundsMessage = """🚫 Kredin yetersiz. Şu an cüzdanında %s var. Ancak en az %s gerekiyor.""" +feeReserveMessage = """⚠️ Cüzdanındaki tüm miktarı göndermek istiyorsun. Ancak Lightning ücreti ödenemeyeceği için gönderim başarısız olabilir. Bu durumda daha az bir miktar dene.""" +invoicePaymentFailedMessage = """🚫 Ödeme başarısız: %s""" +invoiceUndefinedErrorMessage = """Invoice ödenemedi.""" +confirmPayInvoiceMessage = """Bu ödemeyi göndermek istiyor musun?\n\n💸 Miktar: %s""" +confirmPayAppendMemo = """\n✉️ %s""" +payHelpText = """📖 Hoppalaa… olmadı. %s + +*Komut:* `/pay ` +*Örnek:* `/pay lnbc20n1psscehd...`""" + +# DONATE + +donationSuccess = """🙏 Bağış için teşekkürler.""" +donationErrorMessage = """🚫 Bağış yapılamadı.""" +donationProgressMessage = """🧮 Bağışın hazırlanıyor…""" +donationFailedMessage = """🚫 Bağış yapılamadı: %s""" +donateEnterAmountMessage = """Miktar girdin mi?""" +donateValidAmountMessage = """Geçerli bir miktar girdin mi?""" +donateHelpText = """📖 Hoppalaa… olmadı. %s + +*Komut:* `/donate ` +*Örnek:* `/donate 1000`""" + +# PHOTO + +photoQrNotRecognizedMessage = """🚫 Lightning Invoice veya LNURL tanımlanamadı. QR Kodunu ortalayarak, boyutlandırarak veya büyüterek tekrar dene.""" +photoQrRecognizedMessage = """✅ QR Code: +`%s`""" + +# LNURL + +lnurlReceiveInfoText = """👇 Bu LNURL kodunu ödeme almak için kullanabilirsin.""" +lnurlResolvingUrlMessage = """🧮 Adres çözümleniyor…""" +lnurlGettingUserMessage = """🧮 Ödeme hazırlanıyor...""" +lnurlPaymentFailed = """🚫 Ödeme başarısız: %s""" +lnurlInvalidAmountMessage = """🚫 Geçersiz miktar.""" +lnurlInvalidAmountRangeMessage = """🚫 Miktar %s ve %s arasında olmalı.""" +lnurlNoUsernameMessage = """🚫 LNURL ödemesi almak için bir Telegram kullanıcı ismi seçmelisin.""" +lnurlHelpText = """📖 Hoppalaa… olmadı. %s + +*Komut:* `/lnurl [miktar] ` +*Örnek:* `/lnurl LNURL1DP68GUR...`""" + +# LINK + +walletConnectMessage = """🔗 *Cüzdanını bağla* + +⚠️ Bu URL yi veya QR kodunu kimseyle paylaşma. Bu bilgiye ulaşan biri hesabına da ulaşabilir. + +- *BlueWallet:* *New wallet*, *Import wallet*, *Scan or import a file* tıkla ve QR kodunu tarat. +- *Zeus:* Aşağıdaki URL’yi kopyala ve *Add a new node*, *Import* (URL’yi), *Save Node Config* tıkla.""" +couldNotLinkMessage = """🚫 Cüzdanın bağlanamadı. Lütfen daha sonra tekrar dene.""" + +# FAUCET + +inlineQueryFaucetTitle = """🚰 Bir Fıçı oluştur.""" +inlineQueryFaucetDescription = """Komut: @%s faucet """ +inlineResultFaucetTitle = """🚰 Bir %s Fıçı oluştur.""" +inlineResultFaucetDescription = """👉 Fıçı’yı bu sohbete göndermek için tıkla.""" + +inlineFaucetMessage = """✅ butonuna basarak %s'in Fıçıdan %s çek. + +🚰 Kalan: %d/%s (%d/%d çekildi) +%s""" +inlineFaucetEndedMessage = """🚰 Fıçı boşaldı 🍺\n\n🏅 %s %d kullanıcıya dağıtıldı.""" +inlineFaucetAppendMemo = """\n✉️ %s""" +inlineFaucetCreateWalletMessage = """%s 👈 ile cüzdanı kullanmak için sohbete başla.""" +inlineFaucetCancelledMessage = """🚫 Fıçı iptal edildi.""" +inlineFaucetInvalidPeruserAmountMessage = """🚫 Toplam miktar bölü kullanıcı başına miktar tam sayı olmalı. Bu sayı küsuratlı olmaz.""" +inlineFaucetInvalidAmountMessage = """🚫 Geçersiz miktar.""" +inlineFaucetSentMessage = """🚰 %s %s a gönderildi.""" +inlineFaucetReceivedMessage = """🚰 %s sana %s gönderdi.""" +inlineFaucetHelpFaucetInGroup = """Fıçıyı İçinde bot olan bir grupta oluştur veya 👉 Inline komutunu kullan (/advanced daha fazla bilgi).""" +inlineFaucetHelpText = """📖 Hoppalaa… olmadı. %s + +*Komut:* `/faucet ` +*Örnek:* `/faucet 210 21`""" + +# INLINE SEND + +inlineQuerySendTitle = """💸 Sohbete ödeme gönder.""" +inlineQuerySendDescription = """Komut: @%s send [] []""" +inlineResultSendTitle = """💸 %s gönder.""" +inlineResultSendDescription = """👉 Sohbete %s göndermek için tıkla.""" + +inlineSendMessage = """✅ Butona basarak %s dan ödeme al.\n\n💸 Miktar: %s""" +inlineSendAppendMemo = """\n✉️ %s""" +inlineSendUpdateMessageAccept = """💸 %s %s dan %s ye gönderildi.""" +inlineSendCreateWalletMessage = """%s 👈 ile cüzdanı kullanmak için sohbete başla.""" +sendYourselfMessage = """📖 Kendi kendine ödeme yapamazsın.""" +inlineSendFailedMessage = """🚫 Gönderim başarısız.""" +inlineSendInvalidAmountMessage = """🚫 Miktar 0 dan büyük olmalı.""" +inlineSendBalanceLowMessage = """🚫 Kredin yetersiz.""" + +# INLINE RECEIVE + +inlineQueryReceiveTitle = """🏅 Sohbetten ödeme iste.""" +inlineQueryReceiveDescription = """Komut: @%s receive [] []""" +inlineResultReceiveTitle = """🏅 %s iste.""" +inlineResultReceiveDescription = """👉%s istemek için tıkla.""" + +inlineReceiveMessage = """💸 butonuna basarak %s a öde.\n\n💸 Miktar: %s""" +inlineReceiveAppendMemo = """\n✉️ %s""" +inlineReceiveUpdateMessageAccept = """💸 %s %s dan %s ye gönderildi.""" +inlineReceiveCreateWalletMessage = """%s 👈 ile cüzdanı kullanmak için sohbete başla.""" +inlineReceiveYourselfMessage = """📖 Kendi kendine ödeme yapamazsın.""" +inlineReceiveFailedMessage = """🚫 İstek başarısız.""" +inlineReceiveCancelledMessage = """🚫 İstek iptal edildi.""" +# CONVERT LKR TO SAT +convertEnterAmountMessage = """Bir miktar girdiniz mi?""" +convertInvalidAmountMessage = """Geçerli bir miktar girdiniz mi?""" +convertPriceErrorMessage = """🚫 Fiyat alınamadı.""" +convertResultMessage = """%.2f LKR yaklaşık %s.""" diff --git a/users.go b/users.go deleted file mode 100644 index d3b02b3e..00000000 --- a/users.go +++ /dev/null @@ -1,153 +0,0 @@ -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" -) - -func SetUserState(user *lnbits.User, bot TipBot, stateKey lnbits.UserStateKey, stateData string) { - user.StateKey = stateKey - user.StateData = stateData - err := UpdateUserRecord(user, bot) - if err != nil { - log.Errorln(err.Error()) - return - } -} - -func ResetUserState(user *lnbits.User, bot TipBot) { - user.ResetState() - err := UpdateUserRecord(user, bot) - if err != nil { - log.Errorln(err.Error()) - return - } -} - -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 -}