TG settlement service
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/tech/sendico/notification/interface/api/localizer"
|
||||
"github.com/tech/sendico/pkg/db"
|
||||
"github.com/tech/sendico/pkg/domainprovider"
|
||||
@@ -16,6 +17,7 @@ type API interface {
|
||||
Register() messaging.Register
|
||||
Localizer() localizer.Localizer
|
||||
DomainProvider() domainprovider.DomainProvider
|
||||
Router() *chi.Mux
|
||||
}
|
||||
|
||||
type MicroServiceFactoryT = func(API) (mservice.MicroService, error)
|
||||
|
||||
@@ -27,6 +27,7 @@ type APIImp struct {
|
||||
services Microservices
|
||||
debug bool
|
||||
mw *Middleware
|
||||
router *chi.Mux
|
||||
}
|
||||
|
||||
func (a *APIImp) installMicroservice(srv mservice.MicroService) {
|
||||
@@ -69,6 +70,10 @@ func (a *APIImp) Register() messaging.Register {
|
||||
return a.mw
|
||||
}
|
||||
|
||||
func (a *APIImp) Router() *chi.Mux {
|
||||
return a.router
|
||||
}
|
||||
|
||||
func (a *APIImp) installServices() error {
|
||||
srvf := make([]api.MicroServiceFactoryT, 0)
|
||||
|
||||
@@ -117,6 +122,7 @@ func CreateAPI(logger mlogger.Logger, config *api.Config, l localizer.Localizer,
|
||||
p.config = config
|
||||
p.db = db
|
||||
p.localizer = l
|
||||
p.router = router
|
||||
|
||||
var err error
|
||||
if p.domain, err = domainprovider.CreateDomainProvider(p.logger, config.Mw.DomainEnv, config.Mw.APIProtocolEnv, config.Mw.EndPointEnv); err != nil {
|
||||
|
||||
404
api/notification/internal/server/notificationimp/confirmation.go
Normal file
404
api/notification/internal/server/notificationimp/confirmation.go
Normal file
@@ -0,0 +1,404 @@
|
||||
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 \"<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)."
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/tech/sendico/pkg/domainprovider"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
na "github.com/tech/sendico/pkg/messaging/notifications/account"
|
||||
confirmations "github.com/tech/sendico/pkg/messaging/notifications/confirmations"
|
||||
cnotifications "github.com/tech/sendico/pkg/messaging/notifications/confirmation"
|
||||
ni "github.com/tech/sendico/pkg/messaging/notifications/invitation"
|
||||
snotifications "github.com/tech/sendico/pkg/messaging/notifications/site"
|
||||
@@ -26,6 +27,7 @@ type NotificationAPI struct {
|
||||
dp domainprovider.DomainProvider
|
||||
tg telegram.Client
|
||||
announcer *discovery.Announcer
|
||||
confirm *confirmationManager
|
||||
}
|
||||
|
||||
func (a *NotificationAPI) Name() mservice.Type {
|
||||
@@ -36,6 +38,9 @@ func (a *NotificationAPI) Finish(_ context.Context) error {
|
||||
if a.announcer != nil {
|
||||
a.announcer.Stop()
|
||||
}
|
||||
if a.confirm != nil {
|
||||
a.confirm.Stop()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -61,6 +66,7 @@ func CreateAPI(a api.API) (*NotificationAPI, error) {
|
||||
p.logger.Error("Failed to create telegram client", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
p.confirm = newConfirmationManager(p.logger, p.tg, a.Register().Producer())
|
||||
|
||||
db, err := a.DBFactory().NewAccountDB()
|
||||
if err != nil {
|
||||
@@ -81,6 +87,10 @@ func CreateAPI(a api.API) (*NotificationAPI, error) {
|
||||
p.logger.Error("Failed to create confirmation code handler", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
if err := a.Register().Consumer(confirmations.NewConfirmationRequestProcessor(p.logger, p.onConfirmationRequest)); err != nil {
|
||||
p.logger.Error("Failed to register confirmation request handler", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
idb, err := a.DBFactory().NewInvitationsDB()
|
||||
if err != nil {
|
||||
@@ -97,6 +107,10 @@ func CreateAPI(a api.API) (*NotificationAPI, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if router := a.Router(); router != nil {
|
||||
router.Post("/telegram/webhook", p.handleTelegramWebhook)
|
||||
}
|
||||
|
||||
announce := discovery.Announcement{
|
||||
Service: "NOTIFICATIONS",
|
||||
Operations: []string{"notify.send"},
|
||||
@@ -143,3 +157,10 @@ func (a *NotificationAPI) onCallRequest(ctx context.Context, request *model.Call
|
||||
a.logger.Info("Call request sent via Telegram", zap.String("phone", request.Phone))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *NotificationAPI) onConfirmationRequest(ctx context.Context, request *model.ConfirmationRequest) error {
|
||||
if a.confirm == nil {
|
||||
return merrors.Internal("confirmation manager is not configured")
|
||||
}
|
||||
return a.confirm.HandleRequest(ctx, request)
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ type Client interface {
|
||||
SendDemoRequest(ctx context.Context, request *model.DemoRequest) error
|
||||
SendContactRequest(ctx context.Context, request *model.ContactRequest) error
|
||||
SendCallRequest(ctx context.Context, request *model.CallRequest) error
|
||||
SendText(ctx context.Context, chatID string, text string, replyToMessageID string) (*model.TelegramMessage, error)
|
||||
}
|
||||
|
||||
type client struct {
|
||||
@@ -38,13 +39,14 @@ type client struct {
|
||||
}
|
||||
|
||||
type sendMessagePayload struct {
|
||||
ChatID string `json:"chat_id"`
|
||||
Text string `json:"text"`
|
||||
ParseMode string `json:"parse_mode,omitempty"`
|
||||
ThreadID *int64 `json:"message_thread_id,omitempty"`
|
||||
DisablePreview bool `json:"disable_web_page_preview,omitempty"`
|
||||
DisableNotify bool `json:"disable_notification,omitempty"`
|
||||
ProtectContent bool `json:"protect_content,omitempty"`
|
||||
ChatID string `json:"chat_id"`
|
||||
Text string `json:"text"`
|
||||
ParseMode string `json:"parse_mode,omitempty"`
|
||||
ThreadID *int64 `json:"message_thread_id,omitempty"`
|
||||
ReplyToMessageID *int64 `json:"reply_to_message_id,omitempty"`
|
||||
DisablePreview bool `json:"disable_web_page_preview,omitempty"`
|
||||
DisableNotify bool `json:"disable_notification,omitempty"`
|
||||
ProtectContent bool `json:"protect_content,omitempty"`
|
||||
}
|
||||
|
||||
func NewClient(logger mlogger.Logger, cfg *notconfig.TelegramConfig) (Client, error) {
|
||||
@@ -106,16 +108,40 @@ func (c *client) SendDemoRequest(ctx context.Context, request *model.DemoRequest
|
||||
return c.sendForm(ctx, newDemoRequestTemplate(request))
|
||||
}
|
||||
|
||||
func (c *client) sendMessage(ctx context.Context, payload sendMessagePayload) error {
|
||||
type sendMessageResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
Result *messageResponse `json:"result,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type messageResponse struct {
|
||||
MessageID int64 `json:"message_id"`
|
||||
Date int64 `json:"date"`
|
||||
Chat messageChat `json:"chat"`
|
||||
From *messageUser `json:"from,omitempty"`
|
||||
Text string `json:"text"`
|
||||
ReplyToMessage *messageResponse `json:"reply_to_message,omitempty"`
|
||||
}
|
||||
|
||||
type messageChat struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
type messageUser struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username,omitempty"`
|
||||
}
|
||||
|
||||
func (c *client) sendMessage(ctx context.Context, payload sendMessagePayload) (*model.TelegramMessage, error) {
|
||||
body, err := json.Marshal(&payload)
|
||||
if err != nil {
|
||||
c.logger.Warn("Failed to marshal telegram payload", zap.Error(err))
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint(), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
c.logger.Warn("Failed to create telegram request", zap.Error(err))
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
@@ -129,26 +155,41 @@ func (c *client) sendMessage(ctx context.Context, payload sendMessagePayload) er
|
||||
if payload.ThreadID != nil {
|
||||
fields = append(fields, zap.Int64("thread_id", *payload.ThreadID))
|
||||
}
|
||||
if payload.ReplyToMessageID != nil {
|
||||
fields = append(fields, zap.Int64("reply_to_message_id", *payload.ReplyToMessageID))
|
||||
}
|
||||
c.logger.Debug("Sending Telegram message", fields...)
|
||||
start := time.Now()
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
c.logger.Warn("Telegram request failed", zap.Error(err))
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 16<<10))
|
||||
if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices {
|
||||
var parsed sendMessageResponse
|
||||
if err := json.Unmarshal(respBody, &parsed); err != nil {
|
||||
c.logger.Warn("Failed to decode telegram response", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
if !parsed.OK || parsed.Result == nil {
|
||||
msg := "telegram sendMessage response missing result"
|
||||
if parsed.Description != "" {
|
||||
msg = parsed.Description
|
||||
}
|
||||
return nil, merrors.Internal(msg)
|
||||
}
|
||||
c.logger.Debug("Telegram message sent", zap.Int("status_code", resp.StatusCode), zap.Duration("latency", time.Since(start)))
|
||||
return nil
|
||||
return toTelegramMessage(parsed.Result), nil
|
||||
}
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
|
||||
c.logger.Warn("Telegram API returned non-success status",
|
||||
zap.Int("status_code", resp.StatusCode),
|
||||
zap.ByteString("response_body", respBody),
|
||||
zap.String("chat_id", c.chatID))
|
||||
return merrors.Internal(fmt.Sprintf("telegram sendMessage failed with status %d: %s", resp.StatusCode, string(respBody)))
|
||||
return nil, merrors.Internal(fmt.Sprintf("telegram sendMessage failed with status %d: %s", resp.StatusCode, string(respBody)))
|
||||
}
|
||||
|
||||
func (c *client) endpoint() string {
|
||||
@@ -178,5 +219,51 @@ func (c *client) sendForm(ctx context.Context, template messageTemplate) error {
|
||||
ThreadID: c.threadID,
|
||||
DisablePreview: true,
|
||||
}
|
||||
_, err := c.sendMessage(ctx, payload)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *client) SendText(ctx context.Context, chatID string, text string, replyToMessageID string) (*model.TelegramMessage, error) {
|
||||
chatID = strings.TrimSpace(chatID)
|
||||
if chatID == "" {
|
||||
chatID = c.chatID
|
||||
}
|
||||
if chatID == "" {
|
||||
return nil, merrors.InvalidArgument("telegram chat id is empty", "chat_id")
|
||||
}
|
||||
payload := sendMessagePayload{
|
||||
ChatID: chatID,
|
||||
Text: text,
|
||||
ParseMode: c.parseMode.String(),
|
||||
ThreadID: c.threadID,
|
||||
DisablePreview: true,
|
||||
}
|
||||
if replyToMessageID != "" {
|
||||
val, err := strconv.ParseInt(replyToMessageID, 10, 64)
|
||||
if err != nil {
|
||||
return nil, merrors.InvalidArgumentWrap(err, "invalid reply_to_message_id", "reply_to_message_id")
|
||||
}
|
||||
payload.ReplyToMessageID = &val
|
||||
}
|
||||
return c.sendMessage(ctx, payload)
|
||||
}
|
||||
|
||||
func toTelegramMessage(msg *messageResponse) *model.TelegramMessage {
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
result := &model.TelegramMessage{
|
||||
ChatID: strconv.FormatInt(msg.Chat.ID, 10),
|
||||
MessageID: strconv.FormatInt(msg.MessageID, 10),
|
||||
Text: msg.Text,
|
||||
SentAt: msg.Date,
|
||||
}
|
||||
if msg.From != nil {
|
||||
result.FromUserID = strconv.FormatInt(msg.From.ID, 10)
|
||||
result.FromUsername = msg.From.Username
|
||||
}
|
||||
if msg.ReplyToMessage != nil {
|
||||
result.ReplyToMessageID = strconv.FormatInt(msg.ReplyToMessage.MessageID, 10)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
)
|
||||
|
||||
type Update struct {
|
||||
UpdateID int64 `json:"update_id"`
|
||||
Message *Message `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
MessageID int64 `json:"message_id"`
|
||||
Date int64 `json:"date,omitempty"`
|
||||
Chat Chat `json:"chat"`
|
||||
From *User `json:"from,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
ReplyToMessage *Message `json:"reply_to_message,omitempty"`
|
||||
}
|
||||
|
||||
type Chat struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username,omitempty"`
|
||||
}
|
||||
|
||||
func (m *Message) ToModel() *model.TelegramMessage {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
result := &model.TelegramMessage{
|
||||
ChatID: strconv.FormatInt(m.Chat.ID, 10),
|
||||
MessageID: strconv.FormatInt(m.MessageID, 10),
|
||||
Text: m.Text,
|
||||
SentAt: m.Date,
|
||||
}
|
||||
if m.From != nil {
|
||||
result.FromUserID = strconv.FormatInt(m.From.ID, 10)
|
||||
result.FromUsername = m.From.Username
|
||||
}
|
||||
if m.ReplyToMessage != nil {
|
||||
result.ReplyToMessageID = strconv.FormatInt(m.ReplyToMessage.MessageID, 10)
|
||||
}
|
||||
return result
|
||||
}
|
||||
30
api/notification/internal/server/notificationimp/webhook.go
Normal file
30
api/notification/internal/server/notificationimp/webhook.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package notificationimp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/tech/sendico/notification/internal/server/notificationimp/telegram"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const telegramWebhookMaxBody = 1 << 20
|
||||
|
||||
func (a *NotificationAPI) handleTelegramWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
if a == nil || a.confirm == nil {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
var update telegram.Update
|
||||
dec := json.NewDecoder(io.LimitReader(r.Body, telegramWebhookMaxBody))
|
||||
if err := dec.Decode(&update); err != nil {
|
||||
if a.logger != nil {
|
||||
a.logger.Warn("Failed to decode telegram webhook update", zap.Error(err))
|
||||
}
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
a.confirm.HandleUpdate(r.Context(), &update)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
Reference in New Issue
Block a user