github.com/Azareal/Gosora@v0.0.0-20210729070923-553e66b59003/common/alerts.go (about) 1 /* 2 * 3 * Gosora Alerts System 4 * Copyright Azareal 2017 - 2020 5 * 6 */ 7 package common 8 9 import ( 10 "database/sql" 11 "errors" 12 "strconv" 13 "strings" 14 "time" 15 16 //"fmt" 17 18 "github.com/Azareal/Gosora/common/phrases" 19 qgen "github.com/Azareal/Gosora/query_gen" 20 ) 21 22 type Alert struct { 23 ASID int 24 ActorID int 25 TargetUserID int 26 Event string 27 ElementType string 28 ElementID int 29 CreatedAt time.Time 30 Extra string 31 32 Actor *User 33 } 34 35 type AlertStmts struct { 36 notifyWatchers *sql.Stmt 37 getWatchers *sql.Stmt 38 } 39 40 var alertStmts AlertStmts 41 42 // TODO: Move these statements into some sort of activity abstraction 43 // TODO: Rewrite the alerts logic 44 func init() { 45 DbInits.Add(func(acc *qgen.Accumulator) error { 46 alertStmts = AlertStmts{ 47 notifyWatchers: acc.SimpleInsertInnerJoin( 48 qgen.DBInsert{"activity_stream_matches", "watcher,asid", ""}, 49 qgen.DBJoin{"activity_stream", "activity_subscriptions", "activity_subscriptions.user, activity_stream.asid", "activity_subscriptions.targetType = activity_stream.elementType AND activity_subscriptions.targetID = activity_stream.elementID AND activity_subscriptions.user != activity_stream.actor", "asid=?", "", ""}, 50 ), 51 getWatchers: acc.SimpleInnerJoin("activity_stream", "activity_subscriptions", "activity_subscriptions.user", "activity_subscriptions.targetType = activity_stream.elementType AND activity_subscriptions.targetID = activity_stream.elementID AND activity_subscriptions.user != activity_stream.actor", "asid=?", "", ""), 52 } 53 return acc.FirstError() 54 }) 55 } 56 57 const AlertsGrowHint = len(`{"msgs":[],"count":,"tc":}`) + 1 + 10 58 59 // TODO: See if we can json.Marshal instead? 60 func escapeTextInJson(in string) string { 61 in = strings.Replace(in, "\"", "\\\"", -1) 62 return strings.Replace(in, "/", "\\/", -1) 63 } 64 65 func BuildAlert(a Alert, user User /* The current user */) (out string, err error) { 66 var targetUser *User 67 if a.Actor == nil { 68 a.Actor, err = Users.Get(a.ActorID) 69 if err != nil { 70 return "", errors.New(phrases.GetErrorPhrase("alerts_no_actor")) 71 } 72 } 73 74 /*if a.ElementType != "forum" { 75 targetUser, err = users.Get(a.TargetUserID) 76 if err != nil { 77 LocalErrorJS("Unable to find the target user",w,r) 78 return 79 } 80 }*/ 81 if a.Event == "friend_invite" { 82 return buildAlertString(".new_friend_invite", []string{a.Actor.Name}, a.Actor.Link, a.Actor.Avatar, a.ASID), nil 83 } 84 85 // Not that many events for us to handle in a forum 86 if a.ElementType == "forum" { 87 if a.Event == "reply" { 88 topic, err := Topics.Get(a.ElementID) 89 if err != nil { 90 DebugLogf("Unable to find linked topic %d", a.ElementID) 91 return "", errors.New(phrases.GetErrorPhrase("alerts_no_linked_topic")) 92 } 93 // Store the forum ID in the targetUser column instead of making a new one? o.O 94 // Add an additional column for extra information later on when we add the ability to link directly to posts. We don't need the forum data for now... 95 return buildAlertString(".forum_new_topic", []string{a.Actor.Name, topic.Title}, topic.Link, a.Actor.Avatar, a.ASID), nil 96 } 97 return buildAlertString(".forum_unknown_action", []string{a.Actor.Name}, "", a.Actor.Avatar, a.ASID), nil 98 } 99 100 var url, area, phraseName string 101 own := false 102 // TODO: Avoid loading a bit of data twice 103 switch a.ElementType { 104 case "convo": 105 convo, err := Convos.Get(a.ElementID) 106 if err != nil { 107 DebugLogf("Unable to find linked convo %d", a.ElementID) 108 return "", errors.New(phrases.GetErrorPhrase("alerts_no_linked_convo")) 109 } 110 url = convo.Link 111 case "topic": 112 topic, err := Topics.Get(a.ElementID) 113 if err != nil { 114 DebugLogf("Unable to find linked topic %d", a.ElementID) 115 return "", errors.New(phrases.GetErrorPhrase("alerts_no_linked_topic")) 116 } 117 url = topic.Link 118 area = topic.Title 119 own = a.TargetUserID == user.ID 120 case "user": 121 targetUser, err = Users.Get(a.ElementID) 122 if err != nil { 123 DebugLogf("Unable to find target user %d", a.ElementID) 124 return "", errors.New(phrases.GetErrorPhrase("alerts_no_target_user")) 125 } 126 area = targetUser.Name 127 url = targetUser.Link 128 own = a.TargetUserID == user.ID 129 case "post": 130 topic, err := TopicByReplyID(a.ElementID) 131 if err != nil { 132 DebugLogf("Unable to find linked topic by reply ID %d", a.ElementID) 133 return "", errors.New(phrases.GetErrorPhrase("alerts_no_linked_topic_by_reply")) 134 } 135 url = topic.Link 136 area = topic.Title 137 own = a.TargetUserID == user.ID 138 default: 139 return "", errors.New(phrases.GetErrorPhrase("alerts_invalid_elementtype")) 140 } 141 142 badEv := false 143 switch a.Event { 144 case "create", "like", "mention", "reply": 145 // skip 146 default: 147 badEv = true 148 } 149 150 if own && !badEv { 151 phraseName = "." + a.ElementType + "_own_" + a.Event 152 } else if !badEv { 153 phraseName = "." + a.ElementType + "_" + a.Event 154 } else if own { 155 phraseName = "." + a.ElementType + "_own" 156 } else { 157 phraseName = "." + a.ElementType 158 } 159 160 return buildAlertString(phraseName, []string{a.Actor.Name, area}, url, a.Actor.Avatar, a.ASID), nil 161 } 162 163 func buildAlertString(msg string, sub []string, path, avatar string, asid int) string { 164 var sb strings.Builder 165 buildAlertSb(&sb, msg, sub, path, avatar, asid) 166 return sb.String() 167 } 168 169 const AlertsGrowHint2 = len(`{"msg":"","sub":[],"path":"","img":"","id":}`) + 5 + 3 + 1 + 1 + 1 170 171 // TODO: Use a string builder? 172 func buildAlertSb(sb *strings.Builder, msg string, sub []string, path, avatar string, asid int) { 173 sb.WriteString(`{"msg":"`) 174 sb.WriteString(escapeTextInJson(msg)) 175 sb.WriteString(`","sub":[`) 176 for i, it := range sub { 177 if i != 0 { 178 sb.WriteString(",\"") 179 } else { 180 sb.WriteString("\"") 181 } 182 sb.WriteString(escapeTextInJson(it)) 183 sb.WriteString("\"") 184 } 185 sb.WriteString(`],"path":"`) 186 sb.WriteString(escapeTextInJson(path)) 187 sb.WriteString(`","img":"`) 188 sb.WriteString(escapeTextInJson(avatar)) 189 sb.WriteString(`","id":`) 190 sb.WriteString(strconv.Itoa(asid)) 191 sb.WriteRune('}') 192 } 193 194 func BuildAlertSb(sb *strings.Builder, a *Alert, u *User /* The current user */) (err error) { 195 var targetUser *User 196 if a.Actor == nil { 197 a.Actor, err = Users.Get(a.ActorID) 198 if err != nil { 199 return errors.New(phrases.GetErrorPhrase("alerts_no_actor")) 200 } 201 } 202 203 /*if a.ElementType != "forum" { 204 targetUser, err = users.Get(a.TargetUserID) 205 if err != nil { 206 LocalErrorJS("Unable to find the target user",w,r) 207 return 208 } 209 }*/ 210 if a.Event == "friend_invite" { 211 buildAlertSb(sb, ".new_friend_invite", []string{a.Actor.Name}, a.Actor.Link, a.Actor.Avatar, a.ASID) 212 return nil 213 } 214 215 // Not that many events for us to handle in a forum 216 if a.ElementType == "forum" { 217 if a.Event == "reply" { 218 topic, err := Topics.Get(a.ElementID) 219 if err != nil { 220 DebugLogf("Unable to find linked topic %d", a.ElementID) 221 return errors.New(phrases.GetErrorPhrase("alerts_no_linked_topic")) 222 } 223 // Store the forum ID in the targetUser column instead of making a new one? o.O 224 // Add an additional column for extra information later on when we add the ability to link directly to posts. We don't need the forum data for now... 225 buildAlertSb(sb, ".forum_new_topic", []string{a.Actor.Name, topic.Title}, topic.Link, a.Actor.Avatar, a.ASID) 226 return nil 227 } 228 buildAlertSb(sb, ".forum_unknown_action", []string{a.Actor.Name}, "", a.Actor.Avatar, a.ASID) 229 return nil 230 } 231 232 var url, area string 233 own := false 234 // TODO: Avoid loading a bit of data twice 235 switch a.ElementType { 236 case "convo": 237 convo, err := Convos.Get(a.ElementID) 238 if err != nil { 239 DebugLogf("Unable to find linked convo %d", a.ElementID) 240 return errors.New(phrases.GetErrorPhrase("alerts_no_linked_convo")) 241 } 242 url = convo.Link 243 case "topic": 244 topic, err := Topics.Get(a.ElementID) 245 if err != nil { 246 DebugLogf("Unable to find linked topic %d", a.ElementID) 247 return errors.New(phrases.GetErrorPhrase("alerts_no_linked_topic")) 248 } 249 url = topic.Link 250 area = topic.Title 251 own = a.TargetUserID == u.ID 252 case "user": 253 targetUser, err = Users.Get(a.ElementID) 254 if err != nil { 255 DebugLogf("Unable to find target user %d", a.ElementID) 256 return errors.New(phrases.GetErrorPhrase("alerts_no_target_user")) 257 } 258 area = targetUser.Name 259 url = targetUser.Link 260 own = a.TargetUserID == u.ID 261 case "post": 262 t, err := TopicByReplyID(a.ElementID) 263 if err != nil { 264 DebugLogf("Unable to find linked topic by reply ID %d", a.ElementID) 265 return errors.New(phrases.GetErrorPhrase("alerts_no_linked_topic_by_reply")) 266 } 267 url = t.Link 268 area = t.Title 269 own = a.TargetUserID == u.ID 270 default: 271 return errors.New(phrases.GetErrorPhrase("alerts_invalid_elementtype")) 272 } 273 274 sb.WriteString(`{"msg":".`) 275 sb.WriteString(a.ElementType) 276 if own { 277 sb.WriteString("_own_") 278 } else { 279 sb.WriteRune('_') 280 } 281 switch a.Event { 282 case "create", "like", "mention", "reply": 283 sb.WriteString(a.Event) 284 } 285 286 sb.WriteString(`","sub":["`) 287 sb.WriteString(escapeTextInJson(a.Actor.Name)) 288 sb.WriteString("\",\"") 289 sb.WriteString(escapeTextInJson(area)) 290 sb.WriteString(`"],"path":"`) 291 sb.WriteString(escapeTextInJson(url)) 292 sb.WriteString(`","img":"`) 293 sb.WriteString(escapeTextInJson(a.Actor.Avatar)) 294 sb.WriteString(`","id":`) 295 sb.WriteString(strconv.Itoa(a.ASID)) 296 sb.WriteRune('}') 297 298 return nil 299 } 300 301 //const AlertsGrowHint3 = len(`{"msg":"._","sub":["",""],"path":"","img":"","id":}`) + 3 + 2 + 2 + 2 + 2 + 1 302 303 // TODO: Create a notifier structure? 304 func AddActivityAndNotifyAll(a Alert) error { 305 id, err := Activity.Add(a) 306 if err != nil { 307 return err 308 } 309 return NotifyWatchers(id) 310 } 311 312 // TODO: Create a notifier structure? 313 func AddActivityAndNotifyTarget(a Alert) error { 314 id, err := Activity.Add(a) 315 if err != nil { 316 return err 317 } 318 319 err = ActivityMatches.Add(a.TargetUserID, id) 320 if err != nil { 321 return err 322 } 323 a.ASID = id 324 325 // Live alerts, if the target is online and WebSockets is enabled 326 if EnableWebsockets { 327 go func() { 328 defer EatPanics() 329 _ = WsHub.pushAlert(a.TargetUserID, a) 330 //fmt.Println("err:",err) 331 }() 332 } 333 return nil 334 } 335 336 // TODO: Create a notifier structure? 337 func NotifyWatchers(asid int) error { 338 _, err := alertStmts.notifyWatchers.Exec(asid) 339 if err != nil { 340 return err 341 } 342 343 // Alert the subscribers about this without blocking us from doing something else 344 if EnableWebsockets { 345 go func() { 346 defer EatPanics() 347 notifyWatchers(asid) 348 }() 349 } 350 return nil 351 } 352 353 func notifyWatchers(asid int) { 354 rows, e := alertStmts.getWatchers.Query(asid) 355 if e != nil && e != ErrNoRows { 356 LogError(e) 357 return 358 } 359 defer rows.Close() 360 361 var uid int 362 var uids []int 363 for rows.Next() { 364 if e := rows.Scan(&uid); e != nil { 365 LogError(e) 366 return 367 } 368 uids = append(uids, uid) 369 } 370 if e = rows.Err(); e != nil { 371 LogError(e) 372 return 373 } 374 375 alert, e := Activity.Get(asid) 376 if e != nil && e != ErrNoRows { 377 LogError(e) 378 return 379 } 380 _ = WsHub.pushAlerts(uids, alert) 381 } 382 383 func DismissAlert(uid, aid int) { 384 _ = WsHub.PushMessage(uid, `{"event":"dismiss-alert","id":`+strconv.Itoa(aid)+`}`) 385 }