Files
tipper/daemon/daemon.go

1263 lines
34 KiB
Go
Raw Permalink Normal View History

2026-07-21 05:29:37 +03:00
package daemon
import (
"encoding/json"
"errors"
"fmt"
"log"
"regexp"
"sort"
"sync"
"time"
"gordenko.dev/dima/fixme"
"gordenko.dev/dima/flashscore"
fsmodel "gordenko.dev/dima/flashscore/model"
"gordenko.dev/dima/onexbet"
"gordenko.dev/dima/pretty"
"gordenko.dev/dima/tipper/model"
"gordenko.dev/dima/web"
"gordenko.dev/dima/web/api"
"gordenko.dev/dima/ws"
"gordenko.dev/dima/ws/publisher"
)
const (
ChannelHandball = "handball"
ChannelFootball = "football"
ChannelTennis = "tennis"
// Флаги для округления тотала
roundUp = 0
roundDown = 1
// /SportFootball int64 = 1
MarketTotalH1 = 1
MarketTotal = 2
MarketHandicap = 3
SideOver = 1
SideUnder = 2
SideHome = 1
SideAway = 2
WSMessageTipsChanged = "tipsChanged"
// WSMessageTeamMatches Обновленный список матчей после синхронизации с flashscore
WSMessageTeamMatches = "teamMatches"
WSMessageWhyNot = "whyNot"
WSMessageInplayStatusChanged = "inplayStatusChanged"
)
var onexbetSportToFlashscoreSport = map[onexbet.OnexbetSport]int{
onexbet.Football: flashscore.Football,
onexbet.Handball: flashscore.Handball,
onexbet.Tennis: flashscore.Tennis,
}
var flashscoreSportToOnexbetSport = map[int]onexbet.OnexbetSport{
flashscore.Football: onexbet.Football,
flashscore.Handball: onexbet.Handball,
flashscore.Tennis: onexbet.Tennis,
}
var sportToTelegramChatID = map[int]string{
flashscore.Football: footballTelegramChatID,
flashscore.Handball: handballTelegramChatID,
flashscore.Tennis: tennisTelegramChatID,
}
type InplayStatusMessage struct {
MatchID string `json:"matchID"`
IsInplay bool `json:"isInplay"`
IsLinkedToOnexbet bool `json:"isLinkedToOnexbet"`
}
type TeamMatchesMessage struct {
SportID int `json:"sportID"`
Matches []TeamMatch `json:"matches"`
}
// flashscore sportID
func getChannel(sportID int) string {
switch sportID {
case flashscore.Football:
return ChannelFootball
case flashscore.Handball:
return ChannelHandball
case flashscore.Tennis:
return ChannelTennis
default:
panic(fmt.Sprintf("Bug: channel is undefined for the sportID %d", sportID))
}
}
type TipResult struct {
Status model.TipStatus
Result string
}
type StrategyOptions struct {
ID int64
NotifyInTelegram bool
IsChampAccepted func(string) bool
}
type TeamStrategy interface {
ID() int64
ShortName() string
// (report) notes, isInteresting
IsInteresting(*TeamMatch) (interface{}, bool)
// return -> comment why not,
GetTip(*TeamMatch, onexbet.TeamLiveMatchData) (string, *BetDetails, TipCode)
GetResult(model.Tip, onexbet.TeamMatchResult) *TipResult
IsNotifyInTelegram() bool
GetTelegramTipPattern() string
IsNeedToWatch(onexbet.LiveMatch) bool
}
type ChampRatingKey struct {
ChampID string
StrategyID int64
}
type ChampRating struct {
Won int
Lost int
}
func (s *ChampRating) GetAccuracy() (accuracy float64) {
// total = 100%
// won = x%
// x = (won*100) / total
total := s.Won + s.Lost
if total > 0 {
accuracy = float64(s.Won*100) / float64(total)
}
return
}
type FavoriteSide int
const (
FavoriteHome FavoriteSide = 1
FavoriteAway FavoriteSide = 2
)
type TeamMatch struct {
// COMMON
Score onexbet.Score `json:"score"`
ScoreByPeriods map[int]onexbet.Score `json:"scoreByPeriods"`
CurrentPeriod int `json:"currentPeriod"`
// TENNIS stats
TwoSetsBreakpoints int `json:"twoSetsBreakpoints"`
// Для стратегий где нужно определить доматчевого фаворита
// 0 - фаворит не определен
FavoriteSide FavoriteSide `json:"favoriteSide"`
MaxFavoritHandicapIn1stSet *onexbet.ParamOffer `json:"maxFavoritHandicapIn1stSet"`
// less than or equal 1.6 (для алгоритма 4 в теннисе)
Lte16FavoriteSide FavoriteSide `json:"lte16FavoriteSide"`
//IsTargetPriceIn2ndSetFound bool
// Football
Favorite string `json:"favorite,omitempty"`
// Handball
// Начальный тотал (по flashscore до матча)
BasicTotal float64 `json:"basicTotal"`
H1BasicTotal float64 `json:"h1BasicTotal"`
HomeGoalsPerMinute float64 `json:"homeGoalsPerMinute"`
AwayGoalsPerMinute float64 `json:"awayGoalsPerMinute"`
H2HGoalsPerMinute float64 `json:"h2hGoalsPerMinute"`
GamePace float64 `json:"-"`
GamePaceFrame int `json:"-"`
// Main
MatchID string `json:"matchID"`
Home fsmodel.Team `json:"home"`
Away fsmodel.Team `json:"away"`
Zone fsmodel.TeamZone `json:"zone"`
Champ fsmodel.TeamChamp `json:"champ"`
FullChampName string `json:"fullChampName"`
IsLinkedToOnexbet bool `json:"isLinkedToOnexbet"`
IsInplay bool `json:"isInplay"` // узнать - отписались от обновлений или нет?
SportID int `json:"sportID"`
Strategies map[int64]TeamStrategy `json:"-"`
StartTime int64 `json:"startTime"`
ParseTime time.Time `json:"parseTime"`
OnexbetMatchID string `json:"onexbetMatchID"`
OnexbetStatsMatchID string `json:"onexbetStatsMatchID"`
IsTeamsSwapped bool `json:"isTeamsSwapped"`
Notes []StrategyNotes `json:"notes"`
//TipTotalOver float64 `json:"tipTotalOver"`
//TipTotalUnder float64 `json:"tipTotalUnder"`
// Ожимаемый темп игры (голов в минуту). Начальный тотал / 60 минут
//BasicGoalsPerMinute float64 `json:"basicGoalsPerMinute"`
//BasicOverGoalsPerMinute float64 `json:"basicOverGoalsPerMinute"`
//BasicUnderGoalsPerMinute float64 `json:"basicUnderGoalsPerMinute"`
// Статистика для расчета
HomeTeamMatches []fsmodel.TeamMatch `json:"homeTeamMatches"`
AwayTeamMatches []fsmodel.TeamMatch `json:"awayTeamMatches"`
H2H []fsmodel.TeamMatch `json:"h2h"`
Odds flashscore.Odds `json:"odds"`
}
type StrategyNotes struct {
StrategyID int64 `json:"strategyID"`
Notes interface{} `json:"notes"`
}
type TipCode int
const (
Waiting TipCode = 0
Unwatch TipCode = 1
Bet TipCode = 2
)
type BetDetails struct {
Market int
Side int
Param string
Price float64
TelegramMessage string
}
/*
var tipTextAlgo100Pattern = `Сигнал # %d.
Будет ГОЛ!
Алгоритм 100
Первый тайм, Тотал Больше 0.5
Футбол. %s. %s
%s - %s
Коэф. %.3f`
*/
var tipResultTextPattern = `Сигнал # %d.
%s
%s
`
const (
EmptyTipStatus fixme.Code = "T1"
WrongTipStatus fixme.Code = "T2"
EmptyValue fixme.Code = "T3"
)
var errorMessages = map[fixme.Code]string{
EmptyTipStatus: "не указан статус",
WrongTipStatus: "неверный статус",
EmptyValue: "пустое значение недопустимо",
}
var fix = fixme.New(errorMessages)
type Options struct {
Logger *log.Logger
WsPublicServer *ws.PublicServer
FlashscoreAPIAddr string
OnexbetWsURL string
OnexbetAPIURL string
SportIDs []int
FlashscoreModel *fsmodel.Model
TipperModel *model.Model
}
type Daemon struct {
mutex sync.Mutex
fsAPIClient *api.Client
onexbetClient *ws.Client
logger *log.Logger
model *model.Model
wsServer *ws.PublicServer
publisher *publisher.PublicPublisher
onexbetAPIClient *api.Client
fsmodel *fsmodel.Model
champRatings map[int]map[ChampRatingKey]*ChampRating //map[int64]map[string]*ChampRating
//handballChampRatings map[ChampRatingKey]*ChampRating
// Отобаранные матчи по видам спорта
teamMatches map[int]map[string]*TeamMatch
onexbetMatchToTeamMatch map[string]*TeamMatch
sportStrategies map[int]map[int64]TeamStrategy
historyProcessors map[int]func(*TeamMatch)
sportIDs []int
unrecognizedOnexbetMatches map[int]map[string]bool
identifiedMatches map[string]bool // 1xbet MatchID => true
}
func New(opt Options) (*Daemon, error) {
s := new(Daemon)
s.logger = opt.Logger
s.sportIDs = opt.SportIDs
s.identifiedMatches = make(map[string]bool)
s.model = opt.TipperModel
s.fsmodel = opt.FlashscoreModel
// TELEGRAM, topChamps
/*
s.footballStrategies[11] = NewP1Over05(StrategyOptions{
ID: 11,
NotifyInTelegram: true,
IsChampAccepted: acceptedChamps(topChamps),
})
s.footballStrategies[22] = NewOver05(StrategyOptions{
ID: 22,
NotifyInTelegram: true,
IsChampAccepted: acceptedChamps(topChamps),
})
s.footballStrategies[33] = NewOver15(StrategyOptions{
ID: 33,
NotifyInTelegram: true,
IsChampAccepted: acceptedChamps(topChamps),
})
// Мои стратегии - ТМ для всех чемпионатов, кроме топовых
s.footballStrategies[701] = NewP1Under05(StrategyOptions{
ID: 701,
NotifyInTelegram: false,
IsChampAccepted: nonAcceptedChamps(topChamps),
})
s.footballStrategies[702] = NewUnder05(StrategyOptions{
ID: 702,
NotifyInTelegram: false,
IsChampAccepted: nonAcceptedChamps(topChamps),
})
s.footballStrategies[703] = NewUnder15(StrategyOptions{
ID: 703,
NotifyInTelegram: false,
IsChampAccepted: nonAcceptedChamps(topChamps),
})
*/
if opt.FlashscoreAPIAddr == "" {
return nil, errors.New("FlashscoreAPIAddr option is required")
}
var err error
s.fsAPIClient, err = api.NewClient(api.ClientOptions{
URL: opt.FlashscoreAPIAddr,
BuildRequest: api.BuildEnvelopeRequest,
})
if err != nil {
return nil, fmt.Errorf("api.NewClient(flashscore): %s", err)
}
s.onexbetClient, err = ws.NewClient(ws.ClientOptions{
URL: opt.OnexbetWsURL,
Logger: opt.Logger,
})
if err != nil {
return nil, fmt.Errorf("ws.NewClient(onexbet): %s", err)
}
s.onexbetClient.OnConnected(s.onOnexbetWsClientConnected)
s.onexbetClient.Handle(onexbet.WsMsgLiveMatches, s.onOnexbetTeamLiveMatches)
s.onexbetClient.Handle(onexbet.WsMsgTeamLiveMatchData, s.onOnexbetTeamLiveMatchData)
s.onexbetClient.Handle(onexbet.WsMsgTennisLiveMatchData, s.onOnexbetTeamLiveMatchData)
s.wsServer = opt.WsPublicServer
s.wsServer.OnConnected(s.onWsClientConnected)
s.wsServer.OnDisconnected(s.onWsClientDisconnected)
s.publisher, err = publisher.NewPublicPublisher(publisher.PublicPublisherOptions{
WsPublicServer: s.wsServer,
})
s.publisher.OnTopicSubscribeRequest(ChannelHandball, s.onWsSubscribeRequestToHandball)
s.publisher.OnTopicSubscribeRequest(ChannelFootball, s.onWsSubscribeRequestToFootball)
s.publisher.OnTopicSubscribeRequest(ChannelTennis, s.onWsSubscribeRequestToTennis)
s.onexbetAPIClient, err = api.NewClient(api.ClientOptions{
URL: opt.OnexbetAPIURL,
BuildRequest: api.BuildEnvelopeRequest,
})
if err != nil {
return nil, fmt.Errorf("api.NewClient(onexbet): %s", err)
}
s.teamMatches = make(map[int]map[string]*TeamMatch)
s.onexbetMatchToTeamMatch = make(map[string]*TeamMatch)
s.sportStrategies = make(map[int]map[int64]TeamStrategy)
s.unrecognizedOnexbetMatches = make(map[int]map[string]bool)
s.champRatings = make(map[int]map[ChampRatingKey]*ChampRating)
for _, sportID := range s.sportIDs {
s.teamMatches[sportID] = make(map[string]*TeamMatch)
s.sportStrategies[sportID] = make(map[int64]TeamStrategy)
s.unrecognizedOnexbetMatches[sportID] = make(map[string]bool)
s.champRatings[sportID] = make(map[ChampRatingKey]*ChampRating)
}
// Набор статистики по всем чемпионатам
// FOOTBALL
/*
s.sportStrategies[flashscore.Football][1] = NewP1Over05(StrategyOptions{
ID: 1,
NotifyInTelegram: true,
})
s.sportStrategies[flashscore.Football][2] = NewOver05(StrategyOptions{
ID: 2,
NotifyInTelegram: true,
})
s.sportStrategies[flashscore.Football][3] = NewOver15(StrategyOptions{
ID: 3,
NotifyInTelegram: true,
})
*/
s.sportStrategies[flashscore.Football][6] = NewFootballP1Over05(StrategyOptions{
ID: 6,
NotifyInTelegram: true,
})
s.sportStrategies[flashscore.Football][7] = NewFootballOver05(StrategyOptions{
ID: 7,
NotifyInTelegram: true,
})
s.sportStrategies[flashscore.Football][8] = NewFootballOver15(StrategyOptions{
ID: 8,
NotifyInTelegram: true,
})
// HANDBALL
s.sportStrategies[flashscore.Handball][4] = NewHandballFTOver(HandballStrategyOptions{
ID: 4,
NotifyInTelegram: true,
TotalDiff: 10,
FixateGamePaceEveryNMinutes: 5,
})
s.sportStrategies[flashscore.Handball][5] = NewHandballFTUnder(HandballStrategyOptions{
ID: 5,
NotifyInTelegram: true,
TotalDiff: 10,
FixateGamePaceEveryNMinutes: 5,
})
// HANDBALL
s.sportStrategies[flashscore.Handball][11] = NewHandballP1Over(HandballStrategyOptions{
ID: 11,
NotifyInTelegram: true,
TotalDiff: 7,
FixateGamePaceEveryNMinutes: 5,
})
s.sportStrategies[flashscore.Handball][12] = NewHandballP1Under(HandballStrategyOptions{
ID: 12,
NotifyInTelegram: true,
TotalDiff: 7,
FixateGamePaceEveryNMinutes: 5,
})
/*
s.sportStrategies[flashscore.Handball][13] = NewHandballP1Over(HandballStrategyOptions{
ID: 13,
NotifyInTelegram: true,
TotalDiff: 5,
FixateGamePaceEveryNMinutes: 5,
})
s.sportStrategies[flashscore.Handball][14] = NewHandballP1Under(HandballStrategyOptions{
ID: 14,
NotifyInTelegram: true,
TotalDiff: 5,
FixateGamePaceEveryNMinutes: 5,
})
*/
// TENNIS
s.sportStrategies[flashscore.Tennis][15] = NewTennis3rdSetTotalUnder(StrategyOptions{
ID: 15,
NotifyInTelegram: true,
})
s.sportStrategies[flashscore.Tennis][16] = NewTennis2ndSetPlusHandicap(StrategyOptions{
ID: 16,
NotifyInTelegram: true,
})
s.sportStrategies[flashscore.Tennis][17] = NewTennis2ndSetPlusHandicapAny(StrategyOptions{
ID: 17,
NotifyInTelegram: true,
})
s.sportStrategies[flashscore.Tennis][18] = NewTennis2ndSetFavoriteHandicap(StrategyOptions{
ID: 18,
NotifyInTelegram: true,
})
s.historyProcessors = make(map[int]func(*TeamMatch))
s.historyProcessors[flashscore.Handball] = HandballHistoryProcessor
// Формируем рейтинг для футбольных стратегий
for sportID, sportStrategies := range s.sportStrategies {
champRatings := s.champRatings[sportID]
for strategyID := range sportStrategies {
var stats []model.StrategyChampStats
stats, err = s.model.ListStrategyChampStats(strategyID)
if err != nil {
return nil, fmt.Errorf("model.ListStrategyChampStats(%d): %s",
strategyID, err)
}
//buf, _ := json.MarshalIndent(stats, "", " ")
//s.logger.Printf("Stats (%d):\n%s\n\n", strategyID, buf)
for _, champ := range stats {
key := ChampRatingKey{
ChampID: champ.ChampID,
StrategyID: strategyID,
}
champRatings[key] = &ChampRating{
Won: champ.Won,
Lost: champ.Lost,
}
}
}
}
return s, nil
}
func (s *Daemon) ListRatedChamps(state *web.State, reply *web.Reply) (err error) {
var sportID int
state.Val("sportID", &sportID)
s.mutex.Lock()
defer s.mutex.Unlock()
ratings, ok := s.champRatings[sportID]
if !ok {
return
}
str := ""
type ChampAndRating struct {
ChampID string
StrategyID int64
Won int
Lost int
Accuracy float64
}
var sorted []ChampAndRating
for key, rating := range ratings {
//if (rating.Won+rating.Lost) >= 6 && rating.GetAccuracy() >= 75 {
if rating.GetAccuracy() >= 70 {
sorted = append(sorted, ChampAndRating{
ChampID: key.ChampID,
StrategyID: key.StrategyID,
Won: rating.Won,
Lost: rating.Lost,
Accuracy: rating.GetAccuracy(),
})
}
}
sort.Slice(sorted, func(i, j int) bool {
a := sorted[i]
b := sorted[j]
if a.StrategyID == b.StrategyID {
return a.Accuracy < b.Accuracy
}
return a.StrategyID < b.StrategyID
})
for _, champ := range sorted {
str += fmt.Sprintf("<tr><td>%s (стратегия %d):</td><td>won=%d, lost=%d, accuracy=%.2f</td></tr>", champ.ChampID, champ.StrategyID, champ.Won, champ.Lost, champ.Accuracy)
}
//reply.SetHeader("Content-Type", "text/pain")
reply.WriteString(`<table style="border-spacing: 10px; border: 0px;">` + str + `</table>`)
return
}
func (s *Daemon) TryLoadAndSetTipResults() {
tips, err := s.model.ListWaitingTips()
if err != nil {
err = fmt.Errorf("ListWaitingTips: %s", err)
return
}
s.logger.Println(pretty.PSprintln("Waiting Tips", tips))
// Час назад
//aHourAgo := time.Now().Unix() - 3600
for _, tip := range tips {
// Если матч начался менее часа назад - пропускаем.
//if tip.TipTime > aHourAgo {
// continue
//}
err = s.tryLoadAndSetTipResult(tip)
if err != nil {
s.logger.Printf("trySetTipResult: %s; tipID=%d", err, tip.TipID)
}
}
}
func (s *Daemon) Run() {
go s.flashscoreLoader()
go s.tipResultsLoader()
go s.oldMatchesRemover()
}
func (s *Daemon) oldMatchesRemover() {
interval := 30 * time.Minute
ticker := time.NewTicker(interval)
for {
select {
case <-ticker.C:
s.removeOldMatches()
}
}
}
func (s *Daemon) tipResultsLoader() {
interval := 20 * time.Minute
ticker := time.NewTicker(interval)
s.TryLoadAndSetTipResults()
for {
select {
case <-ticker.C:
s.TryLoadAndSetTipResults()
}
}
}
func (s *Daemon) flashscoreLoader() {
interval := 1 * time.Hour
ticker := time.NewTicker(interval)
for _, sportID := range s.sportIDs {
err := s.listUpcomingMatchReports(sportID)
if err != nil {
s.logger.Printf("listUpcomingMatchReports(sportID=%d): %s\n",
sportID, err)
}
}
s.onexbetClient.ConnectAsync()
for {
select {
case <-ticker.C:
for _, sportID := range s.sportIDs {
err := s.listUpcomingMatchReports(sportID)
if err != nil {
s.logger.Printf("listUpcomingMatchReports(sportID=%d): %s\n",
sportID, err)
}
}
}
}
}
func (s *Daemon) removeOldMatches() {
s.mutex.Lock()
defer s.mutex.Unlock()
a2HoursAgo := time.Now().Unix() - 7200
for sportID, sportMatches := range s.teamMatches {
var count int
for _, match := range sportMatches {
_, isInInplay := s.onexbetMatchToTeamMatch[match.OnexbetMatchID]
if match.StartTime < a2HoursAgo && !isInInplay {
// Если матч стартовал более 2 часов назад и не в Inplay - удаляем
delete(sportMatches, match.MatchID)
//delete(s.identifiedMatches, match.OnexbetMatchID)
count++
}
}
// Отправляем уведомления
if count > 0 {
s.publisher.Publish(
getChannel(sportID),
WSMessageTeamMatches,
s.getTeamMatchesMessage(sportID),
)
}
}
}
func (s *Daemon) listUpcomingMatchReports(sportID int) (err error) {
var buf []byte
err = s.fsAPIClient.Call(api.CallReq{
FuncName: "listUpcomingMatchReports",
In: sportID,
Out: &buf,
})
if err != nil {
return
}
var reports []flashscore.UpcomingMatchReport
err = json.Unmarshal(buf, &reports)
if err != nil {
return
}
now := time.Now().Unix() - 10800
s.mutex.Lock()
defer s.mutex.Unlock()
sportStrategies := s.sportStrategies[sportID]
fmt.Printf("Sport strategies qty: %d\n", len(sportStrategies))
teamMatches := s.teamMatches[sportID]
// Сохраняем в этот словарь все matchID из reports, чтобы сделав проход по teamMatches,
// обнаружить матчи, которых больше нет в reports
//checkedMatchIDs := make(map[string]bool)
// Синхронизация:
// - матчи из teamMatches, которые по времени начались - пропускаем
// - матчи из reports, которые по времени начались - пропускаем
// - матчи, которых нет в teamMatches - добавляем
// - матчи, которые есть в teamMatches (и еще не начались), но нет в reports - удаляем
// - матчи, которые есть и там и там, но не начались - обнуляем список стратегий
// и прогоняем с нуля. Если ни одной стратегии не удовлетворяем - удаляем
// Первый проход по reports
for _, report := range reports {
//checkedMatchIDs[report.MatchID] = true
if report.StartTime <= now {
// матч уже начался - пропускаем
s.logger.Printf("match already started: %d < now %d\n", report.StartTime, now)
continue
}
match, ok := teamMatches[report.MatchID]
if ok {
if match.StartTime <= now {
// матч уже начался - пропускаем
s.logger.Printf("match already started: %d < now %d\n", match.StartTime, now)
continue
}
if match.IsLinkedToOnexbet {
// матч уже в линии 1xBet и распознан - пропускаем
s.logger.Printf("match %s already linked\n", match.MatchID)
continue
}
// Обнуляем стратегии чтобы пересканировать заново
match.Strategies = make(map[int64]TeamStrategy)
match.ParseTime = report.ParseTime
match.HomeTeamMatches = report.HomeTeamMatches
match.AwayTeamMatches = report.AwayTeamMatches
match.H2H = report.H2H
match.Odds = report.Odds
match.Notes = nil
} else {
// Чтобы звездочками отметить матчи, которые не были ни разу распознаны
home, err := s.fsmodel.GetTeam(report.Home.TeamID)
if err != nil {
s.logger.Printf("fsmodel.GetTeam: %s; teamID=%s", err, report.Home.TeamID)
} else {
report.Home.WasLinked = home.WasLinked
}
away, err := s.fsmodel.GetTeam(report.Away.TeamID)
if err != nil {
s.logger.Printf("fsmodel.GetTeam: %s; teamID=%s", err, report.Away.TeamID)
} else {
report.Away.WasLinked = away.WasLinked
}
// Добавляем новый матч
match = &TeamMatch{
MatchID: report.MatchID,
Home: report.Home,
Away: report.Away,
Zone: report.Zone,
Champ: report.Champ,
FullChampName: report.FullChampName,
StartTime: report.StartTime,
ParseTime: report.ParseTime,
SportID: sportID,
Strategies: make(map[int64]TeamStrategy),
HomeTeamMatches: report.HomeTeamMatches,
AwayTeamMatches: report.AwayTeamMatches,
H2H: report.H2H,
Odds: report.Odds,
}
teamMatches[report.MatchID] = match
s.logger.Printf("match %s added\n", match.MatchID)
}
historyProcessor := s.historyProcessors[sportID]
if historyProcessor != nil {
historyProcessor(match)
}
for _, strategy := range sportStrategies {
// Сперва проверяем не было ли уже прогнозов на этот матч и эту стратегию
var hasTip bool
hasTip, err = s.model.HasTip(match.MatchID, strategy.ID())
if err != nil {
s.logger.Printf("model.HasTip: %s\n", err)
} else {
if !hasTip {
notes, isInteresting := strategy.IsInteresting(match)
if isInteresting {
match.Strategies[strategy.ID()] = strategy
match.Notes = append(match.Notes, StrategyNotes{
StrategyID: strategy.ID(),
Notes: notes,
})
}
}
}
}
if len(match.Strategies) == 0 {
// матч никому не интересен - удаляем
s.logger.Printf("match %s not interesting -> remove\n", match.MatchID)
delete(teamMatches, report.MatchID)
}
}
/*
// Второй проход по teamMatches
for matchID, match := range teamMatches {
if checkedMatchIDs[matchID] {
continue
}
// Матча не было в reports - какндидат на удаление
if match.StartTime <= now {
// матч уже начался - пропускаем
continue
}
if match.IsLinkedToOnexbet {
// матч уже в линии 1xBet и распознан - пропускаем
continue
}
delete(teamMatches, matchID)
}
*/
s.publisher.Publish(getChannel(sportID),
WSMessageTeamMatches, s.getTeamMatchesMessage(sportID))
return
}
func (s *Daemon) getTeamMatchesMessage(sportID int) (msg TeamMatchesMessage) {
teamsMatches, ok := s.teamMatches[sportID]
if !ok {
panic(fmt.Sprintf("Bug: getTeamMatchesMessage of unknown sportID %d\n", sportID))
}
msg.SportID = sportID
for _, match := range teamsMatches {
msg.Matches = append(msg.Matches, *match)
}
return
}
func (s *Daemon) tryLoadAndSetTipResult(tip model.Tip) (err error) {
if tip.OnexbetStatsMatchID == "" {
err = fmt.Errorf("Empty OnexbetStatsMatchID")
return
}
var matchResult onexbet.TeamMatchResult
if tip.SportID == flashscore.Tennis {
matchResult, err = onexbet.GetTennisMatchResult(tip.OnexbetStatsMatchID)
if err != nil {
err = fmt.Errorf("onexbet.GetTeamMatchResult: %s", err)
return
}
} else {
matchResult, err = onexbet.GetTeamMatchResult(tip.OnexbetStatsMatchID)
if err != nil {
err = fmt.Errorf("onexbet.GetTeamMatchResult: %s", err)
return
}
}
s.logger.Printf("%s result: %s\n", tip.OnexbetStatsMatchID, pretty.Sprint(matchResult))
return s.trySetTipResult(tip, matchResult)
}
func swapTeamMatchResult(r onexbet.TeamMatchResult) onexbet.TeamMatchResult {
r.AwayPoints, r.HomePoints = r.HomePoints, r.AwayPoints
for idx, p := range r.ScoreByPeriods {
p.AwayPoints, p.HomePoints = p.HomePoints, p.AwayPoints
r.ScoreByPeriods[idx] = p
}
return r
}
func (s *Daemon) trySetTipResult(tip model.Tip, matchResult onexbet.TeamMatchResult) (err error) {
sportStrategies, ok := s.sportStrategies[int(tip.SportID)]
if !ok {
err = fmt.Errorf("No strategies found for the sportID %d", tip.SportID)
return
}
strategy, ok := sportStrategies[tip.StrategyID]
if !ok {
s.logger.Printf("Unknown strategyID %d", tip.StrategyID)
return
}
if tip.IsTeamsSwapped {
matchResult = swapTeamMatchResult(matchResult)
}
result := strategy.GetResult(tip, matchResult)
if result == nil {
err = fmt.Errorf("strategy.GetResult returns nil")
return
}
//s.logger.Printf(pretty.PSprintln)
err = s.model.SetTipResult(model.SetTipResultReq{
TipID: tip.TipID,
Status: result.Status,
Result: result.Result,
})
if err != nil {
err = fmt.Errorf("model.SetTipResult: %s", err)
return
}
s.notifyAboutTipResult(tip, result, strategy)
return
}
func (s *Daemon) notifyAboutTipResult(tip model.Tip, result *TipResult, strategy TeamStrategy) (err error) {
var outcome string
switch result.Status {
case model.Won:
outcome = "Выигрыш"
case model.Lost:
outcome = "Проигрыш"
case model.Void:
outcome = "Возврат"
}
msg := fmt.Sprintf(tipResultTextPattern, tip.TipID, result.Result, outcome)
// Обновляем рейтинг чемпионатов
key := ChampRatingKey{
ChampID: tip.ChampID,
StrategyID: tip.StrategyID,
}
champRatings, ok := s.champRatings[int(tip.SportID)]
if ok {
rating, ok := champRatings[key]
if !ok {
rating = &ChampRating{}
champRatings[key] = rating
}
if result.Status == model.Won {
rating.Won++
} else if result.Status == model.Lost {
rating.Lost++
}
}
if strategy.IsNotifyInTelegram() {
// дополнительная проверка на isRated. У тенниса нет рейтингов по чемпионатам,
// поэтому проверка более хитрая.
var ok bool
switch tip.SportID {
case flashscore.Tennis:
ok = true
case flashscore.Handball, flashscore.Football:
if tip.IsRated {
ok = true
}
}
if ok {
telegramChatID, hasChat := sportToTelegramChatID[int(tip.SportID)]
if hasChat {
err = sendMessageToTelegramGroup(telegramChatID, msg)
if err != nil {
s.logger.Printf("send tip result to Telegram: %s\n", err)
}
} else {
s.logger.Printf("Sport %d hasn't telegram chat\n", tip.SportID)
}
}
}
s.publisher.Publish(getChannel(int(tip.SportID)), WSMessageTipsChanged, nil)
return
}
func (s *Daemon) SetTipResultManual(in model.SetTipResultReq) (err error) {
tip, err := s.model.GetTip(in.TipID)
if err != nil {
return
}
if tip == nil {
s.logger.Printf("setTipResultManual: tipID=%d not found in db\n", in.TipID)
return
}
switch in.Status {
case 0:
err = fix.Field(EmptyTipStatus, "status")
return
case model.Won, model.Lost, model.Void:
// pass
default:
err = fix.Field(WrongTipStatus, "status")
return
}
err = s.model.SetTipResult(in)
if err != nil {
return
}
// Если стратегии не найдены - сообщение в телеграм отправлено не будет
sportStrategies, ok := s.sportStrategies[int(tip.SportID)]
if !ok {
err = fmt.Errorf("No strategies found for the sportID %d", tip.SportID)
return
}
strategy, ok := sportStrategies[tip.StrategyID]
if !ok {
s.logger.Printf("Unknown strategyID %d", tip.StrategyID)
return
}
s.notifyAboutTipResult(
*tip,
&TipResult{Status: in.Status, Result: in.Result},
strategy,
)
return
}
func (s *Daemon) onWsSubscribeRequestToHandball(conn *ws.Conn, channel, arg string) {
s.mutex.Lock()
defer s.mutex.Unlock()
s.logger.Printf("ws client subscribes to channel %q\n", channel)
conn.Send(WSMessageTeamMatches, s.getTeamMatchesMessage(flashscore.Handball))
s.publisher.SubscribeTo(conn, channel)
}
func (s *Daemon) onWsSubscribeRequestToFootball(conn *ws.Conn, channel, arg string) {
s.mutex.Lock()
defer s.mutex.Unlock()
s.logger.Printf("ws client subscribes to channel %q\n", channel)
conn.Send(WSMessageTeamMatches, s.getTeamMatchesMessage(flashscore.Football))
s.publisher.SubscribeTo(conn, channel)
}
func (s *Daemon) onWsSubscribeRequestToTennis(conn *ws.Conn, channel, arg string) {
s.mutex.Lock()
defer s.mutex.Unlock()
s.logger.Printf("ws client subscribes to channel %q\n", channel)
conn.Send(WSMessageTeamMatches, s.getTeamMatchesMessage(flashscore.Tennis))
s.publisher.SubscribeTo(conn, channel)
}
// func (s *Daemon) onWsSubscribeRequest(conn *ws.Conn, channel string) {
// s.mutex.Lock()
// defer s.mutex.Unlock()
// s.logger.Printf("ws client subscribes to channel %q\n", channel)
// switch channel {
// case ChannelHandball:
// conn.Send(WSMessageTeamMatches, s.getTeamMatchesMessage(flashscore.Handball))
// case ChannelFootball:
// conn.Send(WSMessageTeamMatches, s.getTeamMatchesMessage(flashscore.Football))
// case ChannelTennis:
// conn.Send(WSMessageTeamMatches, s.getTeamMatchesMessage(flashscore.Tennis))
// }
// s.publisher.SubscribeTo(conn, channel)
// }
func (s *Daemon) onWsClientConnected(conn *ws.Conn) {
//s.mutex.Lock()
//defer s.mutex.Unlock()
s.logger.Printf("ws client connected\n")
//for _, sportID := range s.sportIDs {
// conn.Send(WSMessageTeamMatches, s.getTeamMatchesMessage(sportID))
//}
//s.logger.Printf("team matches sent\n")
//s.publisher.SubscribeTo(conn, Channel)
}
func (s *Daemon) onWsClientDisconnected(conn *ws.Conn) {
s.mutex.Lock()
defer s.mutex.Unlock()
s.logger.Printf("ws client disconnected\n")
//s.publisher.UnsubscribeConn(conn)
}
// Регулярка для вырезания буквы W из названия команды.
// Поиск на 1xBet не дает результатов если в названии есть W, например Ajax W
var reW = regexp.MustCompile(`\bW\b`)
type SearchMatchCandidatesIn struct {
SportID int `json:"sportID"`
Home string `json:"home"`
Away string `json:"away"`
}
func (s *Daemon) SearchMatchCandidates(in SearchMatchCandidatesIn) (matches []onexbet.MatchCandidate, err error) {
in.Home = reW.ReplaceAllString(in.Home, "")
in.Away = reW.ReplaceAllString(in.Away, "")
switch in.SportID {
case flashscore.Football:
return onexbet.SearchMatchCandidates(in.Home, in.Away, onexbet.Football)
case flashscore.Handball:
return onexbet.SearchMatchCandidates(in.Home, in.Away, onexbet.Handball)
case flashscore.Tennis:
return onexbet.SearchMatchCandidates(in.Home, in.Away, onexbet.Tennis)
}
return
}
type SearchMatchCandidatesByTeamNameIn struct {
SportID int `json:"sportID"`
TeamName string `json:"teamName"`
}
func (s *Daemon) SearchMatchCandidatesByTeamName(in SearchMatchCandidatesByTeamNameIn) (matches []onexbet.MatchCandidate, err error) {
in.TeamName = reW.ReplaceAllString(in.TeamName, "")
return onexbet.SearchTeamCandidates(in.TeamName, flashscoreSportToOnexbetSport[in.SportID])
}
type AddTeamsAliasesIn struct {
SportID int
Home fsmodel.TeamAlias
Away fsmodel.TeamAlias
}
func (s *Daemon) AddTeamsAliases(in AddTeamsAliasesIn) (err error) {
err = s.fsmodel.AddTeamAlias(in.Home)
if err != nil {
apierr, ok := err.(fixme.Field)
if !ok {
return
}
if apierr.Code != fsmodel.Duplicate {
return
}
err = nil
}
err = s.fsmodel.AddTeamAlias(in.Away)
if err != nil {
apierr, ok := err.(fixme.Field)
if !ok {
return
}
if apierr.Code != fsmodel.Duplicate {
return
}
err = nil
}
s.mutex.Lock()
defer s.mutex.Unlock()
// Сбрасываем кэш нераспознанных 1xBet матчей
s.unrecognizedOnexbetMatches[in.SportID] = make(map[string]bool)
return
}
func (s *Daemon) ListSportTips(sportID int) (tips []model.Tip, err error) {
tips, err = s.model.ListSportTips(sportID)
if err != nil {
return
}
for idx, tip := range tips {
strategies, foundSport := s.sportStrategies[int(tip.SportID)]
if foundSport {
strategy, has := strategies[tip.StrategyID]
if has {
tip.Strategy = strategy.ShortName()
tips[idx] = tip
}
}
}
return
}