404 lines
12 KiB
Go
404 lines
12 KiB
Go
package notificationimp
|
|
|
|
import (
|
|
"context"
|
|
"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 merrors.Internal("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", merrors.InvalidArgument("empty reply")
|
|
}
|
|
parts := strings.Fields(text)
|
|
if len(parts) < 2 {
|
|
if len(parts) == 1 && amountPattern.MatchString(parts[0]) {
|
|
return nil, "missing_currency", merrors.InvalidArgument("currency is required")
|
|
}
|
|
return nil, "missing_amount", merrors.InvalidArgument("amount is required")
|
|
}
|
|
if len(parts) > 2 {
|
|
return nil, "format", merrors.InvalidArgument("reply format is invalid")
|
|
}
|
|
amount := parts[0]
|
|
currency := parts[1]
|
|
if !amountPattern.MatchString(amount) {
|
|
return nil, "invalid_amount", merrors.InvalidArgument("amount format is invalid")
|
|
}
|
|
if !currencyPattern.MatchString(currency) {
|
|
return nil, "invalid_currency", merrors.InvalidArgument("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 \"<amount> <currency>\" (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 \"<amount> <currency>\" (e.g., 12.34 USD)."
|
|
case "missing_amount":
|
|
return "Amount is required. Reply with \"<amount> <currency>\" (e.g., 12.34 USD)."
|
|
case "invalid_amount":
|
|
return "Amount must be a decimal number. Reply with \"<amount> <currency>\" (e.g., 12.34 USD)."
|
|
case "invalid_currency":
|
|
return "Currency must be a code like USD or EUR. Reply with \"<amount> <currency>\"."
|
|
default:
|
|
return "Reply with \"<amount> <currency>\" (e.g., 12.34 USD)."
|
|
}
|
|
}
|