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 c965465a..246754b2 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,11 @@ data/* LightningTipBot LightningTipBot.exe BitcoinDeepaBot -test_pay_api.sh \ No newline at end of file +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/config.yaml.example b/config.yaml.example index ff8cf6a4..dcbb2d50 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -36,8 +36,23 @@ generate: nostr: private_key: "hex private key here" -# API Send Configuration +# 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" 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/lightning.go b/internal/api/lightning.go index 105c3d1a..bbdcb1d7 100644 --- a/internal/api/lightning.go +++ b/internal/api/lightning.go @@ -9,12 +9,14 @@ import ( "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 + Bot *telegram.TipBot + MemoCache *utils.Cache } type ErrorResponse struct { diff --git a/internal/api/middleware.go b/internal/api/middleware.go index 34b9fa4c..c4c87299 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -226,3 +226,72 @@ 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/send.go b/internal/api/send.go index 10b5b76f..0c3c353f 100644 --- a/internal/api/send.go +++ b/internal/api/send.go @@ -118,7 +118,7 @@ func (s Service) Send(w http.ResponseWriter, r *http.Request) { RespondError(w, "Authentication failed") return } - + walletID := authenticatedWallet.(string) wallet, exists := GetWhitelistedWallets()[walletID] if !exists { @@ -126,7 +126,7 @@ func (s Service) Send(w http.ResponseWriter, r *http.Request) { RespondError(w, "Invalid wallet configuration") return } - + fromUsername := wallet.Username // Validate request @@ -150,6 +150,38 @@ func (s Service) Send(w http.ResponseWriter, r *http.Request) { 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, "@") diff --git a/internal/config.go b/internal/config.go index 9fdb7368..2f8bcddd 100644 --- a/internal/config.go +++ b/internal/config.go @@ -74,7 +74,19 @@ type LnbitsConfiguration struct { } type APIConfiguration struct { - Send APISendConfiguration `yaml:"send"` + 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 { @@ -128,6 +140,7 @@ func init() { Configuration.Bot.LNURLHostUrl = hostname checkLnbitsConfiguration() setAPISendDefaults() + setAPIAnalyticsDefaults() } // GetWebhookURL returns the appropriate webhook URL @@ -214,3 +227,38 @@ func setAPISendDefaults() { 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/lnbits/lnbits.go b/internal/lnbits/lnbits.go index 1419af74..63763950 100644 --- a/internal/lnbits/lnbits.go +++ b/internal/lnbits/lnbits.go @@ -7,6 +7,20 @@ import ( "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{ @@ -31,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 } @@ -54,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) @@ -75,9 +85,7 @@ 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) @@ -98,9 +106,7 @@ func (w Wallet) Invoice(params InvoiceParams, c *Client) (lntx Invoice, err erro } if resp.Response().StatusCode >= 300 { - var reqErr Error - resp.ToJSON(&reqErr) - err = reqErr + err = parseLNbitsError(resp) return } @@ -122,9 +128,7 @@ func (c Client) Info(w Wallet) (wtx Wallet, err error) { } if resp.Response().StatusCode >= 300 { - var reqErr Error - resp.ToJSON(&reqErr) - err = reqErr + err = parseLNbitsError(resp) return } @@ -132,23 +136,27 @@ func (c Client) Info(w Wallet) (wtx Wallet, err error) { return } -// Payments returns wallet payments +// 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, } - resp, err := req.Get(c.url+"/api/v1/payments?limit=60", invoiceHeader, nil) + 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 { - var reqErr Error - resp.ToJSON(&reqErr) - err = reqErr + err = parseLNbitsError(resp) return } @@ -170,9 +178,7 @@ func (c Client) Payment(w Wallet, payment_hash string) (payment LNbitsPayment, e } if resp.Response().StatusCode >= 300 { - var reqErr Error - resp.ToJSON(&reqErr) - err = reqErr + err = parseLNbitsError(resp) return } @@ -188,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 } @@ -214,9 +218,7 @@ func (w Wallet) Pay(params PaymentParams, c *Client) (wtx Invoice, err error) { } 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 ae14ab60..348a0bd0 100644 --- a/internal/lnbits/types.go +++ b/internal/lnbits/types.go @@ -111,11 +111,26 @@ type TransferParams struct { } type Error struct { - Detail string `json:"detail"` + Detail string `json:"detail"` + Message string `json:"message"` + StatusCode int `json:"-"` + RawBody string `json:"-"` } func (err Error) Error() string { - return err.Detail + 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 { @@ -193,3 +208,16 @@ type SavingsPot struct { 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/telegram/bot.go b/internal/telegram/bot.go index 447ca68a..e9c3eabe 100644 --- a/internal/telegram/bot.go +++ b/internal/telegram/bot.go @@ -1,6 +1,7 @@ package telegram import ( + "context" "fmt" "os" "os/signal" @@ -126,6 +127,11 @@ func (bot *TipBot) Start() { // 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() diff --git a/internal/telegram/database.go b/internal/telegram/database.go index bc7f62a5..6dd7791b 100644 --- a/internal/telegram/database.go +++ b/internal/telegram/database.go @@ -121,6 +121,10 @@ func AutoMigration() *Databases { 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 { diff --git a/internal/telegram/error_logger.go b/internal/telegram/error_logger.go index ad3fb56a..b2616b74 100644 --- a/internal/telegram/error_logger.go +++ b/internal/telegram/error_logger.go @@ -52,8 +52,11 @@ func (el *ErrorLogger) LogError(err error, context string, userInfo ...interface return } - // Filter out annoying/irrelevant error messages + // 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 } diff --git a/internal/telegram/handler.go b/internal/telegram/handler.go index d8f0c98c..c2c08738 100644 --- a/internal/telegram/handler.go +++ b/internal/telegram/handler.go @@ -372,6 +372,22 @@ func (bot TipBot) getHandler() []InterceptionWrapper { }, }, }, + { + 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, 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/utils/cache.go b/internal/utils/cache.go index 8fe13674..f90db015 100644 --- a/internal/utils/cache.go +++ b/internal/utils/cache.go @@ -43,3 +43,26 @@ func (c *Cache) Get(key string) (string, bool) { } 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/main.go b/main.go index b78b798a..61dc2814 100644 --- a/main.go +++ b/main.go @@ -4,6 +4,7 @@ import ( "net/http" "runtime/debug" "strings" + "time" "github.com/LightningTipBot/LightningTipBot/internal" "github.com/LightningTipBot/LightningTipBot/internal/api" @@ -21,6 +22,7 @@ import ( "github.com/LightningTipBot/LightningTipBot/internal/lnbits/webhook" "github.com/LightningTipBot/LightningTipBot/internal/price" + "github.com/LightningTipBot/LightningTipBot/internal/utils" log "github.com/sirupsen/logrus" ) @@ -48,18 +50,29 @@ func main() { 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 err != nil && strings.Contains(err.Error(), "[requirePrivateChatInterceptor]") { - return // Skip logging this specific error + if strings.Contains(errMsg, "[requirePrivateChatInterceptor]") { + return } // Log errors to Telegram group if bot.ErrorLogger != nil { userInfo := []interface{}{} - if ctx.Sender() != nil { + if ctx != nil && ctx.Sender() != nil { userInfo = append(userInfo, ctx.Sender()) } - if ctx.Chat() != nil { + if ctx != nil && ctx.Chat() != nil { userInfo = append(userInfo, ctx.Chat()) } bot.ErrorLogger.LogError(err, "Telegram Bot Error", userInfo...) @@ -70,6 +83,11 @@ func startApiServer(bot *telegram.TipBot) { // 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) @@ -89,7 +107,10 @@ func startApiServer(bot *telegram.TipBot) { s.AppendAuthorizedRoute(`/lndhub/ext`, api.AuthTypeBearerBase64, api.AccessKeyTypeAdmin, bot.DB.Users, hub.Handle) // starting api service - apiService := api.Service{Bot: bot} + 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) @@ -97,11 +118,20 @@ func startApiServer(bot *telegram.TipBot) { 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") diff --git a/translations/en.toml b/translations/en.toml index 109f74c1..57cd60cb 100644 --- a/translations/en.toml +++ b/translations/en.toml @@ -85,6 +85,7 @@ _This bot is a Bitcoin Lightning wallet that can sends tips on Telegram. To tip, */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)""" @@ -460,3 +461,25 @@ deletePotHelpText = """๐Ÿ“– Delete an empty savings pot. โš ๏ธ _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_"""