2023-07-14 14:15:58 +00:00
package raffle
import (
"strings"
"sync"
"time"
"github.com/pkg/errors"
2023-09-11 17:51:38 +00:00
"gopkg.in/irc.v4"
2023-11-27 23:09:27 +00:00
"gorm.io/gorm"
2023-07-14 14:15:58 +00:00
2023-11-27 23:09:27 +00:00
"github.com/Luzifer/twitch-bot/v3/internal/helpers"
2023-07-14 14:15:58 +00:00
"github.com/Luzifer/twitch-bot/v3/pkg/database"
"github.com/Luzifer/twitch-bot/v3/plugins"
)
const (
frontendNotifyEventRaffleChange = "raffleChanged"
frontendNotifyEventRaffleEntryChange = "raffleEntryChanged"
)
type (
dbClient struct {
activeRaffles map [ string ] uint64
speakUp map [ string ] * speakUpWait
db database . Connector
lock sync . RWMutex
}
raffle struct {
ID uint64 ` gorm:"primaryKey" json:"id" `
Channel string ` json:"channel" `
Keyword string ` json:"keyword" `
Title string ` json:"title" `
Status raffleStatus ` gorm:"default:planned" json:"status" `
AllowEveryone bool ` json:"allowEveryone" `
AllowFollower bool ` json:"allowFollower" `
AllowSubscriber bool ` json:"allowSubscriber" `
AllowVIP bool ` gorm:"column:allow_vip" json:"allowVIP" `
MinFollowAge time . Duration ` json:"minFollowAge" `
MultiFollower float64 ` json:"multiFollower" `
MultiSubscriber float64 ` json:"multiSubscriber" `
MultiVIP float64 ` gorm:"column:multi_vip" json:"multiVIP" `
AutoStartAt * time . Time ` json:"autoStartAt" `
CloseAfter time . Duration ` json:"closeAfter" `
CloseAt * time . Time ` json:"closeAt" `
WaitForResponse time . Duration ` json:"waitForResponse" `
TextClose string ` json:"textClose" `
TextClosePost bool ` json:"textClosePost" `
TextEntry string ` json:"textEntry" `
TextEntryPost bool ` json:"textEntryPost" `
TextEntryFail string ` json:"textEntryFail" `
TextEntryFailPost bool ` json:"textEntryFailPost" `
TextReminder string ` json:"textReminder" `
TextReminderInterval time . Duration ` json:"textReminderInterval" `
2023-08-26 17:11:49 +00:00
TextReminderNextSend * time . Time ` json:"-" `
2023-07-14 14:15:58 +00:00
TextReminderPost bool ` json:"textReminderPost" `
TextWin string ` json:"textWin" `
TextWinPost bool ` json:"textWinPost" `
Entries [ ] raffleEntry ` gorm:"foreignKey:RaffleID" json:"entries,omitempty" `
}
raffleEntry struct {
ID uint64 ` gorm:"primaryKey" json:"id" `
RaffleID uint64 ` gorm:"uniqueIndex:user_per_raffle" json:"-" `
2023-07-15 22:53:28 +00:00
UserID string ` gorm:"size:128;uniqueIndex:user_per_raffle" json:"userID" `
2023-07-14 14:15:58 +00:00
UserLogin string ` json:"userLogin" `
UserDisplayName string ` json:"userDisplayName" `
EnteredAt time . Time ` json:"enteredAt" `
EnteredAs string ` json:"enteredAs" `
Multiplier float64 ` json:"multiplier" `
WasPicked bool ` json:"wasPicked" `
WasRedrawn bool ` json:"wasRedrawn" `
DrawResponse string ` json:"drawResponse,omitempty" `
SpeakUpUntil * time . Time ` json:"speakUpUntil,omitempty" `
}
raffleMessageEvent uint8
raffleStatus string
speakUpWait struct {
RaffleEntryID uint64
Until time . Time
}
)
const (
raffleMessageEventEntryFailed raffleMessageEvent = iota
raffleMessageEventEntry
raffleMessageEventReminder
raffleMessageEventWin
raffleMessageEventClose
)
const (
raffleStatusPlanned raffleStatus = "planned"
raffleStatusActive raffleStatus = "active"
raffleStatusEnded raffleStatus = "ended"
)
var errRaffleNotFound = errors . New ( "raffle not found" )
func newDBClient ( db database . Connector ) * dbClient {
return & dbClient {
activeRaffles : make ( map [ string ] uint64 ) ,
speakUp : make ( map [ string ] * speakUpWait ) ,
db : db ,
}
}
// AutoCloseExpired collects all active raffles which have overdue
// close_at dates and closes them
func ( d * dbClient ) AutoCloseExpired ( ) ( err error ) {
var rr [ ] raffle
2023-11-27 23:09:27 +00:00
if err = helpers . Retry ( func ( ) error {
return d . db . DB ( ) .
Where ( "status = ? AND close_at IS NOT NULL AND close_at < ?" , raffleStatusActive , time . Now ( ) . UTC ( ) ) .
Find ( & rr ) .
Error
} ) ; err != nil {
2023-07-14 14:15:58 +00:00
return errors . Wrap ( err , "fetching raffles to close" )
}
for _ , r := range rr {
if err = d . Close ( r . ID ) ; err != nil {
return errors . Wrapf ( err , "closing raffle %d" , r . ID )
}
}
return nil
}
// AutoSendReminders collects all active raffles which have enabled
// reminders which are overdue and posts the reminder for them
func ( d * dbClient ) AutoSendReminders ( ) ( err error ) {
var rr [ ] raffle
2023-11-27 23:09:27 +00:00
if err = helpers . Retry ( func ( ) error {
return d . db . DB ( ) .
Where ( "status = ? AND text_reminder_post = ? AND (text_reminder_next_send IS NULL OR text_reminder_next_send < ?)" , raffleStatusActive , true , time . Now ( ) . UTC ( ) ) .
Find ( & rr ) .
Error
} ) ; err != nil {
2023-07-14 14:15:58 +00:00
return errors . Wrap ( err , "fetching raffles to send reminders" )
}
for _ , r := range rr {
if err = r . SendEvent ( raffleMessageEventReminder , nil ) ; err != nil {
return errors . Wrapf ( err , "sending reminder for raffle %d" , r . ID )
}
}
return nil
}
// AutoStart collects planned and overdue raffles and starts them
func ( d * dbClient ) AutoStart ( ) ( err error ) {
var rr [ ] raffle
2023-11-27 23:09:27 +00:00
if err = helpers . Retry ( func ( ) error {
return d . db . DB ( ) .
Where ( "status = ? AND auto_start_at IS NOT NULL AND auto_start_at < ?" , raffleStatusPlanned , time . Now ( ) . UTC ( ) ) .
Find ( & rr ) .
Error
} ) ; err != nil {
2023-07-14 14:15:58 +00:00
return errors . Wrap ( err , "fetching raffles to start" )
}
for _ , r := range rr {
if err = d . Start ( r . ID ) ; err != nil {
return errors . Wrapf ( err , "starting raffle %d" , r . ID )
}
}
return nil
}
// Clone duplicates a raffle into a new draft resetting some
// parameters into their default state
func ( d * dbClient ) Clone ( raffleID uint64 ) error {
raffle , err := d . Get ( raffleID )
if err != nil {
return errors . Wrap ( err , "getting raffle" )
}
2023-12-09 15:22:00 +00:00
raffle . AutoStartAt = nil
2023-07-14 14:15:58 +00:00
raffle . CloseAt = nil
raffle . Entries = nil
raffle . ID = 0
raffle . Status = raffleStatusPlanned
raffle . Title = strings . Join ( [ ] string { "Copy of" , raffle . Title } , " " )
if err = d . Create ( raffle ) ; err != nil {
return errors . Wrap ( err , "creating copy" )
}
frontendNotify ( frontendNotifyEventRaffleChange )
return nil
}
// Close marks the raffle as closed and removes it from the active
// raffle cache
func ( d * dbClient ) Close ( raffleID uint64 ) error {
r , err := d . Get ( raffleID )
if err != nil {
return errors . Wrap ( err , "getting raffle" )
}
2023-11-27 23:09:27 +00:00
if err = helpers . RetryTransaction ( d . db . DB ( ) , func ( tx * gorm . DB ) error {
return tx . Model ( & raffle { } ) .
Where ( "id = ?" , raffleID ) .
Update ( "status" , raffleStatusEnded ) .
Error
} ) ; err != nil {
2023-07-14 14:15:58 +00:00
return errors . Wrap ( err , "setting status closed" )
}
d . lock . Lock ( )
defer d . lock . Unlock ( )
delete ( d . activeRaffles , strings . Join ( [ ] string { r . Channel , r . Keyword } , "::" ) )
frontendNotify ( frontendNotifyEventRaffleChange )
return errors . Wrap (
r . SendEvent ( raffleMessageEventClose , nil ) ,
"sending close-message" ,
)
}
// Create creates a new raffle. The record will be written to
// the database without modification and therefore need to be filled
// before calling this function
func ( d * dbClient ) Create ( r raffle ) error {
2023-11-27 23:09:27 +00:00
if err := helpers . RetryTransaction ( d . db . DB ( ) , func ( tx * gorm . DB ) error {
return tx . Create ( & r ) . Error
} ) ; err != nil {
2023-07-14 14:15:58 +00:00
return errors . Wrap ( err , "creating database record" )
}
frontendNotify ( frontendNotifyEventRaffleChange )
return nil
}
// Delete removes all entries for the given raffle and afterwards
// deletes the raffle itself
func ( d * dbClient ) Delete ( raffleID uint64 ) ( err error ) {
2023-11-27 23:09:27 +00:00
if err = helpers . RetryTransaction ( d . db . DB ( ) , func ( tx * gorm . DB ) error {
if err = tx .
Where ( "raffle_id = ?" , raffleID ) .
Delete ( & raffleEntry { } ) .
Error ; err != nil {
return errors . Wrap ( err , "deleting raffle entries" )
}
2023-07-14 14:15:58 +00:00
2023-11-27 23:09:27 +00:00
if err = tx .
Where ( "id = ?" , raffleID ) .
Delete ( & raffle { } ) . Error ; err != nil {
return errors . Wrap ( err , "creating database record" )
}
return nil
} ) ; err != nil {
return errors . Wrap ( err , "deleting raffle" )
2023-07-14 14:15:58 +00:00
}
frontendNotify ( frontendNotifyEventRaffleChange )
return nil
}
// Enter creates a new raffle entry. The entry will be written to
// the database without modification and therefore need to be filled
// before calling this function
func ( d * dbClient ) Enter ( re raffleEntry ) error {
2023-11-27 23:09:27 +00:00
if err := helpers . RetryTransaction ( d . db . DB ( ) , func ( tx * gorm . DB ) error { return tx . Create ( & re ) . Error } ) ; err != nil {
2023-07-14 14:15:58 +00:00
return errors . Wrap ( err , "creating database record" )
}
frontendNotify ( frontendNotifyEventRaffleEntryChange )
return nil
}
// Get retrieves a raffle from the database
func ( d * dbClient ) Get ( raffleID uint64 ) ( out raffle , err error ) {
return out , errors . Wrap (
2023-11-27 23:09:27 +00:00
helpers . Retry ( func ( ) error {
return d . db . DB ( ) .
Where ( "raffles.id = ?" , raffleID ) .
Preload ( "Entries" ) .
First ( & out ) .
Error
} ) ,
2023-07-14 14:15:58 +00:00
"getting raffle from database" ,
)
}
// GetByChannelAndKeyword resolves an active raffle through channel
// and keyword given in the raffle and returns it through the Get
// function. If the combination is not known errRaffleNotFound is
// returned.
func ( d * dbClient ) GetByChannelAndKeyword ( channel , keyword string ) ( raffle , error ) {
d . lock . RLock ( )
id := d . activeRaffles [ strings . Join ( [ ] string { channel , keyword } , "::" ) ]
d . lock . RUnlock ( )
if id == 0 {
return raffle { } , errRaffleNotFound
}
return d . Get ( id )
}
// List returns a list of all known raffles
func ( d * dbClient ) List ( ) ( raffles [ ] raffle , _ error ) {
return raffles , errors . Wrap (
2023-11-27 23:09:27 +00:00
helpers . Retry ( func ( ) error {
return d . db . DB ( ) . Model ( & raffle { } ) .
Order ( "id DESC" ) .
Find ( & raffles ) .
Error
} ) ,
2023-07-14 14:15:58 +00:00
"updating column" ,
)
}
// PatchNextReminderSend updates the time another reminder shall be
// sent for the given raffle ID. No other fields are modified
func ( d * dbClient ) PatchNextReminderSend ( raffleID uint64 , next time . Time ) error {
return errors . Wrap (
2023-11-27 23:09:27 +00:00
helpers . RetryTransaction ( d . db . DB ( ) , func ( tx * gorm . DB ) error {
return tx . Model ( & raffle { } ) .
Where ( "id = ?" , raffleID ) .
Update ( "text_reminder_next_send" , next ) .
Error
} ) ,
2023-07-14 14:15:58 +00:00
"updating column" ,
)
}
// PickWinner fetches the given raffle and picks a random winner
// based on entries and their multiplier
func ( d * dbClient ) PickWinner ( raffleID uint64 ) error {
r , err := d . Get ( raffleID )
if err != nil {
return errors . Wrap ( err , "getting raffle" )
}
winner , err := pickWinnerFromRaffle ( r )
if err != nil {
return errors . Wrap ( err , "picking winner" )
}
speakUpUntil := time . Now ( ) . UTC ( ) . Add ( r . WaitForResponse )
2023-11-27 23:09:27 +00:00
if err = helpers . RetryTransaction ( d . db . DB ( ) , func ( tx * gorm . DB ) error {
return tx . Model ( & raffleEntry { } ) .
Where ( "id = ?" , winner . ID ) .
Updates ( map [ string ] any { "was_picked" : true , "speak_up_until" : speakUpUntil } ) .
Error
} ) ; err != nil {
2023-07-14 14:15:58 +00:00
return errors . Wrap ( err , "updating winner" )
}
d . lock . Lock ( )
d . speakUp [ strings . Join ( [ ] string { r . Channel , winner . UserLogin } , ":" ) ] = & speakUpWait { RaffleEntryID : winner . ID , Until : speakUpUntil }
d . lock . Unlock ( )
fields := plugins . FieldCollectionFromData ( map [ string ] any {
"user_id" : winner . UserID ,
"user" : winner . UserLogin ,
"winner" : winner ,
} )
frontendNotify ( frontendNotifyEventRaffleEntryChange )
return errors . Wrap (
r . SendEvent ( raffleMessageEventWin , fields ) ,
"sending win-message" ,
)
}
// RedrawWinner marks the previous winner as redrawn (and therefore
// crossed out as winner in the interface) and picks a new one
func ( d * dbClient ) RedrawWinner ( raffleID , winnerID uint64 ) error {
2023-11-27 23:09:27 +00:00
if err := helpers . RetryTransaction ( d . db . DB ( ) , func ( tx * gorm . DB ) error {
return tx . Model ( & raffleEntry { } ) .
Where ( "id = ?" , winnerID ) .
Update ( "was_redrawn" , true ) .
Error
} ) ; err != nil {
2023-07-14 14:15:58 +00:00
return errors . Wrap ( err , "updating previous winner" )
}
return d . PickWinner ( raffleID )
}
// RefreshActiveRaffles loads all active raffles and populates the
// activeRaffles cache
func ( d * dbClient ) RefreshActiveRaffles ( ) error {
d . lock . Lock ( )
defer d . lock . Unlock ( )
var (
actives [ ] raffle
tmp = map [ string ] uint64 { }
)
2023-11-27 23:09:27 +00:00
if err := helpers . Retry ( func ( ) error {
return d . db . DB ( ) .
Where ( "status = ?" , raffleStatusActive ) .
Find ( & actives ) .
Error
} ) ; err != nil {
2023-07-14 14:15:58 +00:00
return errors . Wrap ( err , "fetching active raffles" )
}
for _ , r := range actives {
tmp [ strings . Join ( [ ] string { r . Channel , r . Keyword } , "::" ) ] = r . ID
}
d . activeRaffles = tmp
return nil
}
// RefreshSpeakUp seeks all still active speak-up entries and
// populates the speak-up cache with them
func ( d * dbClient ) RefreshSpeakUp ( ) error {
d . lock . Lock ( )
defer d . lock . Unlock ( )
var (
res [ ] raffleEntry
tmp = map [ string ] * speakUpWait { }
)
2023-11-27 23:09:27 +00:00
if err := helpers . Retry ( func ( ) error {
return d . db . DB ( ) . Debug ( ) .
Where ( "speak_up_until IS NOT NULL AND speak_up_until > ?" , time . Now ( ) . UTC ( ) ) .
Find ( & res ) .
Error
} ) ; err != nil {
2023-07-14 14:15:58 +00:00
return errors . Wrap ( err , "querying active entries" )
}
for _ , e := range res {
var r raffle
2023-11-27 23:09:27 +00:00
if err := helpers . Retry ( func ( ) error {
return d . db . DB ( ) .
Where ( "id = ?" , e . RaffleID ) .
First ( & r ) .
Error
} ) ; err != nil {
2023-07-14 14:15:58 +00:00
return errors . Wrap ( err , "fetching raffle for entry" )
}
tmp [ strings . Join ( [ ] string { r . Channel , e . UserLogin } , ":" ) ] = & speakUpWait { RaffleEntryID : e . ID , Until : * e . SpeakUpUntil }
}
d . speakUp = tmp
return nil
}
// RegisterSpeakUp sets the speak-up message if there was a
// speakUpWait for that user and channel
func ( d * dbClient ) RegisterSpeakUp ( channel , user , message string ) error {
d . lock . RLock ( )
w := d . speakUp [ strings . Join ( [ ] string { channel , user } , ":" ) ]
d . lock . RUnlock ( )
if w == nil || w . Until . Before ( time . Now ( ) ) {
// No speak-up-request for that user or expired
return nil
}
2023-11-27 23:09:27 +00:00
if err := helpers . RetryTransaction ( d . db . DB ( ) , func ( tx * gorm . DB ) error {
return tx . Model ( & raffleEntry { } ) .
Where ( "id = ?" , w . RaffleEntryID ) .
Updates ( map [ string ] any {
"DrawResponse" : message ,
"SpeakUpUntil" : nil ,
} ) .
Error
} ) ; err != nil {
2023-07-14 14:15:58 +00:00
return errors . Wrap ( err , "registering speak-up" )
}
d . lock . Lock ( )
defer d . lock . Unlock ( )
delete ( d . speakUp , strings . Join ( [ ] string { channel , user } , ":" ) )
frontendNotify ( frontendNotifyEventRaffleEntryChange )
return nil
}
// Reopen updates the CloseAt attribute and status to active to
// prolong the raffle
func ( d * dbClient ) Reopen ( raffleID uint64 , duration time . Duration ) error {
r , err := d . Get ( raffleID )
if err != nil {
return errors . Wrap ( err , "getting specified raffle" )
}
2023-11-27 23:09:27 +00:00
if err = helpers . RetryTransaction ( d . db . DB ( ) , func ( tx * gorm . DB ) error {
return tx . Model ( & raffle { } ) .
Where ( "id = ?" , raffleID ) .
Updates ( map [ string ] any {
"CloseAt" : time . Now ( ) . UTC ( ) . Add ( duration ) ,
"status" : raffleStatusActive ,
} ) .
Error
} ) ; err != nil {
2023-07-14 14:15:58 +00:00
return errors . Wrap ( err , "updating raffle" )
}
// Store ID to active-raffle cache
d . lock . Lock ( )
defer d . lock . Unlock ( )
d . activeRaffles [ strings . Join ( [ ] string { r . Channel , r . Keyword } , "::" ) ] = r . ID
frontendNotify ( frontendNotifyEventRaffleChange )
return nil
}
2023-12-09 15:22:00 +00:00
// Reset resets the raffle participants, the status, start / close
// times while preserving the rest of the settings
func ( d * dbClient ) Reset ( raffleID uint64 ) error {
raffle , err := d . Get ( raffleID )
if err != nil {
return errors . Wrap ( err , "getting raffle" )
}
raffle . AutoStartAt = nil
raffle . CloseAt = nil
raffle . Entries = nil
raffle . Status = raffleStatusPlanned
if err = helpers . RetryTransaction ( d . db . DB ( ) , func ( tx * gorm . DB ) error {
if err = tx . Delete ( & raffleEntry { } , "raffle_id = ?" , raffleID ) . Error ; err != nil {
return errors . Wrap ( err , "deleting raffle entries" )
}
return tx . Save ( raffle ) . Error
} ) ; err != nil {
return errors . Wrap ( err , "saving cleaned raffle" )
}
frontendNotify ( frontendNotifyEventRaffleChange )
return nil
}
2023-07-14 14:15:58 +00:00
// Start fetches the given raffle, updates its CloseAt attribute
// in case it is not already set, sets the raffle to active, updates
// the raffle in the database and notes its channel/keyword combo
// into the activeRaffles cache for use with irc handling
func ( d * dbClient ) Start ( raffleID uint64 ) error {
r , err := d . Get ( raffleID )
if err != nil {
return errors . Wrap ( err , "getting specified raffle" )
}
if r . CloseAt == nil {
end := time . Now ( ) . UTC ( ) . Add ( r . CloseAfter )
r . CloseAt = & end
}
r . Status = raffleStatusActive
if err = d . Update ( r ) ; err != nil {
return errors . Wrap ( err , "updating raffle" )
}
// Store ID to active-raffle cache
d . lock . Lock ( )
defer d . lock . Unlock ( )
d . activeRaffles [ strings . Join ( [ ] string { r . Channel , r . Keyword } , "::" ) ] = r . ID
frontendNotify ( frontendNotifyEventRaffleChange )
return errors . Wrap (
r . SendEvent ( raffleMessageEventReminder , nil ) ,
"sending first reminder" ,
)
}
// Update stores the given raffle to the database. The ID within the
// raffle object must be set in order to update it. The object must
// be completely filled.
func ( d * dbClient ) Update ( r raffle ) error {
old , err := d . Get ( r . ID )
if err != nil {
return errors . Wrap ( err , "getting previous version" )
}
// These information must not be changed after raffle has been started
if old . Status != raffleStatusPlanned {
r . Channel = old . Channel
r . Keyword = old . Keyword
r . Status = old . Status
r . AllowEveryone = old . AllowEveryone
r . AllowFollower = old . AllowFollower
r . AllowSubscriber = old . AllowSubscriber
r . AllowVIP = old . AllowVIP
r . MinFollowAge = old . MinFollowAge
r . MultiFollower = old . MultiFollower
r . MultiSubscriber = old . MultiSubscriber
r . MultiVIP = old . MultiVIP
r . AutoStartAt = old . AutoStartAt
}
// This info must be preserved
r . Entries = nil
r . TextReminderNextSend = old . TextReminderNextSend
2023-11-27 23:09:27 +00:00
if err := helpers . RetryTransaction ( d . db . DB ( ) , func ( tx * gorm . DB ) error {
return tx . Model ( & raffle { } ) .
Where ( "id = ?" , r . ID ) .
Updates ( & r ) .
Error
} ) ; err != nil {
2023-07-14 14:15:58 +00:00
return errors . Wrap ( err , "updating raffle" )
}
frontendNotify ( frontendNotifyEventRaffleChange )
return nil
}
// SendEvent processes the text template and sends the message if
// enabled given through the event
func ( r raffle ) SendEvent ( evt raffleMessageEvent , fields * plugins . FieldCollection ) ( err error ) {
if fields == nil {
fields = plugins . NewFieldCollection ( )
}
fields . Set ( "raffle" , r ) // Make raffle available to templating
var sendTextTpl string
switch evt {
case raffleMessageEventClose :
if ! r . TextClosePost {
return nil
}
sendTextTpl = r . TextClose
case raffleMessageEventEntryFailed :
if ! r . TextEntryFailPost {
return nil
}
sendTextTpl = r . TextEntryFail
case raffleMessageEventEntry :
if ! r . TextEntryPost {
return nil
}
sendTextTpl = r . TextEntry
case raffleMessageEventReminder :
if ! r . TextReminderPost {
return nil
}
sendTextTpl = r . TextReminder
if err = dbc . PatchNextReminderSend ( r . ID , time . Now ( ) . UTC ( ) . Add ( r . TextReminderInterval ) ) ; err != nil {
return errors . Wrap ( err , "updating next reminder for raffle" )
}
case raffleMessageEventWin :
if ! r . TextWinPost {
return nil
}
sendTextTpl = r . TextWin
default :
// How?
return errors . New ( "unexpected event" )
}
msg , err := formatMessage ( sendTextTpl , nil , nil , fields )
if err != nil {
return errors . Wrap ( err , "formatting message to send" )
}
return errors . Wrap (
send ( & irc . Message {
Command : "PRIVMSG" ,
Params : [ ] string {
"#" + strings . TrimLeft ( r . Channel , "#" ) ,
msg ,
} ,
} ) ,
"sending message" ,
)
}