package notificationimp import ( "context" "errors" "regexp" "strconv" "strings" "sync" "time" "github.com/tech/sendico/notification/internal/server/notificationimp/telegram" msg "github.com/tech/sendico/pkg/messaging" confirmations "github.com/tech/sendico/pkg/messaging/notifications/confirmations" "github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/model" "github.com/tech/sendico/pkg/mservice" paymenttypes "github.com/tech/sendico/pkg/payments/types" "go.uber.org/zap" ) const ( defaultConfirmationTimeout = 120 * time.Second ) type confirmationManager struct { logger mlogger.Logger tg telegram.Client sender string outbox msg.Producer mu sync.Mutex pendingByMessage map[string]*confirmationState pendingByRequest map[string]*confirmationState } type confirmationState struct { request model.ConfirmationRequest requestMessageID string targetChatID string callbackSubject string clarified bool timer *time.Timer } func newConfirmationManager(logger mlogger.Logger, tg telegram.Client, outbox msg.Producer) *confirmationManager { if logger != nil { logger = logger.Named("confirmations") } return &confirmationManager{ logger: logger, tg: tg, outbox: outbox, sender: string(mservice.Notifications), pendingByMessage: map[string]*confirmationState{}, pendingByRequest: map[string]*confirmationState{}, } } func (m *confirmationManager) Stop() { if m == nil { return } m.mu.Lock() defer m.mu.Unlock() for _, state := range m.pendingByMessage { if state.timer != nil { state.timer.Stop() } } m.pendingByMessage = map[string]*confirmationState{} m.pendingByRequest = map[string]*confirmationState{} } func (m *confirmationManager) HandleRequest(ctx context.Context, request *model.ConfirmationRequest) error { if m == nil { return errors.New("confirmation manager is nil") } if request == nil { return merrors.InvalidArgument("confirmation request is nil", "request") } if m.tg == nil { return merrors.InvalidArgument("telegram client is not configured", "telegram") } req := normalizeConfirmationRequest(*request) if req.RequestID == "" { return merrors.InvalidArgument("confirmation request_id is required", "request_id") } if req.TargetChatID == "" { return merrors.InvalidArgument("confirmation target_chat_id is required", "target_chat_id") } if req.RequestedMoney == nil || strings.TrimSpace(req.RequestedMoney.Amount) == "" || strings.TrimSpace(req.RequestedMoney.Currency) == "" { return merrors.InvalidArgument("confirmation requested_money is required", "requested_money") } if req.SourceService == "" { return merrors.InvalidArgument("confirmation source_service is required", "source_service") } m.mu.Lock() if _, ok := m.pendingByRequest[req.RequestID]; ok { m.mu.Unlock() m.logger.Info("Confirmation request already pending", zap.String("request_id", req.RequestID)) return nil } m.mu.Unlock() message := confirmationPrompt(&req) sent, err := m.tg.SendText(ctx, req.TargetChatID, message, "") if err != nil { m.logger.Warn("Failed to send confirmation request to Telegram", zap.Error(err), zap.String("request_id", req.RequestID)) return err } if sent == nil || strings.TrimSpace(sent.MessageID) == "" { return merrors.Internal("telegram confirmation message id is missing") } state := &confirmationState{ request: req, requestMessageID: strings.TrimSpace(sent.MessageID), targetChatID: strings.TrimSpace(req.TargetChatID), callbackSubject: confirmationCallbackSubject(req.SourceService, req.Rail), } timeout := time.Duration(req.TimeoutSeconds) * time.Second if timeout <= 0 { timeout = defaultConfirmationTimeout } state.timer = time.AfterFunc(timeout, func() { m.handleTimeout(state.requestMessageID) }) m.mu.Lock() m.pendingByMessage[state.requestMessageID] = state m.pendingByRequest[req.RequestID] = state m.mu.Unlock() m.logger.Info("Confirmation request sent", zap.String("request_id", req.RequestID), zap.String("message_id", state.requestMessageID), zap.String("callback_subject", state.callbackSubject)) return nil } func (m *confirmationManager) HandleUpdate(ctx context.Context, update *telegram.Update) { if m == nil || update == nil || update.Message == nil { return } message := update.Message if message.ReplyToMessage == nil { return } replyToID := strconv.FormatInt(message.ReplyToMessage.MessageID, 10) state := m.lookupByMessageID(replyToID) if state == nil { return } chatID := strconv.FormatInt(message.Chat.ID, 10) if chatID != state.targetChatID { m.logger.Debug("Telegram reply ignored: chat mismatch", zap.String("expected_chat_id", state.targetChatID), zap.String("chat_id", chatID)) return } rawReply := message.ToModel() if !state.isUserAllowed(message.From) { m.publishResult(state, &model.ConfirmationResult{ RequestID: state.request.RequestID, Status: model.ConfirmationStatusRejected, ParseError: "unauthorized_user", RawReply: rawReply, }) m.sendNotice(ctx, state, rawReply, "Only approved users can confirm this payment.") m.removeState(state.requestMessageID) return } money, reason, err := parseConfirmationReply(message.Text) if err != nil { m.mu.Lock() state.clarified = true m.mu.Unlock() m.sendNotice(ctx, state, rawReply, clarificationMessage(reason)) return } m.mu.Lock() clarified := state.clarified m.mu.Unlock() status := model.ConfirmationStatusConfirmed if clarified { status = model.ConfirmationStatusClarified } m.publishResult(state, &model.ConfirmationResult{ RequestID: state.request.RequestID, Money: money, RawReply: rawReply, Status: status, }) m.removeState(state.requestMessageID) } func (m *confirmationManager) lookupByMessageID(messageID string) *confirmationState { m.mu.Lock() defer m.mu.Unlock() return m.pendingByMessage[strings.TrimSpace(messageID)] } func (m *confirmationManager) handleTimeout(messageID string) { state := m.lookupByMessageID(messageID) if state == nil { return } m.publishResult(state, &model.ConfirmationResult{ RequestID: state.request.RequestID, Status: model.ConfirmationStatusTimeout, }) m.removeState(messageID) } func (m *confirmationManager) removeState(messageID string) { messageID = strings.TrimSpace(messageID) if messageID == "" { return } m.mu.Lock() state := m.pendingByMessage[messageID] if state != nil && state.timer != nil { state.timer.Stop() } delete(m.pendingByMessage, messageID) if state != nil { delete(m.pendingByRequest, state.request.RequestID) } m.mu.Unlock() } func (m *confirmationManager) publishResult(state *confirmationState, result *model.ConfirmationResult) { if m == nil || state == nil || result == nil { return } if m.outbox == nil { m.logger.Warn("Confirmation result skipped: producer not configured", zap.String("request_id", state.request.RequestID)) return } env := confirmations.ConfirmationResult(m.sender, result, state.request.SourceService, state.request.Rail) if err := m.outbox.SendMessage(env); err != nil { m.logger.Warn("Failed to publish confirmation result", zap.Error(err), zap.String("request_id", state.request.RequestID)) return } m.logger.Info("Confirmation result published", zap.String("request_id", state.request.RequestID), zap.String("status", string(result.Status))) } func (m *confirmationManager) sendNotice(ctx context.Context, state *confirmationState, reply *model.TelegramMessage, text string) { if m == nil || m.tg == nil || state == nil { return } replyID := "" if reply != nil { replyID = reply.MessageID } if _, err := m.tg.SendText(ctx, state.targetChatID, text, replyID); err != nil { m.logger.Warn("Failed to send clarification notice", zap.Error(err), zap.String("request_id", state.request.RequestID)) } } func (s *confirmationState) isUserAllowed(user *telegram.User) bool { if s == nil { return false } allowed := s.request.AcceptedUserIDs if len(allowed) == 0 { return true } if user == nil { return false } userID := strconv.FormatInt(user.ID, 10) for _, id := range allowed { if id == userID { return true } } return false } func confirmationCallbackSubject(sourceService, rail string) string { sourceService = strings.ToLower(strings.TrimSpace(sourceService)) if sourceService == "" { sourceService = "unknown" } rail = strings.ToLower(strings.TrimSpace(rail)) if rail == "" { rail = "default" } return "confirmations." + sourceService + "." + rail } func normalizeConfirmationRequest(request model.ConfirmationRequest) model.ConfirmationRequest { request.RequestID = strings.TrimSpace(request.RequestID) request.TargetChatID = strings.TrimSpace(request.TargetChatID) request.PaymentIntentID = strings.TrimSpace(request.PaymentIntentID) request.QuoteRef = strings.TrimSpace(request.QuoteRef) request.SourceService = strings.TrimSpace(request.SourceService) request.Rail = strings.TrimSpace(request.Rail) request.AcceptedUserIDs = normalizeStringList(request.AcceptedUserIDs) if request.RequestedMoney != nil { request.RequestedMoney.Amount = strings.TrimSpace(request.RequestedMoney.Amount) request.RequestedMoney.Currency = strings.TrimSpace(request.RequestedMoney.Currency) } return request } func normalizeStringList(values []string) []string { if len(values) == 0 { return nil } result := make([]string, 0, len(values)) seen := map[string]struct{}{} for _, value := range values { value = strings.TrimSpace(value) if value == "" { continue } if _, ok := seen[value]; ok { continue } seen[value] = struct{}{} result = append(result, value) } if len(result) == 0 { return nil } return result } var amountPattern = regexp.MustCompile(`^[0-9]+(\.[0-9]+)?$`) var currencyPattern = regexp.MustCompile(`^[A-Za-z]{3,10}$`) func parseConfirmationReply(text string) (*paymenttypes.Money, string, error) { text = strings.TrimSpace(text) if text == "" { return nil, "empty", errors.New("empty reply") } parts := strings.Fields(text) if len(parts) < 2 { if len(parts) == 1 && amountPattern.MatchString(parts[0]) { return nil, "missing_currency", errors.New("currency is required") } return nil, "missing_amount", errors.New("amount is required") } if len(parts) > 2 { return nil, "format", errors.New("reply format is invalid") } amount := parts[0] currency := parts[1] if !amountPattern.MatchString(amount) { return nil, "invalid_amount", errors.New("amount format is invalid") } if !currencyPattern.MatchString(currency) { return nil, "invalid_currency", errors.New("currency format is invalid") } return &paymenttypes.Money{ Amount: amount, Currency: strings.ToUpper(currency), }, "", nil } func confirmationPrompt(req *model.ConfirmationRequest) string { var builder strings.Builder builder.WriteString("Payment confirmation required\n") if req.PaymentIntentID != "" { builder.WriteString("Payment intent: ") builder.WriteString(req.PaymentIntentID) builder.WriteString("\n") } if req.QuoteRef != "" { builder.WriteString("Quote ref: ") builder.WriteString(req.QuoteRef) builder.WriteString("\n") } if req.RequestedMoney != nil { builder.WriteString("Requested: ") builder.WriteString(req.RequestedMoney.Amount) builder.WriteString(" ") builder.WriteString(req.RequestedMoney.Currency) builder.WriteString("\n") } builder.WriteString("Reply with \" \" (e.g., 12.34 USD).") return builder.String() } func clarificationMessage(reason string) string { switch reason { case "missing_currency": return "Currency code is required. Reply with \" \" (e.g., 12.34 USD)." case "missing_amount": return "Amount is required. Reply with \" \" (e.g., 12.34 USD)." case "invalid_amount": return "Amount must be a decimal number. Reply with \" \" (e.g., 12.34 USD)." case "invalid_currency": return "Currency must be a code like USD or EUR. Reply with \" \"." default: return "Reply with \" \" (e.g., 12.34 USD)." } }