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  }