feat: add redis cache and fanout support

This commit is contained in:
richjohnrambo
2026-08-25 20:54:03 +08:00
parent 07376c70e3
commit f900a08a01
68 changed files with 3949 additions and 5696 deletions
+2 -2
View File
@@ -86,7 +86,7 @@ type videoCall struct {
// Call participants (session sid -> callPartyData).
parties map[string]callPartyData
// Call message seq ID.
seq int
seq int64
// Call message content.
content any
// Call message content mime type.
@@ -300,7 +300,7 @@ func (call *videoCall) messageHead(head map[string]any, newState string, duratio
head = map[string]any{}
}
head["replace"] = ":" + strconv.Itoa(call.seq)
head["replace"] = ":" + strconv.FormatInt(call.seq, 10)
head["webrtc"] = newState
if duration > 0 {
+23 -23
View File
@@ -27,9 +27,9 @@ type MsgGetOpts struct {
// Return results modified since this timespamp.
IfModifiedSince *time.Time `json:"ims,omitempty"`
// Load messages/ranges with IDs equal or greater than this (inclusive or closed).
SinceId int `json:"since,omitempty"`
SinceId int64 `json:"since,omitempty"`
// Load messages/ranges with IDs lower than this (exclusive or open).
BeforeId int `json:"before,omitempty"`
BeforeId int64 `json:"before,omitempty"`
// Limit the number of messages loaded.
Limit int `json:"limit,omitempty"`
// Fetch messages with IDs in these ranges.
@@ -100,8 +100,8 @@ type MsgSetQuery struct {
// MsgRange is either an individual ID (HiId=0) or a randge of IDs, low end inclusive (closed),
// high-end exclusive (open): [LowId .. HiId), e.g. 1..5 -> 1, 2, 3, 4.
type MsgRange struct {
LowId int `json:"low,omitempty"`
HiId int `json:"hi,omitempty"`
LowId int64 `json:"low,omitempty"`
HiId int64 `json:"hi,omitempty"`
}
/****************************************************************
@@ -321,7 +321,7 @@ type MsgClientNote struct {
// what is being reported: "recv" - message received, "read" - message read, "kp" - typing notification
What string `json:"what"`
// Server-issued message ID being reported
SeqId int `json:"seq,omitempty"`
SeqId int64 `json:"seq,omitempty"`
// Client's count of unread messages to report back to the server. Used in push notifications on iOS.
Unread int `json:"unread,omitempty"`
// Call event.
@@ -453,9 +453,9 @@ type MsgTopicDesc struct {
// Actual access mode
Acs *MsgAccessMode `json:"acs,omitempty"`
// Max message ID
SeqId int `json:"seq,omitempty"`
ReadSeqId int `json:"read,omitempty"`
RecvSeqId int `json:"recv,omitempty"`
SeqId int64 `json:"seq,omitempty"`
ReadSeqId int64 `json:"read,omitempty"`
RecvSeqId int64 `json:"recv,omitempty"`
// Id of the last delete operation as seen by the requesting user
DelId int `json:"clear,omitempty"`
SubCnt int `json:"subcnt,omitempty"`
@@ -475,13 +475,13 @@ func (src *MsgTopicDesc) describe() string {
s += " acs={" + src.Acs.describe() + "}"
}
if src.SeqId != 0 {
s += " seq=" + strconv.Itoa(src.SeqId)
s += " seq=" + strconv.FormatInt(src.SeqId, 10)
}
if src.ReadSeqId != 0 {
s += " read=" + strconv.Itoa(src.ReadSeqId)
s += " read=" + strconv.FormatInt(src.ReadSeqId, 10)
}
if src.RecvSeqId != 0 {
s += " recv=" + strconv.Itoa(src.RecvSeqId)
s += " recv=" + strconv.FormatInt(src.RecvSeqId, 10)
}
if src.DelId != 0 {
s += " clear=" + strconv.Itoa(src.DelId)
@@ -517,9 +517,9 @@ type MsgTopicSub struct {
// Acs.Mode = want & given. The field is not a pointer because at least one value is always assigned.
Acs MsgAccessMode `json:"acs,omitempty"`
// ID of the message reported by the given user as read
ReadSeqId int `json:"read,omitempty"`
ReadSeqId int64 `json:"read,omitempty"`
// ID of the message reported by the given user as received
RecvSeqId int `json:"recv,omitempty"`
RecvSeqId int64 `json:"recv,omitempty"`
// Topic's public data
Public any `json:"public,omitempty"`
// Topic's trusted public data
@@ -540,7 +540,7 @@ type MsgTopicSub struct {
// Timestamp of the last message in the topic.
TouchedAt *time.Time `json:"touched,omitempty"`
// ID of the last {data} message in a topic
SeqId int `json:"seq,omitempty"`
SeqId int64 `json:"seq,omitempty"`
// Id of the latest Delete operation
DelId int `json:"clear,omitempty"`
// Number of subscribers, group topics only.
@@ -555,13 +555,13 @@ func (src *MsgTopicSub) describe() string {
s := src.Topic + ":" + src.User + " online=" + strconv.FormatBool(src.Online) + " acs=" + src.Acs.describe()
if src.SeqId != 0 {
s += " seq=" + strconv.Itoa(src.SeqId)
s += " seq=" + strconv.FormatInt(src.SeqId, 10)
}
if src.ReadSeqId != 0 {
s += " read=" + strconv.Itoa(src.ReadSeqId)
s += " read=" + strconv.FormatInt(src.ReadSeqId, 10)
}
if src.RecvSeqId != 0 {
s += " recv=" + strconv.Itoa(src.RecvSeqId)
s += " recv=" + strconv.FormatInt(src.RecvSeqId, 10)
}
if src.DelId != 0 {
s += " clear=" + strconv.Itoa(src.DelId)
@@ -621,7 +621,7 @@ type MsgServerData struct {
From string `json:"from,omitempty"`
Timestamp time.Time `json:"ts"`
DeletedAt *time.Time `json:"deleted,omitempty"`
SeqId int `json:"seq"`
SeqId int64 `json:"seq"`
Head map[string]any `json:"head,omitempty"`
Content any `json:"content"`
}
@@ -636,7 +636,7 @@ func (src *MsgServerData) copy() *MsgServerData {
}
func (src *MsgServerData) describe() string {
s := src.Topic + " from=" + src.From + " seq=" + strconv.Itoa(src.SeqId)
s := src.Topic + " from=" + src.From + " seq=" + strconv.FormatInt(src.SeqId, 10)
if src.DeletedAt != nil {
s += " deleted"
} else {
@@ -654,7 +654,7 @@ type MsgServerPres struct {
Src string `json:"src,omitempty"`
What string `json:"what"`
UserAgent string `json:"ua,omitempty"`
SeqId int `json:"seq,omitempty"`
SeqId int64 `json:"seq,omitempty"`
DelId int `json:"clear,omitempty"`
DelSeq []MsgRange `json:"delseq,omitempty"`
AcsTarget string `json:"tgt,omitempty"`
@@ -706,7 +706,7 @@ func (src *MsgServerPres) describe() string {
s += " ua=" + src.UserAgent
}
if src.SeqId != 0 {
s += " seq=" + strconv.Itoa(src.SeqId)
s += " seq=" + strconv.FormatInt(src.SeqId, 10)
}
if src.DelId != 0 {
s += " clear=" + strconv.Itoa(src.DelId)
@@ -799,7 +799,7 @@ type MsgServerInfo struct {
// The event being reported: "rcpt" - message received, "read" - message read, "kp" - typing notification, "call" - video call.
What string `json:"what"`
// Server-issued message ID being reported.
SeqId int `json:"seq,omitempty"`
SeqId int64 `json:"seq,omitempty"`
// Call event.
Event string `json:"event,omitempty"`
// Arbitrary json payload (used by video calls).
@@ -829,7 +829,7 @@ func (src *MsgServerInfo) describe() string {
}
s += " what=" + src.What + " from=" + src.From
if src.SeqId > 0 {
s += " seq=" + strconv.Itoa(src.SeqId)
s += " seq=" + strconv.FormatInt(src.SeqId, 10)
}
if len(src.Payload) > 0 {
s += " payload=<..." + strconv.Itoa(len(src.Payload)) + " bytes ...>"
+12 -5
View File
@@ -326,13 +326,20 @@ type TenantBusinessAdapter interface {
TenantMessageSave(t.TenantID, *t.Message) error
TenantMessageGetByClientID(t.TenantID, t.Uid, string) (*t.Message, error)
TenantMessageGetOne(t.TenantID, string, int) (*t.Message, error)
TenantMessageRecall(t.TenantID, string, int, t.Uid, int, time.Time) (*t.Message, error)
TenantMessageEdit(t.TenantID, string, int, t.Uid, int, t.KVMap, any, string, time.Time) (*t.Message, error)
TenantMessageGetOne(t.TenantID, string, int64) (*t.Message, error)
TenantMessageRecall(t.TenantID, string, int64, t.Uid, int, time.Time) (*t.Message, error)
TenantMessageEdit(t.TenantID, string, int64, t.Uid, int, t.KVMap, any, string, time.Time) (*t.Message, error)
TenantMessageGetAll(t.TenantID, string, t.Uid, *t.QueryOpt) ([]t.Message, error)
TenantMessageDeleteList(t.TenantID, string, *t.DelMessage) error
TenantMessageGetDeleted(t.TenantID, string, t.Uid, *t.QueryOpt) ([]t.DelMessage, error)
TenantMessageSearch(t.TenantID, string, t.Uid, string, string, string, int) ([]t.Message, string, error)
TenantGroupMessageSave(t.TenantID, *t.Message) error
TenantGroupMessageGetByClientID(t.TenantID, string, t.Uid, string) (*t.Message, error)
TenantGroupMessageGetOne(t.TenantID, string, int64) (*t.Message, error)
TenantGroupMessageRecall(t.TenantID, string, int64, t.Uid, int, time.Time) (*t.Message, error)
TenantGroupMessageEdit(t.TenantID, string, int64, t.Uid, int, t.KVMap, any, string, time.Time) (*t.Message, error)
TenantGroupMessageGetAll(t.TenantID, string, t.Uid, *t.QueryOpt) ([]t.Message, error)
TenantGroupMessageDeleteList(t.TenantID, string, *t.DelMessage) error
TenantConversationSettingsGet(t.TenantID, t.Uid, string) (*t.ConversationSettings, error)
TenantConversationSettingsUpdate(t.TenantID, t.Uid, string, t.ConversationSettingsPatch) (*t.ConversationSettings, error)
@@ -340,8 +347,8 @@ type TenantBusinessAdapter interface {
TenantTopicDraftPut(t.TenantID, t.Uid, string, string, []byte, int) (*t.TopicDraft, error)
TenantCallCreate(t.TenantID, *t.CallRecord) error
TenantCallAccept(t.TenantID, string, int, time.Time) error
TenantCallFinish(t.TenantID, string, int, string, int64, time.Time) error
TenantCallAccept(t.TenantID, string, int64, time.Time) error
TenantCallFinish(t.TenantID, string, int64, string, int64, time.Time) error
TenantCallList(t.TenantID, t.Uid, string, string, int) ([]t.CallRecord, string, error)
TenantGroupSettingsCreate(t.TenantID, *t.GroupSettings) (*t.GroupSettings, error)
+3 -3
View File
@@ -126,7 +126,7 @@ func TestRangesToSql(t *testing.T) {
ranges := []types.Range{{Low: 5, Hi: 0}}
sql, args := RangesToSql(ranges)
expectedSql := "IN (?)"
expectedArgs := []any{5}
expectedArgs := []any{int64(5)}
if sql != expectedSql {
t.Errorf("Expected SQL '%s', got '%s'", expectedSql, sql)
}
@@ -138,7 +138,7 @@ func TestRangesToSql(t *testing.T) {
ranges = []types.Range{{Low: 5, Hi: 8}}
sql, args = RangesToSql(ranges)
expectedSql = "BETWEEN ? AND ?"
expectedArgs = []any{5, 7} // Hi-1 for BETWEEN
expectedArgs = []any{int64(5), int64(7)} // Hi-1 for BETWEEN
if sql != expectedSql {
t.Errorf("Expected SQL '%s', got '%s'", expectedSql, sql)
}
@@ -150,7 +150,7 @@ func TestRangesToSql(t *testing.T) {
ranges = []types.Range{{Low: 1, Hi: 3}, {Low: 5, Hi: 0}, {Low: 8, Hi: 10}}
sql, args = RangesToSql(ranges)
expectedSql = "IN (?,?,?,?,?)"
expectedArgs = []any{1, 2, 5, 8, 9}
expectedArgs = []any{int64(1), int64(2), int64(5), int64(8), int64(9)}
if sql != expectedSql {
t.Errorf("Expected SQL '%s', got '%s'", expectedSql, sql)
}
+9 -9
View File
@@ -754,7 +754,7 @@ func (a *adapter) CreateDb(reset bool) error {
usebt TINYINT DEFAULT 0,
owner BIGINT NOT NULL DEFAULT 0,
access JSON,
seqid INT NOT NULL DEFAULT 0,
seqid BIGINT NOT NULL DEFAULT 0,
delid INT DEFAULT 0,
subcnt INT DEFAULT 0,
public JSON,
@@ -799,8 +799,8 @@ func (a *adapter) CreateDb(reset bool) error {
userid BIGINT NOT NULL,
topic CHAR(25) NOT NULL,
delid INT DEFAULT 0,
recvseqid INT DEFAULT 0,
readseqid INT DEFAULT 0,
recvseqid BIGINT DEFAULT 0,
readseqid BIGINT DEFAULT 0,
modewant CHAR(8),
modegiven CHAR(8),
private JSON,
@@ -822,7 +822,7 @@ func (a *adapter) CreateDb(reset bool) error {
updatedat DATETIME(3) NOT NULL,
deletedat DATETIME(3),
delid INT DEFAULT 0,
seqid INT NOT NULL,
seqid BIGINT NOT NULL,
topic CHAR(25) NOT NULL,` +
"`from` BIGINT NOT NULL," +
`client_msg_id VARCHAR(64),
@@ -845,7 +845,7 @@ func (a *adapter) CreateDb(reset bool) error {
createdat DATETIME(3) NOT NULL,
updatedat DATETIME(3) NOT NULL,
topic CHAR(25) NOT NULL,
seqid INT NOT NULL,
seqid BIGINT NOT NULL,
version INT NOT NULL,
actor BIGINT NOT NULL,
head JSON,
@@ -1050,7 +1050,7 @@ func createIMCallTables(tx *sql.Tx) error {
id BIGINT NOT NULL AUTO_INCREMENT,
call_id VARCHAR(64) NOT NULL,
topic CHAR(25) NOT NULL,
seq_id INT NOT NULL,
seq_id BIGINT NOT NULL,
caller_id BIGINT NOT NULL,
callee_id BIGINT NOT NULL,
call_type VARCHAR(16) NOT NULL,
@@ -1403,7 +1403,7 @@ func createIMUserProductTables(tx *sql.Tx) error {
target_type VARCHAR(16) NOT NULL,
target_user_id BIGINT NOT NULL DEFAULT 0,
target_topic VARCHAR(32) NOT NULL DEFAULT '',
target_seq INT NOT NULL DEFAULT 0,
target_seq BIGINT NOT NULL DEFAULT 0,
reason_code VARCHAR(64) NOT NULL,
description TEXT NOT NULL,
evidence_file_ids JSON,
@@ -1481,7 +1481,7 @@ func upgradeIMMessageActions(a *adapter) error {
createdat DATETIME(3) NOT NULL,
updatedat DATETIME(3) NOT NULL,
topic CHAR(25) NOT NULL,
seqid INT NOT NULL,
seqid BIGINT NOT NULL,
version INT NOT NULL,
actor BIGINT NOT NULL,
head JSON,
@@ -1554,7 +1554,7 @@ func createIMRelationTables(exec sqlExecutor) error {
user_id BIGINT NOT NULL,
item_type VARCHAR(16) NOT NULL,
source_topic CHAR(25) NOT NULL,
source_seq INT NOT NULL,
source_seq BIGINT NOT NULL,
snapshot JSON,
attachment_refs JSON,
availability VARCHAR(16) NOT NULL DEFAULT 'available',
+21 -1
View File
@@ -23,6 +23,26 @@ func ensureIMGroupBusinessTables(exec sqlExecutor) error {
INDEX idx_im_group_settings_owner(owner_user_id, group_status),
INDEX idx_im_group_settings_status_updated(group_status, updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_group_messages (
id BIGINT NOT NULL AUTO_INCREMENT, topic VARCHAR(64) NOT NULL, group_id BIGINT NOT NULL,
seqid BIGINT NOT NULL, from_user BIGINT NOT NULL, client_msg_id VARCHAR(64),
msg_type VARCHAR(32) NOT NULL DEFAULT 'text', status VARCHAR(32) NOT NULL DEFAULT 'normal',
content JSON, head JSON, mentioned_user_ids JSON, mention_all TINYINT(1) NOT NULL DEFAULT 0,
reply_seqid BIGINT, root_seqid BIGINT, file_count INT NOT NULL DEFAULT 0,
content_size INT NOT NULL DEFAULT 0, has_risk TINYINT(1) NOT NULL DEFAULT 0,
audit_status VARCHAR(32) NOT NULL DEFAULT 'pass', version INT NOT NULL DEFAULT 1,
recalled_at DATETIME(3), recalled_by BIGINT, deleted_at DATETIME(3), expires_at DATETIME(3),
created_at DATETIME(3) NOT NULL, updated_at DATETIME(3) NOT NULL,
PRIMARY KEY(id), UNIQUE INDEX uk_im_group_messages_group_seq(group_id, seqid),
UNIQUE INDEX uk_im_group_messages_topic_seq(topic, seqid),
UNIQUE INDEX uk_im_group_messages_topic_sender_client_mid(topic, from_user, client_msg_id),
INDEX idx_im_group_messages_group_created(group_id, created_at),
INDEX idx_im_group_messages_group_status_seq(group_id, status, seqid),
INDEX idx_im_group_messages_topic_created(topic, created_at),
INDEX idx_im_group_messages_topic_from_seq(topic, from_user, seqid),
INDEX idx_im_group_messages_audit(audit_status, created_at),
INDEX idx_im_group_messages_expires(expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_group_members (
id BIGINT NOT NULL AUTO_INCREMENT, topic VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL,
role VARCHAR(16) NOT NULL DEFAULT 'member', member_status VARCHAR(16) NOT NULL DEFAULT 'active',
@@ -105,7 +125,7 @@ func ensureIMGroupBusinessTables(exec sqlExecutor) error {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_group_files (
id BIGINT NOT NULL AUTO_INCREMENT, group_file_id VARCHAR(64) NOT NULL, topic VARCHAR(32) NOT NULL,
file_id VARCHAR(64) NOT NULL, source_topic VARCHAR(32) NOT NULL DEFAULT '', source_seq INT NOT NULL DEFAULT 0,
file_id VARCHAR(64) NOT NULL, source_topic VARCHAR(32) NOT NULL DEFAULT '', source_seq BIGINT NOT NULL DEFAULT 0,
uploader_id BIGINT NOT NULL, file_name VARCHAR(255) NOT NULL DEFAULT '', mime_type VARCHAR(128) NOT NULL DEFAULT '',
file_type VARCHAR(32) NOT NULL DEFAULT 'file', file_size BIGINT NOT NULL DEFAULT 0,
status VARCHAR(16) NOT NULL DEFAULT 'active', deleted_by BIGINT, deleted_at DATETIME(3),
+434 -10
View File
@@ -92,6 +92,56 @@ func finalizeTenantMessage(id t.TenantID, msg t.Message, recalledBy sql.NullInt6
return msg
}
func normalizeGroupMessage(id t.TenantID, msg t.Message, recalledBy sql.NullInt64) t.Message {
msg.TenantID = id
msg.From = common.EncodeUidString(msg.From).String()
if recalledBy.Valid {
msg.RecalledBy = common.EncodeUidString(strconv.FormatInt(recalledBy.Int64, 10)).String()
}
msg.Content = common.FromJSON(msg.Content)
return msg
}
func messageHeadType(head t.KVMap) string {
if head == nil {
return "text"
}
for _, key := range []string{"x-im-type", "type"} {
if value, ok := head[key].(string); ok {
value = strings.TrimSpace(value)
if value != "" {
return value
}
}
}
return "text"
}
func messageHeadJSON(head t.KVMap, key string) []byte {
if head == nil {
return nil
}
if value, ok := head[key]; ok {
return common.ToJSON(value)
}
return nil
}
func messageHeadBool(head t.KVMap, key string) bool {
if head == nil {
return false
}
switch value := head[key].(type) {
case bool:
return value
case string:
parsed, _ := strconv.ParseBool(value)
return parsed
default:
return false
}
}
func stampTenantFile(id t.TenantID, file *t.FileDef) *t.FileDef {
if file != nil {
file.TenantID = id
@@ -1105,6 +1155,63 @@ func tenantMessageDeleteList(tx *sqlx.Tx, tenantID t.TenantID, topic string, toD
return nil
}
func groupMessageDeleteList(tx *sqlx.Tx, topic string, toDel *t.DelMessage) error {
var err error
if toDel == nil {
if _, err = tx.Exec("DELETE FROM im_dellog WHERE topic=?", topic); err != nil {
return err
}
_, err = tx.Exec("DELETE FROM im_group_messages WHERE topic=?", topic)
return err
}
delRanges := toDel.SeqIdRanges
if toDel.DeletedFor == "" {
where := "m.topic=?"
args := []any{topic}
if len(delRanges) > 0 {
rSQL, rArgs := common.RangesToSql(delRanges)
where += " AND m.seqid " + rSQL
args = append(args, rArgs...)
}
where += " AND m.deleted_at IS NULL"
if newerThan := toDel.GetNewerThan(); newerThan != nil {
where += " AND m.created_at>?"
args = append(args, newerThan)
}
var seqIDs []int
if err = tx.Select(&seqIDs, "SELECT seqid FROM im_group_messages AS m WHERE "+where, args...); err != nil {
return err
}
if len(seqIDs) == 0 {
return nil
}
sort.Ints(seqIDs)
delRanges = t.SliceToRanges(seqIDs)
rSQL, rArgs := common.RangesToSql(delRanges)
where = "m.topic=? AND m.seqid " + rSQL
args = append([]any{topic}, rArgs...)
if _, err = tx.Exec("UPDATE im_group_messages AS m SET m.updated_at=?,m.deleted_at=?,m.status='deleted',m.from_user=0,m.head=NULL,m.content=NULL,m.content_size=0 WHERE "+
where, append([]any{t.TimeNow(), t.TimeNow()}, args...)...); err != nil {
return err
}
}
stmt, err := tx.Prepare("INSERT INTO im_dellog(topic,deletedfor,delid,low,hi) VALUES(?,?,?,?,?)")
if err != nil {
return err
}
defer stmt.Close()
forUser := common.DecodeUidString(toDel.DeletedFor)
for _, rng := range delRanges {
if rng.Hi == 0 {
rng.Hi = rng.Low + 1
}
if _, err = stmt.Exec(topic, forUser, toDel.DelId, rng.Low, rng.Hi); err != nil {
return err
}
}
return nil
}
func (a *adapter) TenantTopicDelete(id t.TenantID, topic string, isChan, hard bool) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || topic == "" {
@@ -1138,7 +1245,12 @@ func (a *adapter) TenantTopicDelete(id t.TenantID, topic string, isChan, hard bo
if _, err = tx.Exec(tx.Rebind(q), args...); err != nil {
return err
}
if err = tenantMessageDeleteList(tx, id, topic, nil); err != nil {
if t.GetTopicCat(topic) == t.TopicCatGrp {
err = groupMessageDeleteList(tx, topic, nil)
} else {
err = tenantMessageDeleteList(tx, id, topic, nil)
}
if err != nil {
return err
}
if _, err = tx.Exec("DELETE FROM im_topictags WHERE tenant_id=? AND topic=?", id, topic); err != nil {
@@ -1652,6 +1764,42 @@ func (a *adapter) TenantMessageSave(id t.TenantID, msg *t.Message) error {
return err
}
func (a *adapter) TenantGroupMessageSave(id t.TenantID, msg *t.Message) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || msg == nil || msg.TenantID != id || msg.Topic == "" || msg.SeqId <= 0 {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantGroupMessageSave(id, msg)
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
groupID, err := a.groupIDByTopic(ctx, msg.Topic)
if err != nil {
return err
}
clientMsgID := sql.NullString{String: msg.ClientMsgID, Valid: msg.ClientMsgID != ""}
contentJSON := common.ToJSON(msg.Content)
res, err := a.db.ExecContext(ctx,
`INSERT INTO im_group_messages
(topic,group_id,seqid,from_user,client_msg_id,msg_type,status,content,head,mentioned_user_ids,mention_all,
file_count,content_size,version,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
msg.Topic, groupID, msg.SeqId, decodeUID(t.ParseUid(msg.From)), clientMsgID, messageHeadType(msg.Head), "normal",
contentJSON, msg.Head, messageHeadJSON(msg.Head, "mentioned_user_ids"), messageHeadBool(msg.Head, "mention_all"),
0, len(contentJSON), messageVersion(msg.Version), msg.CreatedAt, msg.UpdatedAt)
if err == nil {
lastID, _ := res.LastInsertId()
msg.SetUid(t.Uid(lastID))
}
if isDupe(err) {
return t.ErrDuplicate
}
return err
}
func (a *adapter) TenantMessageGetByClientID(id t.TenantID, from t.Uid, clientMsgID string) (*t.Message, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
@@ -1687,7 +1835,40 @@ func (a *adapter) TenantMessageGetByClientID(id t.TenantID, from t.Uid, clientMs
return &msg, nil
}
func (a *adapter) TenantMessageGetOne(id t.TenantID, topic string, seq int) (*t.Message, error) {
func (a *adapter) TenantGroupMessageGetByClientID(id t.TenantID, topic string, from t.Uid, clientMsgID string) (*t.Message, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantGroupMessageGetByClientID(id, topic, from, clientMsgID)
}
if topic == "" || from.IsZero() || clientMsgID == "" {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
row := struct {
t.Message
RecalledBy sql.NullInt64 `db:"recalled_by"`
}{}
err = a.db.QueryRowxContext(ctx,
"SELECT id,created_at AS createdat,updated_at AS updatedat,deleted_at AS deletedat,0 AS delid,seqid,topic,from_user AS `from`,client_msg_id,version,recalled_at,recalled_by,head,content "+
"FROM im_group_messages WHERE topic=? AND from_user=? AND client_msg_id=?",
topic, decodeUID(from), clientMsgID).StructScan(&row)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
msg := normalizeGroupMessage(id, row.Message, row.RecalledBy)
return &msg, nil
}
func (a *adapter) TenantMessageGetOne(id t.TenantID, topic string, seq int64) (*t.Message, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
@@ -1711,7 +1892,29 @@ func (a *adapter) TenantMessageGetOne(id t.TenantID, topic string, seq int) (*t.
return msg, err
}
func (a *adapter) TenantMessageRecall(id t.TenantID, topic string, seq int, actor t.Uid, expectedVersion int, recalledAt time.Time) (*t.Message, error) {
func (a *adapter) TenantGroupMessageGetOne(id t.TenantID, topic string, seq int64) (*t.Message, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantGroupMessageGetOne(id, topic, seq)
}
if topic == "" || seq <= 0 {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
msg, err := tenantGroupMessageGetOne(a.db, ctx, id, topic, seq, "")
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
return msg, err
}
func (a *adapter) TenantMessageRecall(id t.TenantID, topic string, seq int64, actor t.Uid, expectedVersion int, recalledAt time.Time) (*t.Message, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
@@ -1763,7 +1966,57 @@ func (a *adapter) TenantMessageRecall(id t.TenantID, topic string, seq int, acto
return a.TenantMessageGetOne(id, topic, seq)
}
func (a *adapter) TenantMessageEdit(id t.TenantID, topic string, seq int, actor t.Uid, expectedVersion int, head t.KVMap, content any, reason string, editedAt time.Time) (*t.Message, error) {
func (a *adapter) TenantGroupMessageRecall(id t.TenantID, topic string, seq int64, actor t.Uid, expectedVersion int, recalledAt time.Time) (*t.Message, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantGroupMessageRecall(id, topic, seq, actor, expectedVersion, recalledAt)
}
if topic == "" || seq <= 0 || actor.IsZero() || expectedVersion <= 0 {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return nil, err
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
msg, err := tenantGroupMessageGetOne(tx, ctx, id, topic, seq, " FOR UPDATE")
if err != nil {
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
return nil, err
}
if msg.Version != expectedVersion || msg.RecalledAt != nil || msg.DeletedAt != nil {
return nil, t.ErrDuplicate
}
_, err = tx.ExecContext(ctx,
"UPDATE im_group_messages SET updated_at=?,version=version+1,status='recalled',recalled_at=?,recalled_by=?,head=NULL,content=NULL WHERE topic=? AND seqid=? AND version=?",
recalledAt, recalledAt, decodeUID(actor), topic, seq, expectedVersion)
if err != nil {
return nil, err
}
if err = insertBusinessEventTx(tx, t.StableEventID("message", topic, seq, expectedVersion+1, "recalled"),
"topic", topic, actor, "message.recalled", map[string]any{"topic": topic, "seq": seq, "version": expectedVersion + 1}); err != nil {
return nil, err
}
if err = tx.Commit(); err != nil {
return nil, err
}
return a.TenantGroupMessageGetOne(id, topic, seq)
}
func (a *adapter) TenantMessageEdit(id t.TenantID, topic string, seq int64, actor t.Uid, expectedVersion int, head t.KVMap, content any, reason string, editedAt time.Time) (*t.Message, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
@@ -1820,6 +2073,62 @@ func (a *adapter) TenantMessageEdit(id t.TenantID, topic string, seq int, actor
return a.TenantMessageGetOne(id, topic, seq)
}
func (a *adapter) TenantGroupMessageEdit(id t.TenantID, topic string, seq int64, actor t.Uid, expectedVersion int, head t.KVMap, content any, reason string, editedAt time.Time) (*t.Message, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantGroupMessageEdit(id, topic, seq, actor, expectedVersion, head, content, reason, editedAt)
}
if topic == "" || seq <= 0 || actor.IsZero() || expectedVersion <= 0 {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return nil, err
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
msg, err := tenantGroupMessageGetOne(tx, ctx, id, topic, seq, " FOR UPDATE")
if err != nil {
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
return nil, err
}
if msg.Version != expectedVersion || msg.RecalledAt != nil || msg.DeletedAt != nil {
return nil, t.ErrDuplicate
}
if _, err = tx.ExecContext(ctx,
"INSERT INTO im_message_revisions(createdat,updatedat,topic,seqid,version,actor,head,content,reason) VALUES(?,?,?,?,?,?,?,?,?)",
editedAt, editedAt, topic, seq, msg.Version, decodeUID(actor), msg.Head, common.ToJSON(msg.Content), strings.TrimSpace(reason)); err != nil {
return nil, err
}
contentJSON := common.ToJSON(content)
_, err = tx.ExecContext(ctx,
"UPDATE im_group_messages SET updated_at=?,version=version+1,msg_type=?,head=?,content=?,content_size=? WHERE topic=? AND seqid=? AND version=?",
editedAt, messageHeadType(head), head, contentJSON, len(contentJSON), topic, seq, expectedVersion)
if err != nil {
return nil, err
}
if err = insertBusinessEventTx(tx, t.StableEventID("message", topic, seq, expectedVersion+1, "edited"),
"topic", topic, actor, "message.edited", map[string]any{"topic": topic, "seq": seq, "version": expectedVersion + 1}); err != nil {
return nil, err
}
if err = tx.Commit(); err != nil {
return nil, err
}
return a.TenantGroupMessageGetOne(id, topic, seq)
}
func (a *adapter) TenantMessageGetAll(id t.TenantID, topic string, uid t.Uid, opts *t.QueryOpt) ([]t.Message, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || topic == "" {
@@ -1888,6 +2197,69 @@ func (a *adapter) TenantMessageGetAll(id t.TenantID, topic string, uid t.Uid, op
return msgs, rows.Err()
}
func (a *adapter) TenantGroupMessageGetAll(id t.TenantID, topic string, uid t.Uid, opts *t.QueryOpt) ([]t.Message, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || topic == "" {
return nil, t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantGroupMessageGetAll(id, topic, uid, opts)
}
limit := a.maxMessageResults
args := []any{store.DecodeUid(uid), topic}
seq := ""
if opts != nil {
seq = "AND m.seqid "
if len(opts.IdRanges) > 0 {
constr, addArgs := common.RangesToSql(opts.IdRanges)
seq += constr
args = append(args, addArgs...)
} else {
seq += "BETWEEN ? AND ?"
if opts.Since > 0 {
args = append(args, opts.Since)
} else {
args = append(args, 0)
}
if opts.Before > 1 {
args = append(args, opts.Before-1)
} else {
args = append(args, 1<<31-1)
}
}
if opts.Limit > 0 && opts.Limit < limit {
limit = opts.Limit
}
}
args = append(args, limit)
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx,
"SELECT m.id,m.created_at AS createdat,m.updated_at AS updatedat,m.deleted_at AS deletedat,0 AS delid,m.seqid,m.topic,m.from_user AS `from`,m.client_msg_id,m.version,m.recalled_at,m.recalled_by,m.head,m.content "+
"FROM im_group_messages AS m LEFT JOIN im_dellog AS d "+
"ON d.topic=m.topic AND m.seqid BETWEEN d.low AND d.hi-1 AND d.deletedfor=? "+
"WHERE m.status!='deleted' AND m.topic=? "+seq+" AND d.deletedfor IS NULL ORDER BY m.seqid DESC LIMIT ?",
args...)
if err != nil {
return nil, err
}
defer rows.Close()
msgs := make([]t.Message, 0, limit)
for rows.Next() {
row := struct {
t.Message
RecalledBy sql.NullInt64 `db:"recalled_by"`
}{}
if err = rows.StructScan(&row); err != nil {
return nil, err
}
msgs = append(msgs, normalizeGroupMessage(id, row.Message, row.RecalledBy))
}
return msgs, rows.Err()
}
func (a *adapter) TenantMessageSearch(id t.TenantID, topic string, uid t.Uid, queryText, messageType, cursor string, limit int) ([]t.Message, string, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
@@ -1951,7 +2323,7 @@ func (a *adapter) TenantMessageSearch(id t.TenantID, topic string, uid t.Uid, qu
msg := finalizeTenantMessage(id, row.Message, row.RecalledBy)
msg.Head = kvMapFromJSON(msg.Head)
if len(msgs) == limit {
nextCursor = strconv.Itoa(msg.SeqId)
nextCursor = strconv.FormatInt(msg.SeqId, 10)
continue
}
msgs = append(msgs, msg)
@@ -2001,7 +2373,7 @@ func (a *adapter) TenantCallCreate(id t.TenantID, record *t.CallRecord) error {
return err
}
func (a *adapter) TenantCallAccept(id t.TenantID, callID string, seq int, acceptedAt time.Time) error {
func (a *adapter) TenantCallAccept(id t.TenantID, callID string, seq int64, acceptedAt time.Time) error {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
@@ -2026,7 +2398,7 @@ func (a *adapter) TenantCallAccept(id t.TenantID, callID string, seq int, accept
return err
}
func (a *adapter) TenantCallFinish(id t.TenantID, callID string, seq int, status string, durationMS int64, finishedAt time.Time) error {
func (a *adapter) TenantCallFinish(id t.TenantID, callID string, seq int64, status string, durationMS int64, finishedAt time.Time) error {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
@@ -2102,7 +2474,7 @@ func (a *adapter) TenantCallList(id t.TenantID, uid t.Uid, topic, cursor string,
ID int64
CallID string `db:"call_id"`
Topic string
SeqID int `db:"seq_id"`
SeqID int64 `db:"seq_id"`
CallerID int64 `db:"caller_id"`
CalleeID int64 `db:"callee_id"`
CallType string `db:"call_type"`
@@ -4509,7 +4881,7 @@ func (a *adapter) abuseReportByIdempotency(ctx context.Context, uid t.Uid, idemp
TargetType string `db:"target_type"`
TargetUserID int64 `db:"target_user_id"`
TargetTopic string `db:"target_topic"`
TargetSeq int `db:"target_seq"`
TargetSeq int64 `db:"target_seq"`
ReasonCode string `db:"reason_code"`
Description string `db:"description"`
EvidenceFileIDs []byte `db:"evidence_file_ids"`
@@ -4699,7 +5071,7 @@ func isAllowedWorkbenchRoute(route string) bool {
}
}
func tenantMessageGetOne(q sqlx.QueryerContext, ctx context.Context, tenantID t.TenantID, topic string, seq int, suffix string) (*t.Message, error) {
func tenantMessageGetOne(q sqlx.QueryerContext, ctx context.Context, tenantID t.TenantID, topic string, seq int64, suffix string) (*t.Message, error) {
row := struct {
t.Message
RecalledBy sql.NullInt64 `db:"recalled_by"`
@@ -4721,6 +5093,31 @@ func tenantMessageGetOne(q sqlx.QueryerContext, ctx context.Context, tenantID t.
return &msg, nil
}
func (a *adapter) groupIDByTopic(ctx context.Context, topic string) (int64, error) {
var groupID int64
err := a.db.QueryRowContext(ctx, "SELECT id FROM im_group_settings WHERE topic=? LIMIT 1", topic).Scan(&groupID)
if err == sql.ErrNoRows {
return 0, t.ErrNotFound
}
return groupID, err
}
func tenantGroupMessageGetOne(q sqlx.QueryerContext, ctx context.Context, tenantID t.TenantID, topic string, seq int64, suffix string) (*t.Message, error) {
row := struct {
t.Message
RecalledBy sql.NullInt64 `db:"recalled_by"`
}{}
err := sqlx.GetContext(ctx, q, &row,
"SELECT id,created_at AS createdat,updated_at AS updatedat,deleted_at AS deletedat,0 AS delid,seqid,topic,from_user AS `from`,client_msg_id,version,recalled_at,recalled_by,head,content "+
"FROM im_group_messages WHERE topic=? AND seqid=?"+suffix,
topic, seq)
if err != nil {
return nil, err
}
msg := normalizeGroupMessage(tenantID, row.Message, row.RecalledBy)
return &msg, nil
}
func messageVersion(version int) int {
if version <= 0 {
return 1
@@ -5174,6 +5571,33 @@ func (a *adapter) TenantMessageDeleteList(id t.TenantID, topic string, del *t.De
return tx.Commit()
}
func (a *adapter) TenantGroupMessageDeleteList(id t.TenantID, topic string, del *t.DelMessage) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || topic == "" || (del != nil && del.TenantID != id) {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantGroupMessageDeleteList(id, topic, del)
}
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
if err = groupMessageDeleteList(tx, topic, del); err != nil {
return err
}
return tx.Commit()
}
func (a *adapter) TenantMessageGetDeleted(id t.TenantID, topic string, uid t.Uid, opts *t.QueryOpt) ([]t.DelMessage, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || topic == "" {
+1 -1
View File
@@ -1201,7 +1201,7 @@ func TestTopicUpdateOnMessage(t *testing.T) {
}
var got struct {
TouchedAt time.Time
SeqId int
SeqId int64
}
err = db.QueryRow("SELECT touchedat, seqid FROM im_topics WHERE name=?", testData.Topics[2].Id).
Scan(&got.TouchedAt, &got.SeqId)
+1 -1
View File
@@ -830,7 +830,7 @@ func TestTopicUpdateOnMessage(t *testing.T) {
}
var got struct {
TouchedAt time.Time
SeqId int
SeqId int64
}
err = db.QueryRow(ctx, "SELECT touchedat, seqid FROM im_topics WHERE name=$1", testData.Topics[2].Id).
Scan(&got.TouchedAt, &got.SeqId)
+1 -1
View File
@@ -844,7 +844,7 @@ func TestTopicUpdateOnMessage(t *testing.T) {
var got struct {
TouchedAt time.Time
SeqId int
SeqId int64
}
if err = cursor.One(&got); err != nil {
t.Fatal(err)
+90 -15
View File
@@ -60,6 +60,7 @@ const (
imVerificationExpiresInSeconds = 300
imVerificationResendAfterSeconds = 60
imVerificationMaxAttempts = 5
imVerificationCreateRateWindow = time.Hour
imActionTokenExpiresInSeconds = 300
imIndependentPasswordMaxAttempts = 5
imIndependentPasswordLockSeconds = 300
@@ -380,7 +381,7 @@ type imMessageRevisionRequest struct {
type imFavoritesBatchCreateRequest struct {
Messages []struct {
Topic string `json:"topic"`
Seq int `json:"seq,string"`
Seq int64 `json:"seq,string"`
} `json:"messages"`
}
@@ -479,7 +480,7 @@ type imGroupFileCreateRequest struct {
FileType string `json:"file_type,omitempty"`
FileSize int64 `json:"file_size,omitempty"`
SourceTopic string `json:"source_topic,omitempty"`
SourceSeq int `json:"source_seq,omitempty"`
SourceSeq int64 `json:"source_seq,omitempty"`
}
type imGroupAnnouncementPutRequest struct {
@@ -874,6 +875,18 @@ func serveIMVerificationCodes(wrt http.ResponseWriter, req *http.Request) {
now := time.Now().UTC().Round(time.Millisecond)
phoneHash := hashIMSecret(tenant.ID, scene, phone, "phone")
if allowed, _, ttl, err := store.AllowRate(tenant.ID, "verification:create:"+scene+":"+phoneHash, imVerificationMaxAttempts, imVerificationCreateRateWindow); err != nil {
logs.Warn.Println("im verification create: redis rate limit failed", tenant.Code, scene, err)
} else if !allowed {
retryAfter := int(ttl.Seconds())
if retryAfter < 1 {
retryAfter = int(imVerificationCreateRateWindow.Seconds())
}
writeIMError(wrt, req, http.StatusTooManyRequests, 30020, imReasonVerificationRate, "verification rate limited", map[string]any{
"retry_after_seconds": retryAfter,
})
return
}
active, err := store.Verifications.GetActive(tenant.ID, scene, phoneHash, now)
if err != nil && !errors.Is(err, types.ErrNotFound) {
logs.Warn.Println("im verification create: active lookup failed", tenant.Code, scene, err)
@@ -3342,7 +3355,7 @@ func serveIMMessageContext(wrt http.ResponseWriter, req *http.Request) {
}
before := parseIMLimit(req.URL.Query().Get("before"), 20, 100)
after := parseIMLimit(req.URL.Query().Get("after"), 20, 100)
msgs, err := store.Messages.GetAll(principal.TenantID, topic, principal.Uid, &types.QueryOpt{Since: seq - before, Before: seq + after + 1, Limit: before + after + 1})
msgs, err := store.Messages.GetAll(principal.TenantID, topic, principal.Uid, &types.QueryOpt{Since: seq - int64(before), Before: seq + int64(after) + 1, Limit: before + after + 1})
if err != nil {
writeIMRelationError(wrt, req, err)
return
@@ -3599,7 +3612,7 @@ func serveIMConversationContext(wrt http.ResponseWriter, req *http.Request) {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
peer := peerFromP2PTopic(topic, principal.Uid)
topic, peer := normalizeP2PConversationTopic(topic, principal.Uid)
if topic == "" || peer.IsZero() {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
return
@@ -3617,6 +3630,7 @@ func serveIMConversationSettings(wrt http.ResponseWriter, req *http.Request, pri
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
return
}
topic = normalizeConversationSettingsTopic(topic, principal.Uid)
switch req.Method {
case http.MethodGet:
settings, err := store.Conversations.GetSettings(principal.TenantID, principal.Uid, topic)
@@ -4043,6 +4057,11 @@ func serveIMGroupMembers(wrt http.ResponseWriter, req *http.Request, topic strin
roleFilter := strings.TrimSpace(req.URL.Query().Get("role"))
eligibleRole := strings.TrimSpace(req.URL.Query().Get("eligible_role"))
muteEligible := parseIMBool(req.URL.Query().Get("mute_eligible"))
cacheKey := groupMemberPageCacheKey(topic, principal.Uid, selfAccess, query, roleFilter, eligibleRole, limit, cursor, mentionableOnly, muteEligible)
if cached, ok := store.GetCachedGroupMemberPage(principal.TenantID, cacheKey); ok {
writeIMSuccess(wrt, req, cached)
return
}
members, err := store.Topics.GetUsers(principal.TenantID, topic, &types.QueryOpt{Limit: 1000})
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
@@ -4093,12 +4112,14 @@ func serveIMGroupMembers(wrt http.ResponseWriter, req *http.Request, topic strin
permissions := map[string]any{
"can_mention_all": selfAccess.IsAdmin(),
}
writeIMSuccess(wrt, req, map[string]any{
payload := map[string]any{
"topic": topic,
"items": items,
"next_cursor": nextCursor,
"permissions": permissions,
})
}
store.CacheGroupMemberPage(principal.TenantID, cacheKey, payload)
writeIMSuccess(wrt, req, payload)
}
func serveIMGroupDetail(wrt http.ResponseWriter, req *http.Request, topic string) {
@@ -5767,6 +5788,18 @@ func currentUserPhone(tenantID types.TenantID, uid types.Uid) (string, error) {
func createSensitiveChallenge(wrt http.ResponseWriter, req *http.Request, principal *imPrincipal, action, phone string) {
now := time.Now().UTC().Round(time.Millisecond)
phoneHash := hashIMSecret(principal.TenantID, "sensitive_action", phone, "phone")
if allowed, _, ttl, err := store.AllowRate(principal.TenantID, "verification:create:sensitive_action:"+phoneHash, imVerificationMaxAttempts, imVerificationCreateRateWindow); err != nil {
logs.Warn.Println("im sensitive verification create: redis rate limit failed", principal.TenantID, err)
} else if !allowed {
retryAfter := int(ttl.Seconds())
if retryAfter < 1 {
retryAfter = int(imVerificationCreateRateWindow.Seconds())
}
writeIMError(wrt, req, http.StatusTooManyRequests, 30020, imReasonVerificationRate, "verification rate limited", map[string]any{
"retry_after_seconds": retryAfter,
})
return
}
active, err := store.Verifications.GetActive(principal.TenantID, "sensitive_action", phoneHash, now)
if err != nil && !errors.Is(err, types.ErrNotFound) {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
@@ -6712,7 +6745,23 @@ func groupMemberMatches(item map[string]any, query string) bool {
return false
}
func authorizeIMMessageMutation(principal *imPrincipal, topic string, seq int, allowAdmin bool) (*types.Message, bool, error) {
func groupMemberPageCacheKey(topic string, uid types.Uid, access types.AccessMode, query, roleFilter, eligibleRole string,
limit, cursor int, mentionableOnly, muteEligible bool) string {
return strings.Join([]string{
topic,
uid.UserId(),
access.String(),
strings.ToLower(strings.TrimSpace(query)),
strings.TrimSpace(roleFilter),
strings.TrimSpace(eligibleRole),
strconv.Itoa(limit),
strconv.Itoa(cursor),
strconv.FormatBool(mentionableOnly),
strconv.FormatBool(muteEligible),
}, "|")
}
func authorizeIMMessageMutation(principal *imPrincipal, topic string, seq int64, allowAdmin bool) (*types.Message, bool, error) {
if principal == nil {
return nil, false, types.ErrPermissionDenied
}
@@ -8385,7 +8434,7 @@ func cleanTenantCodeHint(code string) string {
return code
}
func normalizeIMAbuseTarget(tenantID types.TenantID, reporter types.Uid, target types.AbuseTarget) (string, types.Uid, string, int, map[string]any, error) {
func normalizeIMAbuseTarget(tenantID types.TenantID, reporter types.Uid, target types.AbuseTarget) (string, types.Uid, string, int64, map[string]any, error) {
targetType := strings.ToLower(strings.TrimSpace(target.Type))
snapshot := map[string]any{"type": targetType}
switch targetType {
@@ -8413,7 +8462,7 @@ func normalizeIMAbuseTarget(tenantID types.TenantID, reporter types.Uid, target
return targetType, 0, topic, 0, snapshot, nil
case "message":
topic := strings.TrimSpace(target.Topic)
seq, err := strconv.Atoi(strings.TrimSpace(target.Seq))
seq, err := strconv.ParseInt(strings.TrimSpace(target.Seq), 10, 64)
if topic == "" || seq <= 0 || err != nil {
return "", 0, "", 0, nil, types.ErrMalformed
}
@@ -8476,7 +8525,7 @@ func groupInvitationResourcePath(path string) (string, string, bool) {
return strings.TrimSpace(invitationID), strings.Trim(tail, "/"), true
}
func messageReceiptsPath(path string) (string, int, bool) {
func messageReceiptsPath(path string) (string, int64, bool) {
const marker = "/v1/im/topics/"
idx := strings.Index(path, marker)
if idx < 0 {
@@ -8491,7 +8540,7 @@ func messageReceiptsPath(path string) (string, int, bool) {
if !ok || suffix != "receipts" {
return "", 0, false
}
seq, err := strconv.Atoi(seqValue)
seq, err := strconv.ParseInt(seqValue, 10, 64)
if err != nil {
return topic, 0, true
}
@@ -8531,7 +8580,7 @@ func topicFromPathWithSuffix(path, prefix, suffix string) string {
return strings.Trim(rest, "/")
}
func messageContextPath(path string) (string, int, bool) {
func messageContextPath(path string) (string, int64, bool) {
const prefix = "/v1/im/topics/"
idx := strings.Index(path, prefix)
if idx < 0 {
@@ -8542,14 +8591,14 @@ func messageContextPath(path string) (string, int, bool) {
if len(parts) != 4 || parts[1] != "messages" || parts[3] != "context" {
return "", 0, false
}
seq, err := strconv.Atoi(parts[2])
seq, err := strconv.ParseInt(parts[2], 10, 64)
if err != nil || seq <= 0 {
return "", 0, false
}
return parts[0], seq, true
}
func messageActionPath(path, action string) (string, int, bool) {
func messageActionPath(path, action string) (string, int64, bool) {
const marker = "/v1/im/topics/"
idx := strings.Index(path, marker)
if idx < 0 {
@@ -8564,7 +8613,7 @@ func messageActionPath(path, action string) (string, int, bool) {
if !ok || suffix != action {
return "", 0, false
}
seq, err := strconv.Atoi(seqValue)
seq, err := strconv.ParseInt(seqValue, 10, 64)
if err != nil {
return topic, 0, true
}
@@ -8756,3 +8805,29 @@ func peerFromP2PTopic(topic string, uid types.Uid) types.Uid {
}
return types.ZeroUid
}
func normalizeP2PConversationTopic(topic string, uid types.Uid) (string, types.Uid) {
topic = strings.TrimSpace(topic)
if len(topic) < 3 || uid.IsZero() {
return "", types.ZeroUid
}
if types.GetTopicCat(topic) == types.TopicCatMe {
peer := types.ParseUserId(topic)
if peer.IsZero() || peer == uid {
return "", types.ZeroUid
}
return uid.P2PName(peer), peer
}
peer := peerFromP2PTopic(topic, uid)
if peer.IsZero() {
return "", types.ZeroUid
}
return topic, peer
}
func normalizeConversationSettingsTopic(topic string, uid types.Uid) string {
if normalized, peer := normalizeP2PConversationTopic(topic, uid); !peer.IsZero() {
return normalized
}
return strings.TrimSpace(topic)
}
+61 -7
View File
@@ -861,11 +861,11 @@ func (s *testCallsStore) Create(_ types.TenantID, _ *types.CallRecord) error {
return nil
}
func (s *testCallsStore) Accept(_ types.TenantID, _ string, _ int, _ time.Time) error {
func (s *testCallsStore) Accept(_ types.TenantID, _ string, _ int64, _ time.Time) error {
return nil
}
func (s *testCallsStore) Finish(_ types.TenantID, _ string, _ int, _ string, _ int64, _ time.Time) error {
func (s *testCallsStore) Finish(_ types.TenantID, _ string, _ int64, _ string, _ int64, _ time.Time) error {
return nil
}
@@ -885,6 +885,7 @@ func (s *testConversationsStore) EnsureP2P(_ types.TenantID, uid, peer types.Uid
}
func (s *testConversationsStore) GetSettings(_ types.TenantID, _ types.Uid, topic string) (*types.ConversationSettings, error) {
s.topic = topic
if s.settings == nil {
now := time.Now().UTC().Round(time.Millisecond)
s.settings = &types.ConversationSettings{Topic: topic, Pinned: false, Muted: false, MessageRetention: "default", Version: 1, CreatedAt: now, UpdatedAt: now}
@@ -3770,7 +3771,7 @@ func TestServeIMFavoriteBatchCreateCountsExisting(t *testing.T) {
store.Messages = oldMessages
})
messages.EXPECT().GetOne(types.TenantID(7), "grpAlpha", 3).Return(&types.Message{
messages.EXPECT().GetOne(types.TenantID(7), "grpAlpha", int64(3)).Return(&types.Message{
TenantID: 7,
Topic: "grpAlpha",
SeqId: 3,
@@ -4307,7 +4308,7 @@ func TestServeIMMessageRecall(t *testing.T) {
ModeWant: types.ModeCFull,
ModeGiven: types.ModeCFull,
}, nil)
messages.EXPECT().GetOne(types.TenantID(7), "grpRecall", 9).Return(&types.Message{
messages.EXPECT().GetOne(types.TenantID(7), "grpRecall", int64(9)).Return(&types.Message{
TenantID: 7,
SeqId: 9,
Topic: "grpRecall",
@@ -4315,7 +4316,7 @@ func TestServeIMMessageRecall(t *testing.T) {
Version: 1,
ObjHeader: types.ObjHeader{CreatedAt: time.Now().Add(-time.Second)},
}, nil)
messages.EXPECT().Recall(types.TenantID(7), "grpRecall", 9, types.Uid(42), 1, gomock.Any()).Return(&types.Message{
messages.EXPECT().Recall(types.TenantID(7), "grpRecall", int64(9), types.Uid(42), 1, gomock.Any()).Return(&types.Message{
TenantID: 7,
SeqId: 9,
Topic: "grpRecall",
@@ -4363,7 +4364,7 @@ func TestServeIMMessageRevision(t *testing.T) {
ModeWant: types.ModeCFull,
ModeGiven: types.ModeCFull,
}, nil)
messages.EXPECT().GetOne(types.TenantID(7), "grpEdit", 3).Return(&types.Message{
messages.EXPECT().GetOne(types.TenantID(7), "grpEdit", int64(3)).Return(&types.Message{
TenantID: 7,
SeqId: 3,
Topic: "grpEdit",
@@ -4373,7 +4374,7 @@ func TestServeIMMessageRevision(t *testing.T) {
Content: "old",
ObjHeader: types.ObjHeader{CreatedAt: time.Now().Add(-time.Second)},
}, nil)
messages.EXPECT().Edit(types.TenantID(7), "grpEdit", 3, types.Uid(42), 1, gomock.Any(), "new", "", gomock.Any()).Return(&types.Message{
messages.EXPECT().Edit(types.TenantID(7), "grpEdit", int64(3), types.Uid(42), 1, gomock.Any(), "new", "", gomock.Any()).Return(&types.Message{
TenantID: 7,
SeqId: 3,
Topic: "grpEdit",
@@ -4431,6 +4432,31 @@ func TestServeIMConversationContext(t *testing.T) {
}
}
func TestServeIMConversationContextAcceptsUserTopic(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 1)
oldRelations := store.Relations
peer := types.Uid(2)
topic := types.Uid(1).P2PName(peer)
store.Relations = &testRelationsStore{}
defer func() { store.Relations = oldRelations }()
req := httptest.NewRequest(http.MethodGet, "/v1/im/conversations/"+peer.UserId()+"/context", nil)
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMConversationContext(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
if data["topic"] != topic || data["peer_user_id"] != peer.UserId() {
t.Fatalf("unexpected context response: %#v", data)
}
}
func TestServeIMP2PConversation(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
@@ -4487,6 +4513,34 @@ func TestServeIMConversationSettings(t *testing.T) {
}
}
func TestServeIMConversationSettingsNormalizesUserTopic(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 1)
oldConversations := store.Conversations
peer := types.Uid(2)
topic := types.Uid(1).P2PName(peer)
conversations := &testConversationsStore{}
store.Conversations = conversations
defer func() { store.Conversations = oldConversations }()
req := httptest.NewRequest(http.MethodGet, "/v1/im/conversations/"+peer.UserId()+"/settings", nil)
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMConversationContext(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if conversations.topic != topic {
t.Fatalf("settings topic: got %q, want %q", conversations.topic, topic)
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
if data["topic"] != topic {
t.Fatalf("unexpected settings response: %#v", data)
}
}
func TestServeIMTopicDraftPutAndGet(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
+2
View File
@@ -11,6 +11,7 @@ package main
//go:generate protoc --go_out=../pbx --go_opt=paths=source_relative --go-grpc_out=../pbx --go-grpc_opt=paths=source_relative ../pbx/model.proto
import (
"context"
"encoding/json"
"flag"
"net/http"
@@ -676,6 +677,7 @@ func main() {
globals.sessionStore = NewSessionStore(idleSessionTimeout + 15*time.Second)
// The hub (the main message router)
globals.hub = newHub()
startRedisGroupFanout(context.Background())
stopExternalBusinessEvents := startIMExternalBusinessEventPoller()
defer stopExternalBusinessEvents()
+31 -31
View File
@@ -41,7 +41,7 @@ func pbServDataSerialize(data *MsgServerData) *pbx.ServerMsg_Data {
FromUserId: data.From,
Timestamp: timeToInt64(&data.Timestamp),
DeletedAt: timeToInt64(data.DeletedAt),
SeqId: int32(data.SeqId),
SeqId: data.SeqId,
Head: interfaceMapToByteMap(data.Head),
Content: interfaceToBytes(data.Content),
},
@@ -86,7 +86,7 @@ func pbServPresSerialize(pres *MsgServerPres) *pbx.ServerMsg_Pres {
Src: pres.Src,
What: what,
UserAgent: pres.UserAgent,
SeqId: int32(pres.SeqId),
SeqId: pres.SeqId,
DelId: int32(pres.DelId),
DelSeq: pbDelQuerySerialize(pres.DelSeq),
TargetUserId: pres.AcsTarget,
@@ -103,7 +103,7 @@ func pbServInfoSerialize(info *MsgServerInfo) *pbx.ServerMsg_Info {
FromUserId: info.From,
Src: info.Src,
What: pbInfoNoteWhatSerialize(info.What),
SeqId: int32(info.SeqId),
SeqId: info.SeqId,
Event: pbCallEventSerialize(info.Event),
Payload: info.Payload,
},
@@ -165,7 +165,7 @@ func pbServDeserialize(pkt *pbx.ServerMsg) *ServerComMessage {
From: data.GetFromUserId(),
Timestamp: *tsptr,
DeletedAt: int64ToTime(data.GetDeletedAt()),
SeqId: int(data.GetSeqId()),
SeqId: data.GetSeqId(),
Head: byteMapToInterfaceMap(data.GetHead()),
Content: data.GetContent(),
}
@@ -204,7 +204,7 @@ func pbServDeserialize(pkt *pbx.ServerMsg) *ServerComMessage {
Src: pres.GetSrc(),
What: what,
UserAgent: pres.GetUserAgent(),
SeqId: int(pres.GetSeqId()),
SeqId: pres.GetSeqId(),
DelId: int(pres.GetDelId()),
DelSeq: pbDelQueryDeserialize(pres.GetDelSeq()),
AcsTarget: pres.GetTargetUserId(),
@@ -217,7 +217,7 @@ func pbServDeserialize(pkt *pbx.ServerMsg) *ServerComMessage {
Src: info.GetSrc(),
From: info.GetFromUserId(),
What: pbInfoNoteWhatDeserialize(info.GetWhat()),
SeqId: int(info.GetSeqId()),
SeqId: info.GetSeqId(),
Event: pbCallEventDeserialize(info.GetEvent()),
Payload: info.GetPayload(),
}
@@ -365,7 +365,7 @@ func pbCliSerialize(msg *ClientComMessage) *pbx.ClientMsg {
Note: &pbx.ClientNote{
Topic: msg.Note.Topic,
What: pbInfoNoteWhatSerialize(msg.Note.What),
SeqId: int32(msg.Note.SeqId),
SeqId: msg.Note.SeqId,
Unread: int32(msg.Note.Unread),
Event: pbCallEventSerialize(msg.Note.Event),
Payload: msg.Note.Payload,
@@ -485,7 +485,7 @@ func pbCliDeserialize(pkt *pbx.ClientMsg) *ClientComMessage {
} else if note := pkt.GetNote(); note != nil {
msg.Note = &MsgClientNote{
Topic: note.GetTopic(),
SeqId: int(note.GetSeqId()),
SeqId: note.GetSeqId(),
What: pbInfoNoteWhatDeserialize(note.GetWhat()),
Unread: int(note.GetUnread()),
Event: pbCallEventDeserialize(note.GetEvent()),
@@ -585,15 +585,15 @@ func pbGetQuerySerialize(in *MsgGetQuery) *pbx.GetQuery {
}
if in.Data != nil {
out.Data = &pbx.GetOpts{
BeforeId: int32(in.Data.BeforeId),
SinceId: int32(in.Data.SinceId),
BeforeId: in.Data.BeforeId,
SinceId: in.Data.SinceId,
Limit: int32(in.Data.Limit),
}
if len(in.Data.IdRanges) > 0 {
out.Data.Ranges = make([]*pbx.SeqRange, len(in.Data.IdRanges))
for i, dq := range in.Data.IdRanges {
out.Data.Ranges[i] = &pbx.SeqRange{Low: int32(dq.LowId), Hi: int32(dq.HiId)}
out.Data.Ranges[i] = &pbx.SeqRange{Low: dq.LowId, Hi: dq.HiId}
}
}
}
@@ -623,16 +623,16 @@ func pbGetQueryDeserialize(in *pbx.GetQuery) *MsgGetQuery {
}
if data := in.GetData(); data != nil {
msg.Data = &MsgGetOpts{
BeforeId: int(data.GetBeforeId()),
SinceId: int(data.GetSinceId()),
BeforeId: data.GetBeforeId(),
SinceId: data.GetSinceId(),
Limit: int(data.GetLimit()),
}
if ranges := data.GetRanges(); len(ranges) > 0 {
msg.Data.IdRanges = make([]MsgRange, len(ranges))
for i, sr := range ranges {
msg.Data.IdRanges[i].LowId = int(sr.GetLow())
msg.Data.IdRanges[i].HiId = int(sr.GetHi())
msg.Data.IdRanges[i].LowId = sr.GetLow()
msg.Data.IdRanges[i].HiId = sr.GetHi()
}
}
}
@@ -890,9 +890,9 @@ func pbTopicDescSerialize(desc *MsgTopicDesc) *pbx.TopicDesc {
IsChan: desc.IsChan,
Defacs: pbDefaultAcsSerialize(desc.DefaultAcs),
Acs: pbAccessModeSerialize(desc.Acs),
SeqId: int32(desc.SeqId),
ReadId: int32(desc.ReadSeqId),
RecvId: int32(desc.RecvSeqId),
SeqId: desc.SeqId,
ReadId: desc.ReadSeqId,
RecvId: desc.RecvSeqId,
DelId: int32(desc.DelId),
Public: interfaceToBytes(desc.Public),
Trusted: interfaceToBytes(desc.Trusted),
@@ -918,9 +918,9 @@ func pbTopicDescDeserialize(desc *pbx.TopicDesc) *MsgTopicDesc {
IsChan: desc.GetIsChan(),
DefaultAcs: pbDefaultAcsDeserialize(desc.GetDefacs()),
Acs: pbAccessModeDeserialize(desc.GetAcs()),
SeqId: int(desc.SeqId),
ReadSeqId: int(desc.ReadId),
RecvSeqId: int(desc.RecvId),
SeqId: desc.SeqId,
ReadSeqId: desc.ReadId,
RecvSeqId: desc.RecvId,
DelId: int(desc.DelId),
Public: bytesToInterface(desc.Public),
Trusted: bytesToInterface(desc.Trusted),
@@ -947,7 +947,7 @@ func pbTopicSerializeToDesc(topic *Topic) *pbx.TopicDesc {
Auth: topic.accessAuth.String(),
Anon: topic.accessAnon.String(),
},
SeqId: int32(topic.lastID),
SeqId: topic.lastID,
DelId: int32(topic.delID),
Public: interfaceToBytes(topic.public),
Trusted: interfaceToBytes(topic.trusted),
@@ -972,15 +972,15 @@ func pbTopicSubSerialize(sub *MsgTopicSub) *pbx.TopicSub {
DeletedAt: timeToInt64(sub.DeletedAt),
Online: sub.Online,
Acs: pbAccessModeSerialize(&sub.Acs),
ReadId: int32(sub.ReadSeqId),
RecvId: int32(sub.RecvSeqId),
ReadId: sub.ReadSeqId,
RecvId: sub.RecvSeqId,
Public: interfaceToBytes(sub.Public),
Trusted: interfaceToBytes(sub.Trusted),
Private: interfaceToBytes(sub.Private),
UserId: sub.User,
Topic: sub.Topic,
TouchedAt: timeToInt64(sub.TouchedAt),
SeqId: int32(sub.SeqId),
SeqId: sub.SeqId,
DelId: int32(sub.DelId),
}
if sub.LastSeen != nil {
@@ -1001,15 +1001,15 @@ func pbTopicSubSliceDeserialize(subs []*pbx.TopicSub) []MsgTopicSub {
UpdatedAt: int64ToTime(subs[i].GetUpdatedAt()),
DeletedAt: int64ToTime(subs[i].GetDeletedAt()),
Online: subs[i].GetOnline(),
ReadSeqId: int(subs[i].GetReadId()),
RecvSeqId: int(subs[i].GetRecvId()),
ReadSeqId: subs[i].GetReadId(),
RecvSeqId: subs[i].GetRecvId(),
Public: bytesToInterface(subs[i].GetPublic()),
Trusted: bytesToInterface(subs[i].GetTrusted()),
Private: bytesToInterface(subs[i].GetPrivate()),
User: subs[i].GetUserId(),
Topic: subs[i].GetTopic(),
TouchedAt: int64ToTime(subs[i].GetTouchedAt()),
SeqId: int(subs[i].GetSeqId()),
SeqId: subs[i].GetSeqId(),
DelId: int(subs[i].GetDelId()),
}
if acs := subs[i].GetAcs(); acs != nil {
@@ -1063,7 +1063,7 @@ func pbDelQuerySerialize(in []MsgRange) []*pbx.SeqRange {
out := make([]*pbx.SeqRange, len(in))
for i, dq := range in {
out[i] = &pbx.SeqRange{Low: int32(dq.LowId), Hi: int32(dq.HiId)}
out[i] = &pbx.SeqRange{Low: dq.LowId, Hi: dq.HiId}
}
return out
@@ -1076,8 +1076,8 @@ func pbDelQueryDeserialize(in []*pbx.SeqRange) []MsgRange {
out := make([]MsgRange, len(in))
for i, sr := range in {
out[i].LowId = int(sr.GetLow())
out[i].HiId = int(sr.GetHi())
out[i].LowId = sr.GetLow()
out[i].HiId = sr.GetHi()
}
return out
+2 -2
View File
@@ -577,8 +577,8 @@ func pluginSubscription(sub *types.Subscription, action int) {
UserId: sub.User,
DelId: int32(sub.DelId),
ReadId: int32(sub.ReadSeqId),
RecvId: int32(sub.RecvSeqId),
ReadId: sub.ReadSeqId,
RecvId: sub.RecvSeqId,
Mode: &pbx.AccessMode{
Want: sub.ModeWant.String(),
+5 -5
View File
@@ -12,7 +12,7 @@ import (
// presParams defines parameters for creating a presence notification.
type presParams struct {
userAgent string
seqID int
seqID int64
delID int
delSeq []MsgRange
@@ -483,7 +483,7 @@ func (t *Topic) presSubsOffline(what string, params *presParams,
// Publish {info what=read|recv|kp} to topic subscribers's sessions currently offline in the topic,
// on subscriber's 'me'. Group and P2P.
func (t *Topic) infoSubsOffline(from types.Uid, what string, seq int, skipSid string) {
func (t *Topic) infoSubsOffline(from types.Uid, what string, seq int64, skipSid string) {
user := from.UserId()
for uid, pud := range t.perUser {
@@ -509,7 +509,7 @@ func (t *Topic) infoSubsOffline(from types.Uid, what string, seq int, skipSid st
}
// Publish {info what=call} to topic subscribers's sessions on subscriber's 'me'.
func (t *Topic) infoCallSubsOffline(from string, target types.Uid, event string, seq int,
func (t *Topic) infoCallSubsOffline(from string, target types.Uid, event string, seq int64,
sdp json.RawMessage, skipSid string, offlineOnly bool) {
if target.IsZero() {
logs.Err.Printf("callSubs could not find target: topic %s - from %s", t.name, from)
@@ -672,9 +672,9 @@ func presSingleUserOfflineOffline(tenantID types.TenantID, uid types.Uid, origin
// Let other sessions of a given user know what messages are now received/read.
// If both 'read' and 'recv' != 0 then 'read' takes precedence over 'recv'.
// Cases U
func (t *Topic) presPubMessageCount(uid types.Uid, mode types.AccessMode, read, recv int, skip string) {
func (t *Topic) presPubMessageCount(uid types.Uid, mode types.AccessMode, read, recv int64, skip string) {
var what string
var seq int
var seq int64
if read > 0 {
what = "read"
seq = read
+1 -1
View File
@@ -164,7 +164,7 @@ func pushForChanDelete(tenantID types.TenantID, topicName string, now time.Time)
}
// Prepares payload to be delivered to a mobile device as a push notification in response to receiving "read" notification.
func (t *Topic) pushForReadRcpt(uid types.Uid, seq int, now time.Time) *push.Receipt {
func (t *Topic) pushForReadRcpt(uid types.Uid, seq int64, now time.Time) *push.Receipt {
// The `Topic` in the push receipt is `t.xoriginal` for group topics, `fromUid` for p2p topics,
// not the t.original(fromUid) because it's the topic name as seen by the recipient, not by the sender.
topic := t.xoriginal
+2 -2
View File
@@ -39,7 +39,7 @@ func payloadToData(pl *push.Payload) (map[string]string, error) {
// Must use "xfrom" because "from" is a reserved word. Google did not bother to document it anywhere.
data["xfrom"] = pl.From
if pl.What == push.ActMsg {
data["seq"] = strconv.Itoa(pl.SeqId)
data["seq"] = strconv.FormatInt(pl.SeqId, 10)
if pl.ContentType != "" {
data["mime"] = pl.ContentType
}
@@ -81,7 +81,7 @@ func payloadToData(pl *push.Payload) (map[string]string, error) {
data["modeWant"] = pl.ModeWant.String()
data["modeGiven"] = pl.ModeGiven.String()
} else if pl.What == push.ActRead {
data["seq"] = strconv.Itoa(pl.SeqId)
data["seq"] = strconv.FormatInt(pl.SeqId, 10)
data["silent"] = "true"
} else {
return nil, errors.New("unknown push type")
+1 -1
View File
@@ -76,7 +76,7 @@ type Payload struct {
// Message sender 'usrXXX'
From string `json:"from"`
// Sequential ID of the message.
SeqId int `json:"seq"`
SeqId int64 `json:"seq"`
// MIME-Type of the message content, text/x-drafty or text/plain
ContentType string `json:"mime"`
// Actual Data.Content of the message, if requested
+87
View File
@@ -0,0 +1,87 @@
package main
import (
"context"
"encoding/json"
"github.com/tinode/chat/server/logs"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
func startRedisGroupFanout(ctx context.Context) {
if err := store.StartGroupFanoutSubscriber(ctx, currentNodeID(), func(raw []byte) {
var msg ServerComMessage
if err := json.Unmarshal(raw, &msg); err != nil {
logs.Warn.Println("redis group fanout: malformed message", err)
return
}
if !msg.TenantID.IsValid() || msg.RcptTo == "" || types.GetTopicCat(msg.RcptTo) != types.TopicCatGrp {
return
}
msg.sess = nil
deliverRedisGroupFanout(&msg)
}); err != nil {
logs.Warn.Println("redis group fanout: subscriber disabled", err)
}
}
func deliverRedisGroupFanout(msg *ServerComMessage) {
key := types.TopicKey{TenantID: msg.TenantID, Topic: msg.RcptTo}
if globals.hub.topicGet(key) != nil {
select {
case globals.hub.routeSrv <- msg:
default:
logs.Err.Println("redis group fanout: hub routeSrv queue full", msg.RcptTo)
}
return
}
delivered := 0
localMembers := redisGroupFanoutMembers(msg.TenantID, msg.RcptTo)
globals.sessionStore.Range(func(_ string, sess *Session) bool {
if sess == nil || sess.isCluster() || sess.isProxy() || sess.tenantID != msg.TenantID || sess.sid == msg.SkipSid {
return true
}
_, listedOnline := localMembers[sess.uid.UserId()]
if !listedOnline && !sess.hasSub(msg.RcptTo) {
return true
}
msgCopy := msg.copy()
prepareRedisGroupFanoutMessage(msgCopy, msg.RcptTo)
if sess.queueOut(msgCopy) {
delivered++
} else {
logs.Warn.Printf("redis group fanout: session queue full, sid=%s topic=%s", sess.sid, msg.RcptTo)
}
return true
})
if delivered == 0 {
logs.Info.Println("redis group fanout: no local subscribers for unloaded topic", msg.RcptTo)
}
}
func redisGroupFanoutMembers(tenantID types.TenantID, topic string) map[string]struct{} {
uids, err := store.GetTopicNodeOnlineMembers(tenantID, topic, currentNodeID())
if err != nil {
logs.Warn.Println("redis group fanout: failed to read local online members", topic, err)
return nil
}
members := make(map[string]struct{}, len(uids))
for _, uid := range uids {
members[uid.UserId()] = struct{}{}
}
return members
}
func prepareRedisGroupFanoutMessage(msg *ServerComMessage, topic string) {
switch {
case msg.Data != nil && msg.Data.Topic == "":
msg.Data.Topic = topic
case msg.Pres != nil && msg.Pres.Topic == "":
msg.Pres.Topic = topic
case msg.Info != nil && msg.Info.Topic == "":
msg.Info.Topic = topic
}
}
+63
View File
@@ -205,6 +205,32 @@ func (s *Session) getSub(topic string) *Subscription {
return s.subs[topic]
}
func (s *Session) hasSub(topic string) bool {
if s.multi != nil {
return s.multi.hasSub(topic)
}
s.subsLock.RLock()
defer s.subsLock.RUnlock()
_, ok := s.subs[topic]
return ok
}
func (s *Session) groupSubs() []string {
if s.multi != nil {
return s.multi.groupSubs()
}
s.subsLock.RLock()
defer s.subsLock.RUnlock()
topics := make([]string, 0, len(s.subs))
for topic := range s.subs {
if len(topic) >= 3 && types.GetTopicCat(topic) == types.TopicCatGrp {
topics = append(topics, topic)
}
}
return topics
}
func (s *Session) delSub(topic string) {
if s.multi != nil {
s.multi.delSub(topic)
@@ -434,6 +460,11 @@ func (s *Session) purgeChannels() {
// cleanUp is called when the session is terminated to perform resource cleanup.
func (s *Session) cleanUp(expired bool) {
atomic.StoreInt32(&s.terminating, 1)
if !s.uid.IsZero() && s.tenantID.IsValid() {
if err := store.MarkUserOffline(s.tenantID, s.uid, s.sid, currentNodeID()); err != nil {
logs.Warn.Println("s.cleanUp: failed to mark user offline", s.uid, err, s.sid)
}
}
s.purgeChannels()
s.inflightReqs.Wait()
s.inflightReqs = nil
@@ -488,6 +519,16 @@ func (s *Session) dispatchRaw(raw []byte) {
func (s *Session) dispatch(msg *ClientComMessage) {
now := types.TimeNow()
atomic.StoreInt64(&s.lastAction, now.UnixNano())
if !s.uid.IsZero() && s.tenantID.IsValid() {
if err := store.RefreshUserPresence(s.tenantID, s.uid, s.sid, currentNodeID()); err != nil {
logs.Warn.Println("s.dispatch: failed to refresh user presence", s.uid, err, s.sid)
}
for _, topic := range s.groupSubs() {
if err := store.MarkTopicMemberOnline(s.tenantID, topic, s.uid, currentNodeID()); err != nil {
logs.Warn.Println("s.dispatch: failed to refresh group member presence", topic, s.uid, err, s.sid)
}
}
}
// This should be the first block here, before any other checks.
var resp *ServerComMessage
@@ -1200,6 +1241,9 @@ func (s *Session) onLogin(msgID string, timestamp time.Time, rec *auth.Rec, miss
// Authenticate the session.
s.uid = rec.Uid
s.authLvl = rec.AuthLevel
if err := store.MarkUserOnline(s.tenantID, s.uid, s.sid, currentNodeID(), s.platf, s.userAgent); err != nil {
logs.Warn.Println("s.onLogin: failed to mark user online", s.uid, err, s.sid)
}
// Reset expiration time.
rec.Lifetime = 0
}
@@ -1228,6 +1272,25 @@ func (s *Session) onLogin(msgID string, timestamp time.Time, rec *auth.Rec, miss
return reply
}
func currentNodeID() string {
if globals.cluster != nil && globals.cluster.thisNodeName != "" {
return globals.cluster.thisNodeName
}
return "local"
}
func redisUserOnline(tenantID types.TenantID, uid types.Uid, fallback bool) bool {
if !store.UserPresenceEnabled() || !tenantID.IsValid() || uid.IsZero() {
return fallback
}
presence, err := store.GetUserPresence(tenantID, uid)
if err != nil {
logs.Warn.Printf("presence[%s]: failed to read redis presence: %v", uid.UserId(), err)
return fallback
}
return presence.Online
}
func (s *Session) get(msg *ClientComMessage) {
// Expand topic name.
var resp *ServerComMessage
+85
View File
@@ -0,0 +1,85 @@
package store
import (
"sort"
"strings"
"sync"
"time"
"github.com/tinode/chat/server/store/types"
)
const configCacheTTL = time.Minute
type configCacheEntry[T any] struct {
value T
expiresAt time.Time
}
type configCache[T any] struct {
mu sync.RWMutex
items map[string]configCacheEntry[T]
}
func newConfigCache[T any]() *configCache[T] {
return &configCache[T]{items: make(map[string]configCacheEntry[T])}
}
func (c *configCache[T]) get(key string) (T, bool) {
var zero T
c.mu.RLock()
entry, ok := c.items[key]
c.mu.RUnlock()
if !ok || time.Now().After(entry.expiresAt) {
return zero, false
}
return entry.value, true
}
func (c *configCache[T]) set(key string, value T) {
c.mu.Lock()
c.items[key] = configCacheEntry[T]{value: value, expiresAt: time.Now().Add(configCacheTTL)}
c.mu.Unlock()
}
var (
tenantByCodeCache = newConfigCache[*types.Tenant]()
tenantByIDCache = newConfigCache[*types.Tenant]()
registrationSMSCache = newConfigCache[bool]()
thirdPartyConfigCache = newConfigCache[map[string]string]()
thirdPartyConfigAllKeys = "*"
)
func cloneTenant(t *types.Tenant) *types.Tenant {
if t == nil {
return nil
}
cp := *t
return &cp
}
func cloneStringMap(src map[string]string) map[string]string {
if src == nil {
return nil
}
dst := make(map[string]string, len(src))
for k, v := range src {
dst[k] = v
}
return dst
}
func thirdPartyCacheKey(group string, keys ...string) string {
group = strings.TrimSpace(group)
if len(keys) == 0 {
return group + ":" + thirdPartyConfigAllKeys
}
normalized := make([]string, 0, len(keys))
for _, key := range keys {
if key = strings.TrimSpace(key); key != "" {
normalized = append(normalized, key)
}
}
sort.Strings(normalized)
return group + ":" + strings.Join(normalized, ",")
}
+866
View File
@@ -0,0 +1,866 @@
package store
import (
"context"
"encoding/json"
"fmt"
"sort"
"strconv"
"strings"
"time"
"github.com/redis/go-redis/v9"
"github.com/tinode/chat/server/store/types"
)
type redisConfig struct {
Addr string `json:"addr"`
Username string `json:"username"`
Password string `json:"password"`
DB int `json:"db"`
KeyPrefix string `json:"key_prefix"`
DialMS int `json:"dial_timeout_ms"`
ReadMS int `json:"read_timeout_ms"`
WriteMS int `json:"write_timeout_ms"`
PendingTTL int `json:"pending_ttl_seconds"`
DoneTTL int `json:"done_ttl_seconds"`
FailedTTL int `json:"failed_ttl_seconds"`
HotMsgTTL int `json:"hot_message_ttl_seconds"`
PresenceTTL int `json:"presence_ttl_seconds"`
CacheTTL int `json:"cache_ttl_seconds"`
ShortTTL int `json:"short_state_ttl_seconds"`
GroupFanout bool `json:"group_fanout_enabled"`
}
type groupMessageUniqueRecord struct {
Status string `json:"status"`
Seq int64 `json:"seq,omitempty"`
MessageID string `json:"message_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type groupFanoutEnvelope struct {
OriginNodeID string `json:"origin_node_id"`
Message json.RawMessage `json:"message"`
}
type groupMessageRuntime interface {
Close() error
Claim(tenantID types.TenantID, topic string, from types.Uid, clientMsgID string) (*groupMessageUniqueRecord, bool, error)
NextSeq(tenantID types.TenantID, topic string, baseline int64) (int64, error)
MarkDone(tenantID types.TenantID, topic string, from types.Uid, clientMsgID string, seq int64, msgID string) error
MarkFailed(tenantID types.TenantID, topic string, from types.Uid, clientMsgID string) error
CacheHotMessage(tenantID types.TenantID, msg *types.Message) error
GetHotMessages(tenantID types.TenantID, topic string, opts *types.QueryOpt, maxLimit int) ([]types.Message, bool, error)
InvalidateHotMessages(tenantID types.TenantID, topic string, ranges []types.Range) error
MarkUserOnline(tenantID types.TenantID, uid types.Uid, sid, nodeID, device, userAgent string) error
RefreshUserPresence(tenantID types.TenantID, uid types.Uid, sid, nodeID string) error
MarkUserOffline(tenantID types.TenantID, uid types.Uid, sid, nodeID string) error
GetUserPresence(tenantID types.TenantID, uid types.Uid) (*UserPresence, error)
MarkTopicMemberOnline(tenantID types.TenantID, topic string, uid types.Uid, nodeID string) error
MarkTopicMemberOffline(tenantID types.TenantID, topic string, uid types.Uid, nodeID string) error
GetTopicNodeOnlineMembers(tenantID types.TenantID, topic, nodeID string) ([]types.Uid, error)
PublishGroupFanout(originNodeID string, message any) error
StartGroupFanoutSubscriber(ctx context.Context, nodeID string, handler func([]byte)) error
CacheObject(tenantID types.TenantID, namespace, id string, value any) error
GetCachedObject(tenantID types.TenantID, namespace, id string, value any) (bool, error)
DeleteCachedObject(tenantID types.TenantID, namespace, id string) error
AddCachedSetMember(tenantID types.TenantID, namespace, id, member string) error
RemoveCachedSetMember(tenantID types.TenantID, namespace, id, member string) error
CachedSetContains(tenantID types.TenantID, namespace, id, member string) (bool, error)
AllowRate(tenantID types.TenantID, scope string, limit int, window time.Duration) (bool, int, time.Duration, error)
}
var groupMsgRuntime groupMessageRuntime
type UserPresence struct {
Online bool `json:"online"`
NodeID string `json:"node_id"`
SessionCount int `json:"session_count"`
LastActiveAt time.Time `json:"last_active_at"`
LastSeenAt time.Time `json:"last_seen_at"`
}
func MarkUserOnline(tenantID types.TenantID, uid types.Uid, sid, nodeID, device, userAgent string) error {
if groupMsgRuntime == nil || !tenantID.IsValid() || uid.IsZero() || sid == "" {
return nil
}
return groupMsgRuntime.MarkUserOnline(tenantID, uid, sid, nodeID, device, userAgent)
}
func RefreshUserPresence(tenantID types.TenantID, uid types.Uid, sid, nodeID string) error {
if groupMsgRuntime == nil || !tenantID.IsValid() || uid.IsZero() || sid == "" {
return nil
}
return groupMsgRuntime.RefreshUserPresence(tenantID, uid, sid, nodeID)
}
func MarkUserOffline(tenantID types.TenantID, uid types.Uid, sid, nodeID string) error {
if groupMsgRuntime == nil || !tenantID.IsValid() || uid.IsZero() || sid == "" {
return nil
}
return groupMsgRuntime.MarkUserOffline(tenantID, uid, sid, nodeID)
}
func GetUserPresence(tenantID types.TenantID, uid types.Uid) (*UserPresence, error) {
if groupMsgRuntime == nil || !tenantID.IsValid() || uid.IsZero() {
return &UserPresence{}, nil
}
return groupMsgRuntime.GetUserPresence(tenantID, uid)
}
func UserPresenceEnabled() bool {
return groupMsgRuntime != nil
}
func MarkTopicMemberOnline(tenantID types.TenantID, topic string, uid types.Uid, nodeID string) error {
if groupMsgRuntime == nil || !tenantID.IsValid() || topic == "" || uid.IsZero() {
return nil
}
return groupMsgRuntime.MarkTopicMemberOnline(tenantID, topic, uid, nodeID)
}
func MarkTopicMemberOffline(tenantID types.TenantID, topic string, uid types.Uid, nodeID string) error {
if groupMsgRuntime == nil || !tenantID.IsValid() || topic == "" || uid.IsZero() {
return nil
}
return groupMsgRuntime.MarkTopicMemberOffline(tenantID, topic, uid, nodeID)
}
func GetTopicNodeOnlineMembers(tenantID types.TenantID, topic, nodeID string) ([]types.Uid, error) {
if groupMsgRuntime == nil || !tenantID.IsValid() || topic == "" {
return nil, nil
}
return groupMsgRuntime.GetTopicNodeOnlineMembers(tenantID, topic, nodeID)
}
func PublishGroupFanout(originNodeID string, message any) error {
if groupMsgRuntime == nil || originNodeID == "" || message == nil {
return nil
}
return groupMsgRuntime.PublishGroupFanout(originNodeID, message)
}
func StartGroupFanoutSubscriber(ctx context.Context, nodeID string, handler func([]byte)) error {
if groupMsgRuntime == nil || nodeID == "" || handler == nil {
return nil
}
return groupMsgRuntime.StartGroupFanoutSubscriber(ctx, nodeID, handler)
}
func CacheObject(tenantID types.TenantID, namespace, id string, value any) error {
if groupMsgRuntime == nil || !tenantID.IsValid() || namespace == "" || id == "" || value == nil {
return nil
}
return groupMsgRuntime.CacheObject(tenantID, namespace, id, value)
}
func GetCachedObject(tenantID types.TenantID, namespace, id string, value any) (bool, error) {
if groupMsgRuntime == nil || !tenantID.IsValid() || namespace == "" || id == "" || value == nil {
return false, nil
}
return groupMsgRuntime.GetCachedObject(tenantID, namespace, id, value)
}
func DeleteCachedObject(tenantID types.TenantID, namespace, id string) error {
if groupMsgRuntime == nil || !tenantID.IsValid() || namespace == "" || id == "" {
return nil
}
return groupMsgRuntime.DeleteCachedObject(tenantID, namespace, id)
}
func AddCachedSetMember(tenantID types.TenantID, namespace, id, member string) error {
if groupMsgRuntime == nil || !tenantID.IsValid() || namespace == "" || id == "" || member == "" {
return nil
}
return groupMsgRuntime.AddCachedSetMember(tenantID, namespace, id, member)
}
func RemoveCachedSetMember(tenantID types.TenantID, namespace, id, member string) error {
if groupMsgRuntime == nil || !tenantID.IsValid() || namespace == "" || id == "" || member == "" {
return nil
}
return groupMsgRuntime.RemoveCachedSetMember(tenantID, namespace, id, member)
}
func CachedSetContains(tenantID types.TenantID, namespace, id, member string) (bool, error) {
if groupMsgRuntime == nil || !tenantID.IsValid() || namespace == "" || id == "" || member == "" {
return false, nil
}
return groupMsgRuntime.CachedSetContains(tenantID, namespace, id, member)
}
func AllowRate(tenantID types.TenantID, scope string, limit int, window time.Duration) (bool, int, time.Duration, error) {
if groupMsgRuntime == nil || !tenantID.IsValid() || scope == "" || limit <= 0 || window <= 0 {
return true, 0, 0, nil
}
return groupMsgRuntime.AllowRate(tenantID, scope, limit, window)
}
func initGroupMessageRuntime(raw json.RawMessage) error {
closeGroupMessageRuntime()
if len(raw) == 0 || string(raw) == "null" {
return nil
}
var cfg redisConfig
if err := json.Unmarshal(raw, &cfg); err != nil {
return err
}
cfg.Addr = strings.TrimSpace(cfg.Addr)
if cfg.Addr == "" {
return nil
}
if cfg.KeyPrefix == "" {
cfg.KeyPrefix = "im"
}
if cfg.PendingTTL <= 0 {
cfg.PendingTTL = 120
}
if cfg.DoneTTL <= 0 {
cfg.DoneTTL = 86400
}
if cfg.FailedTTL <= 0 {
cfg.FailedTTL = 600
}
if cfg.HotMsgTTL <= 0 {
cfg.HotMsgTTL = 86400
}
if cfg.PresenceTTL <= 0 {
cfg.PresenceTTL = 120
}
if cfg.CacheTTL <= 0 {
cfg.CacheTTL = 300
}
if cfg.ShortTTL <= 0 {
cfg.ShortTTL = 600
}
dialTimeout := timeoutOrDefault(cfg.DialMS, 2*time.Second)
readTimeout := timeoutOrDefault(cfg.ReadMS, 2*time.Second)
writeTimeout := timeoutOrDefault(cfg.WriteMS, 2*time.Second)
client := redis.NewClient(&redis.Options{
Addr: cfg.Addr,
Username: cfg.Username,
Password: cfg.Password,
DB: cfg.DB,
DialTimeout: dialTimeout,
ReadTimeout: readTimeout,
WriteTimeout: writeTimeout,
})
ctx, cancel := context.WithTimeout(context.Background(), dialTimeout)
defer cancel()
if err := client.Ping(ctx).Err(); err != nil {
_ = client.Close()
return err
}
groupMsgRuntime = &redisGroupMessageRuntime{client: client, prefix: cfg.KeyPrefix, cfg: cfg}
return nil
}
func closeGroupMessageRuntime() {
if groupMsgRuntime != nil {
_ = groupMsgRuntime.Close()
groupMsgRuntime = nil
}
}
func timeoutOrDefault(ms int, fallback time.Duration) time.Duration {
if ms <= 0 {
return fallback
}
return time.Duration(ms) * time.Millisecond
}
type redisGroupMessageRuntime struct {
client *redis.Client
prefix string
cfg redisConfig
}
func (r *redisGroupMessageRuntime) Close() error {
return r.client.Close()
}
func (r *redisGroupMessageRuntime) Claim(tenantID types.TenantID, topic string, from types.Uid, clientMsgID string) (*groupMessageUniqueRecord, bool, error) {
if clientMsgID == "" {
return nil, true, nil
}
key := r.uniqueKey(tenantID, topic, from, clientMsgID)
rec := &groupMessageUniqueRecord{Status: "pending", CreatedAt: time.Now().UTC()}
raw, _ := json.Marshal(rec)
ctx := context.Background()
ok, err := r.client.SetNX(ctx, key, raw, time.Duration(r.cfg.PendingTTL)*time.Second).Result()
if err != nil {
return nil, false, err
}
if ok {
return rec, true, nil
}
existing, err := r.client.Get(ctx, key).Bytes()
if err == redis.Nil {
return nil, false, nil
}
if err != nil {
return nil, false, err
}
var current groupMessageUniqueRecord
if err = json.Unmarshal(existing, &current); err != nil {
return nil, false, err
}
if current.Status == "failed" {
_ = r.client.Del(ctx, key).Err()
ok, err = r.client.SetNX(ctx, key, raw, time.Duration(r.cfg.PendingTTL)*time.Second).Result()
if err != nil {
return nil, false, err
}
if ok {
return rec, true, nil
}
}
return &current, false, nil
}
func (r *redisGroupMessageRuntime) NextSeq(tenantID types.TenantID, topic string, baseline int64) (int64, error) {
if baseline < 0 {
baseline = 0
}
seqKey := r.seqKey(tenantID, topic)
seq, err := r.client.Eval(context.Background(), `
if redis.call("EXISTS", KEYS[1]) == 0 then
redis.call("SET", KEYS[1], ARGV[1])
end
return redis.call("INCR", KEYS[1])
`, []string{seqKey}, baseline).Int64()
if err != nil {
return 0, err
}
return seq, nil
}
func (r *redisGroupMessageRuntime) MarkDone(tenantID types.TenantID, topic string, from types.Uid, clientMsgID string, seq int64, msgID string) error {
if clientMsgID == "" {
return nil
}
rec := &groupMessageUniqueRecord{Status: "done", Seq: seq, MessageID: msgID, CreatedAt: time.Now().UTC()}
raw, _ := json.Marshal(rec)
return r.client.Set(context.Background(), r.uniqueKey(tenantID, topic, from, clientMsgID), raw, time.Duration(r.cfg.DoneTTL)*time.Second).Err()
}
func (r *redisGroupMessageRuntime) MarkFailed(tenantID types.TenantID, topic string, from types.Uid, clientMsgID string) error {
if clientMsgID == "" {
return nil
}
rec := &groupMessageUniqueRecord{Status: "failed", CreatedAt: time.Now().UTC()}
raw, _ := json.Marshal(rec)
return r.client.Set(context.Background(), r.uniqueKey(tenantID, topic, from, clientMsgID), raw, time.Duration(r.cfg.FailedTTL)*time.Second).Err()
}
func (r *redisGroupMessageRuntime) CacheHotMessage(tenantID types.TenantID, msg *types.Message) error {
if msg == nil || msg.Topic == "" || msg.SeqId <= 0 {
return types.ErrMalformed
}
payload, err := json.Marshal(msg)
if err != nil {
return err
}
ctx := context.Background()
ttl := time.Duration(r.cfg.HotMsgTTL) * time.Second
seq := strconv.FormatInt(msg.SeqId, 10)
pipe := r.client.Pipeline()
pipe.Set(ctx, r.hotMsgKey(tenantID, msg.Topic, msg.SeqId), payload, ttl)
pipe.ZAdd(ctx, r.hotSeqKey(tenantID, msg.Topic), redis.Z{Score: float64(msg.SeqId), Member: seq})
pipe.Expire(ctx, r.hotSeqKey(tenantID, msg.Topic), ttl+time.Hour)
_, err = pipe.Exec(ctx)
return err
}
func (r *redisGroupMessageRuntime) GetHotMessages(tenantID types.TenantID, topic string, opts *types.QueryOpt, maxLimit int) ([]types.Message, bool, error) {
if topic == "" {
return nil, false, types.ErrMalformed
}
limit := maxLimit
if limit <= 0 {
limit = 100
}
if opts != nil && opts.Limit > 0 && opts.Limit < limit {
limit = opts.Limit
}
seqs, completeRange, err := r.hotSeqIDs(tenantID, topic, opts, limit)
if err != nil || len(seqs) == 0 || !completeRange {
return nil, false, err
}
keys := make([]string, 0, len(seqs))
for _, seq := range seqs {
keys = append(keys, r.hotMsgKey(tenantID, topic, seq))
}
values, err := r.client.MGet(context.Background(), keys...).Result()
if err != nil {
return nil, false, err
}
if len(values) != len(seqs) {
return nil, false, nil
}
msgs := make([]types.Message, 0, len(values))
for _, value := range values {
if value == nil {
return nil, false, nil
}
raw, ok := value.(string)
if !ok {
return nil, false, nil
}
var msg types.Message
if err := json.Unmarshal([]byte(raw), &msg); err != nil {
return nil, false, err
}
msg.TenantID = tenantID
msgs = append(msgs, msg)
}
return msgs, true, nil
}
func (r *redisGroupMessageRuntime) InvalidateHotMessages(tenantID types.TenantID, topic string, ranges []types.Range) error {
if topic == "" {
return types.ErrMalformed
}
seqs := expandRanges(ranges, int(^uint(0)>>1))
ctx := context.Background()
if len(seqs) == 0 {
pipe := r.client.Pipeline()
pipe.Del(ctx, r.hotSeqKey(tenantID, topic))
keys, err := r.client.Keys(ctx, r.hotMsgKey(tenantID, topic, "*")).Result()
if err != nil {
return err
}
if len(keys) > 0 {
pipe.Del(ctx, keys...)
}
_, err = pipe.Exec(ctx)
return err
}
pipe := r.client.Pipeline()
members := make([]any, 0, len(seqs))
for _, seq := range seqs {
pipe.Del(ctx, r.hotMsgKey(tenantID, topic, seq))
members = append(members, strconv.FormatInt(int64(seq), 10))
}
pipe.ZRem(ctx, r.hotSeqKey(tenantID, topic), members...)
_, err := pipe.Exec(ctx)
return err
}
func (r *redisGroupMessageRuntime) MarkUserOnline(tenantID types.TenantID, uid types.Uid, sid, nodeID, device, userAgent string) error {
if nodeID == "" {
nodeID = "local"
}
now := time.Now().UTC()
ttl := time.Duration(r.cfg.PresenceTTL) * time.Second
ctx := context.Background()
userKey := r.presenceUserKey(tenantID, uid)
sessionsKey := r.presenceSessionsKey(tenantID, uid)
sessionKey := r.presenceSessionKey(tenantID, uid, sid)
nodeKey := r.presenceNodeUsersKey(tenantID, nodeID)
member := nodeID + ":" + sid
pipe := r.client.Pipeline()
pipe.HSet(ctx, sessionKey, map[string]any{
"node_id": nodeID,
"session_id": sid,
"device": device,
"user_agent": userAgent,
"last_active_at": now.Format(time.RFC3339Nano),
})
pipe.Expire(ctx, sessionKey, ttl)
pipe.SAdd(ctx, sessionsKey, member)
pipe.Expire(ctx, sessionsKey, ttl)
pipe.SAdd(ctx, nodeKey, uid.UserId())
pipe.Expire(ctx, nodeKey, ttl)
pipe.HSet(ctx, userKey, map[string]any{
"online": "1",
"node_id": nodeID,
"last_active_at": now.Format(time.RFC3339Nano),
"last_seen_at": now.Format(time.RFC3339Nano),
})
pipe.Expire(ctx, userKey, ttl)
if _, err := pipe.Exec(ctx); err != nil {
return err
}
count, err := r.client.SCard(ctx, sessionsKey).Result()
if err != nil {
return err
}
return r.client.HSet(ctx, userKey, "session_count", count).Err()
}
func (r *redisGroupMessageRuntime) RefreshUserPresence(tenantID types.TenantID, uid types.Uid, sid, nodeID string) error {
if nodeID == "" {
nodeID = "local"
}
now := time.Now().UTC()
ttl := time.Duration(r.cfg.PresenceTTL) * time.Second
ctx := context.Background()
userKey := r.presenceUserKey(tenantID, uid)
sessionsKey := r.presenceSessionsKey(tenantID, uid)
sessionKey := r.presenceSessionKey(tenantID, uid, sid)
nodeKey := r.presenceNodeUsersKey(tenantID, nodeID)
pipe := r.client.Pipeline()
pipe.HSet(ctx, sessionKey, "last_active_at", now.Format(time.RFC3339Nano), "node_id", nodeID)
pipe.Expire(ctx, sessionKey, ttl)
pipe.Expire(ctx, sessionsKey, ttl)
pipe.SAdd(ctx, nodeKey, uid.UserId())
pipe.Expire(ctx, nodeKey, ttl)
pipe.HSet(ctx, userKey, "online", "1", "node_id", nodeID, "last_active_at", now.Format(time.RFC3339Nano), "last_seen_at", now.Format(time.RFC3339Nano))
pipe.Expire(ctx, userKey, ttl)
_, err := pipe.Exec(ctx)
return err
}
func (r *redisGroupMessageRuntime) MarkUserOffline(tenantID types.TenantID, uid types.Uid, sid, nodeID string) error {
if nodeID == "" {
nodeID = "local"
}
now := time.Now().UTC()
ttl := time.Duration(r.cfg.PresenceTTL) * time.Second
ctx := context.Background()
userKey := r.presenceUserKey(tenantID, uid)
sessionsKey := r.presenceSessionsKey(tenantID, uid)
sessionKey := r.presenceSessionKey(tenantID, uid, sid)
nodeKey := r.presenceNodeUsersKey(tenantID, nodeID)
member := nodeID + ":" + sid
pipe := r.client.Pipeline()
pipe.Del(ctx, sessionKey)
pipe.SRem(ctx, sessionsKey, member)
pipe.SRem(ctx, nodeKey, uid.UserId())
if _, err := pipe.Exec(ctx); err != nil {
return err
}
count, err := r.client.SCard(ctx, sessionsKey).Result()
if err != nil {
return err
}
if count > 0 {
pipe = r.client.Pipeline()
pipe.HSet(ctx, userKey, "online", "1", "session_count", count, "last_seen_at", now.Format(time.RFC3339Nano))
pipe.Expire(ctx, userKey, ttl)
pipe.Expire(ctx, sessionsKey, ttl)
_, err = pipe.Exec(ctx)
return err
}
pipe = r.client.Pipeline()
pipe.HSet(ctx, userKey, "online", "0", "session_count", 0, "node_id", nodeID, "last_seen_at", now.Format(time.RFC3339Nano))
pipe.Expire(ctx, userKey, ttl)
pipe.Del(ctx, sessionsKey)
_, err = pipe.Exec(ctx)
return err
}
func (r *redisGroupMessageRuntime) GetUserPresence(tenantID types.TenantID, uid types.Uid) (*UserPresence, error) {
raw, err := r.client.HGetAll(context.Background(), r.presenceUserKey(tenantID, uid)).Result()
if err != nil {
return nil, err
}
presence := &UserPresence{}
if len(raw) == 0 {
return presence, nil
}
presence.Online = raw["online"] == "1"
presence.NodeID = raw["node_id"]
presence.SessionCount, _ = strconv.Atoi(raw["session_count"])
presence.LastActiveAt, _ = time.Parse(time.RFC3339Nano, raw["last_active_at"])
presence.LastSeenAt, _ = time.Parse(time.RFC3339Nano, raw["last_seen_at"])
return presence, nil
}
func (r *redisGroupMessageRuntime) MarkTopicMemberOnline(tenantID types.TenantID, topic string, uid types.Uid, nodeID string) error {
if nodeID == "" {
nodeID = "local"
}
ttl := time.Duration(r.cfg.PresenceTTL) * time.Second
ctx := context.Background()
pipe := r.client.Pipeline()
pipe.SAdd(ctx, r.topicOnlineMembersKey(tenantID, topic), uid.UserId())
pipe.Expire(ctx, r.topicOnlineMembersKey(tenantID, topic), ttl)
pipe.SAdd(ctx, r.topicNodeOnlineMembersKey(tenantID, topic, nodeID), uid.UserId())
pipe.Expire(ctx, r.topicNodeOnlineMembersKey(tenantID, topic, nodeID), ttl)
_, err := pipe.Exec(ctx)
return err
}
func (r *redisGroupMessageRuntime) MarkTopicMemberOffline(tenantID types.TenantID, topic string, uid types.Uid, nodeID string) error {
if nodeID == "" {
nodeID = "local"
}
ctx := context.Background()
pipe := r.client.Pipeline()
pipe.SRem(ctx, r.topicOnlineMembersKey(tenantID, topic), uid.UserId())
pipe.SRem(ctx, r.topicNodeOnlineMembersKey(tenantID, topic, nodeID), uid.UserId())
_, err := pipe.Exec(ctx)
return err
}
func (r *redisGroupMessageRuntime) GetTopicNodeOnlineMembers(tenantID types.TenantID, topic, nodeID string) ([]types.Uid, error) {
if nodeID == "" {
nodeID = "local"
}
members, err := r.client.SMembers(context.Background(), r.topicNodeOnlineMembersKey(tenantID, topic, nodeID)).Result()
if err != nil {
return nil, err
}
uids := make([]types.Uid, 0, len(members))
for _, member := range members {
uid := types.ParseUserId(member)
if !uid.IsZero() {
uids = append(uids, uid)
}
}
return uids, nil
}
func (r *redisGroupMessageRuntime) PublishGroupFanout(originNodeID string, message any) error {
if !r.cfg.GroupFanout {
return nil
}
payload, err := json.Marshal(message)
if err != nil {
return err
}
envelope, err := json.Marshal(groupFanoutEnvelope{
OriginNodeID: originNodeID,
Message: payload,
})
if err != nil {
return err
}
return r.client.Publish(context.Background(), r.groupFanoutChannel(), envelope).Err()
}
func (r *redisGroupMessageRuntime) StartGroupFanoutSubscriber(ctx context.Context, nodeID string, handler func([]byte)) error {
if !r.cfg.GroupFanout {
return nil
}
pubsub := r.client.Subscribe(ctx, r.groupFanoutChannel())
if _, err := pubsub.Receive(ctx); err != nil {
_ = pubsub.Close()
return err
}
go func() {
defer pubsub.Close()
ch := pubsub.Channel()
for {
select {
case <-ctx.Done():
return
case msg, ok := <-ch:
if !ok {
return
}
var envelope groupFanoutEnvelope
if err := json.Unmarshal([]byte(msg.Payload), &envelope); err != nil || envelope.OriginNodeID == nodeID || len(envelope.Message) == 0 {
continue
}
handler(envelope.Message)
}
}
}()
return nil
}
func (r *redisGroupMessageRuntime) CacheObject(tenantID types.TenantID, namespace, id string, value any) error {
payload, err := json.Marshal(value)
if err != nil {
return err
}
return r.client.Set(context.Background(), r.objectCacheKey(tenantID, namespace, id), payload, r.cacheTTL(namespace)).Err()
}
func (r *redisGroupMessageRuntime) GetCachedObject(tenantID types.TenantID, namespace, id string, value any) (bool, error) {
payload, err := r.client.Get(context.Background(), r.objectCacheKey(tenantID, namespace, id)).Bytes()
if err == redis.Nil {
return false, nil
}
if err != nil {
return false, err
}
if err = json.Unmarshal(payload, value); err != nil {
_ = r.client.Del(context.Background(), r.objectCacheKey(tenantID, namespace, id)).Err()
return false, err
}
return true, nil
}
func (r *redisGroupMessageRuntime) DeleteCachedObject(tenantID types.TenantID, namespace, id string) error {
return r.client.Del(context.Background(), r.objectCacheKey(tenantID, namespace, id)).Err()
}
func (r *redisGroupMessageRuntime) AddCachedSetMember(tenantID types.TenantID, namespace, id, member string) error {
key := r.setCacheKey(tenantID, namespace, id)
ctx := context.Background()
pipe := r.client.Pipeline()
pipe.SAdd(ctx, key, member)
pipe.Expire(ctx, key, r.cacheTTL(namespace))
_, err := pipe.Exec(ctx)
return err
}
func (r *redisGroupMessageRuntime) RemoveCachedSetMember(tenantID types.TenantID, namespace, id, member string) error {
key := r.setCacheKey(tenantID, namespace, id)
ctx := context.Background()
pipe := r.client.Pipeline()
pipe.SRem(ctx, key, member)
pipe.Expire(ctx, key, r.cacheTTL(namespace))
_, err := pipe.Exec(ctx)
return err
}
func (r *redisGroupMessageRuntime) CachedSetContains(tenantID types.TenantID, namespace, id, member string) (bool, error) {
return r.client.SIsMember(context.Background(), r.setCacheKey(tenantID, namespace, id), member).Result()
}
func (r *redisGroupMessageRuntime) AllowRate(tenantID types.TenantID, scope string, limit int, window time.Duration) (bool, int, time.Duration, error) {
key := r.rateKey(tenantID, scope)
ctx := context.Background()
count, err := r.client.Incr(ctx, key).Result()
if err != nil {
return true, 0, 0, err
}
if count == 1 {
if err = r.client.Expire(ctx, key, window).Err(); err != nil {
return true, int(count), 0, err
}
}
ttl, _ := r.client.TTL(ctx, key).Result()
return count <= int64(limit), int(count), ttl, nil
}
func (r *redisGroupMessageRuntime) hotSeqIDs(tenantID types.TenantID, topic string, opts *types.QueryOpt, limit int) ([]int64, bool, error) {
if opts != nil && len(opts.IdRanges) > 0 {
seqs := expandRanges(opts.IdRanges, limit)
sort.Slice(seqs, func(i, j int) bool { return seqs[i] > seqs[j] })
return seqs, len(seqs) > 0 && len(seqs) <= limit, nil
}
min := "-inf"
max := "+inf"
if opts != nil {
if opts.Since > 0 {
min = strconv.FormatInt(opts.Since, 10)
}
if opts.Before > 1 {
max = strconv.FormatInt(opts.Before-1, 10)
}
}
raw, err := r.client.ZRevRangeByScore(context.Background(), r.hotSeqKey(tenantID, topic), &redis.ZRangeBy{
Min: min,
Max: max,
Offset: 0,
Count: int64(limit),
}).Result()
if err != nil {
return nil, false, err
}
if len(raw) < limit {
return nil, false, nil
}
seqs := make([]int64, 0, len(raw))
for _, value := range raw {
seq, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return nil, false, err
}
seqs = append(seqs, seq)
}
return seqs, true, nil
}
func expandRanges(ranges []types.Range, limit int) []int64 {
if limit <= 0 {
return nil
}
seqs := make([]int64, 0)
for _, rng := range ranges {
if rng.Low <= 0 {
continue
}
if rng.Hi == 0 {
seqs = append(seqs, rng.Low)
} else {
for seq := rng.Low; seq < rng.Hi; seq++ {
seqs = append(seqs, seq)
if len(seqs) >= limit {
return seqs
}
}
}
if len(seqs) >= limit {
return seqs
}
}
return seqs
}
func (r *redisGroupMessageRuntime) seqKey(tenantID types.TenantID, topic string) string {
return r.key(tenantID, "topic", topic, "last_seq")
}
func (r *redisGroupMessageRuntime) uniqueKey(tenantID types.TenantID, topic string, from types.Uid, clientMsgID string) string {
return r.key(tenantID, "topic", topic, "uniq", "msg", from.UserId(), clientMsgID)
}
func (r *redisGroupMessageRuntime) hotSeqKey(tenantID types.TenantID, topic string) string {
return r.key(tenantID, "topic", topic, "hot_msg_seqs")
}
func (r *redisGroupMessageRuntime) hotMsgKey(tenantID types.TenantID, topic string, seq any) string {
return r.key(tenantID, "topic", topic, "hot_msg", fmt.Sprint(seq))
}
func (r *redisGroupMessageRuntime) presenceUserKey(tenantID types.TenantID, uid types.Uid) string {
return r.key(tenantID, "presence", "user", uid.UserId())
}
func (r *redisGroupMessageRuntime) presenceSessionsKey(tenantID types.TenantID, uid types.Uid) string {
return r.key(tenantID, "presence", "user", uid.UserId(), "sessions")
}
func (r *redisGroupMessageRuntime) presenceSessionKey(tenantID types.TenantID, uid types.Uid, sid string) string {
return r.key(tenantID, "presence", "user", uid.UserId(), "session", sid)
}
func (r *redisGroupMessageRuntime) presenceNodeUsersKey(tenantID types.TenantID, nodeID string) string {
return r.key(tenantID, "presence", "node", nodeID, "users")
}
func (r *redisGroupMessageRuntime) topicOnlineMembersKey(tenantID types.TenantID, topic string) string {
return r.key(tenantID, "topic", topic, "online_members")
}
func (r *redisGroupMessageRuntime) topicNodeOnlineMembersKey(tenantID types.TenantID, topic, nodeID string) string {
return r.key(tenantID, "topic", topic, "node", nodeID, "online_members")
}
func (r *redisGroupMessageRuntime) groupFanoutChannel() string {
return r.key(types.ZeroTenantID, "group_fanout")
}
func (r *redisGroupMessageRuntime) objectCacheKey(tenantID types.TenantID, namespace, id string) string {
return r.key(tenantID, "cache", namespace, id)
}
func (r *redisGroupMessageRuntime) setCacheKey(tenantID types.TenantID, namespace, id string) string {
return r.key(tenantID, "set", namespace, id)
}
func (r *redisGroupMessageRuntime) rateKey(tenantID types.TenantID, scope string) string {
return r.key(tenantID, "rate", scope)
}
func (r *redisGroupMessageRuntime) cacheTTL(namespace string) time.Duration {
ttl := r.cfg.CacheTTL
if namespace == "verification" || namespace == "action_token" {
ttl = r.cfg.ShortTTL
}
return time.Duration(ttl) * time.Second
}
func (r *redisGroupMessageRuntime) key(tenantID types.TenantID, parts ...string) string {
all := []string{strings.TrimSuffix(r.prefix, ":"), strconv.FormatInt(int64(tenantID), 10)}
all = append(all, parts...)
return strings.Join(all, ":")
}
+3 -3
View File
@@ -1114,7 +1114,7 @@ func (mr *MockMessagesPersistenceInterfaceMockRecorder) DeleteList(tenantID, top
}
// Edit mocks base method.
func (m *MockMessagesPersistenceInterface) Edit(tenantID types.TenantID, topic string, seq int, actor types.Uid, expectedVersion int, head types.KVMap, content any, reason string, editedAt time.Time) (*types.Message, error) {
func (m *MockMessagesPersistenceInterface) Edit(tenantID types.TenantID, topic string, seq int64, actor types.Uid, expectedVersion int, head types.KVMap, content any, reason string, editedAt time.Time) (*types.Message, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Edit", tenantID, topic, seq, actor, expectedVersion, head, content, reason, editedAt)
ret0, _ := ret[0].(*types.Message)
@@ -1176,7 +1176,7 @@ func (mr *MockMessagesPersistenceInterfaceMockRecorder) Search(tenantID, topic,
}
// GetOne mocks base method.
func (m *MockMessagesPersistenceInterface) GetOne(tenantID types.TenantID, topic string, seq int) (*types.Message, error) {
func (m *MockMessagesPersistenceInterface) GetOne(tenantID types.TenantID, topic string, seq int64) (*types.Message, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetOne", tenantID, topic, seq)
ret0, _ := ret[0].(*types.Message)
@@ -1191,7 +1191,7 @@ func (mr *MockMessagesPersistenceInterfaceMockRecorder) GetOne(tenantID, topic,
}
// Recall mocks base method.
func (m *MockMessagesPersistenceInterface) Recall(tenantID types.TenantID, topic string, seq int, actor types.Uid, expectedVersion int, recalledAt time.Time) (*types.Message, error) {
func (m *MockMessagesPersistenceInterface) Recall(tenantID types.TenantID, topic string, seq int64, actor types.Uid, expectedVersion int, recalledAt time.Time) (*types.Message, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Recall", tenantID, topic, seq, actor, expectedVersion, recalledAt)
ret0, _ := ret[0].(*types.Message)
+645 -47
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -16,7 +16,7 @@ type CallRecord struct {
TenantID TenantID `db:"tenant_id" json:"-" bson:"tenant_id"`
CallID string `db:"call_id"`
Topic string
SeqID int `db:"seq_id"`
SeqID int64 `db:"seq_id"`
CallerID Uid `db:"caller_id"`
CalleeID Uid `db:"callee_id"`
CallType string `db:"call_type"`
+1 -1
View File
@@ -9,7 +9,7 @@ type FavoriteItem struct {
UserID Uid `db:"user_id"`
ItemType string `db:"item_type"`
SourceTopic string `db:"source_topic"`
SourceSeq int `db:"source_seq"`
SourceSeq int64 `db:"source_seq"`
Snapshot any
AttachmentRefs any `db:"attachment_refs"`
Availability string `db:"availability"`
+1 -1
View File
@@ -187,7 +187,7 @@ type GroupFile struct {
Topic string `db:"topic"`
FileID string `db:"file_id"`
SourceTopic string `db:"source_topic"`
SourceSeq int `db:"source_seq"`
SourceSeq int64 `db:"source_seq"`
UploaderID Uid `db:"-"`
FileName string `db:"file_name"`
MIMEType string `db:"mime_type"`
+1 -1
View File
@@ -63,7 +63,7 @@ type AbuseReport struct {
TargetType string
TargetUserID Uid
TargetTopic string
TargetSeq int
TargetSeq int64
ReasonCode string
Description string
EvidenceFileIDs []string
+14 -14
View File
@@ -922,9 +922,9 @@ type Subscription struct {
// ID of the latest Soft-delete operation
DelId int
// Last SeqId reported by user as received by at least one of his sessions
RecvSeqId int
RecvSeqId int64
// Last SeqID reported read by the user
ReadSeqId int
ReadSeqId int64
// Access mode requested by this user
ModeWant AccessMode
@@ -941,7 +941,7 @@ type Subscription struct {
// In case of P2P topics this is the Trusted value of the other user.
trusted any
// deserialized SeqID from user or topic
seqId int
seqId int64
// Deserialized TouchedAt from topic
touchedAt time.Time
// Timestamp & user agent of when the user was last online.
@@ -1013,12 +1013,12 @@ func (s *Subscription) LastModified() time.Time {
}
// GetSeqId returns seqId.
func (s *Subscription) GetSeqId() int {
func (s *Subscription) GetSeqId() int64 {
return s.seqId
}
// SetSeqId sets seqId field.
func (s *Subscription) SetSeqId(id int) {
func (s *Subscription) SetSeqId(id int64) {
s.seqId = id
}
@@ -1144,7 +1144,7 @@ type Topic struct {
Access DefaultAccess
// Server-issued sequential ID
SeqId int
SeqId int64
// If messages were deleted, sequential id of the last operation to delete them
DelId int
@@ -1239,7 +1239,7 @@ type Message struct {
DelId int `json:"DelId,omitempty" bson:",omitempty"`
// List of users who have marked this message as soft-deleted
DeletedFor []SoftDelete `json:"DeletedFor,omitempty" bson:",omitempty"`
SeqId int
SeqId int64
Topic string
// Sender's user ID as string (without 'usr' prefix), could be empty.
From string
@@ -1256,7 +1256,7 @@ type MessageRevision struct {
ObjHeader `bson:",inline"`
TenantID TenantID `db:"tenant_id" json:"-" bson:"tenant_id"`
Topic string
SeqId int
SeqId int64
Version int
Actor string
Head KVMap `json:"Head,omitempty" bson:",omitempty"`
@@ -1266,7 +1266,7 @@ type MessageRevision struct {
// MessageSaveResult describes the outcome of a message persistence attempt.
type MessageSaveResult struct {
SeqId int
SeqId int64
MarkedReadBySender bool
Duplicate bool
}
@@ -1274,8 +1274,8 @@ type MessageSaveResult struct {
// Range is a range of message SeqIDs. Low end is inclusive (closed), high end is exclusive (open): [Low, Hi).
// If the range contains just one ID, Hi is set to 0
type Range struct {
Low int
Hi int `json:"Hi,omitempty" bson:",omitempty"`
Low int64
Hi int64 `json:"Hi,omitempty" bson:",omitempty"`
}
// RangeSorter is a helper type required by 'sort' package.
@@ -1334,7 +1334,7 @@ func (rs RangeSorter) Normalize() RangeSorter {
// Convert a slice of int values to a slice of ranges.
// The int slice must be sorted low -> high.
func SliceToRanges(in []int) []Range {
func SliceToRanges(in []int64) []Range {
if len(in) == 0 {
return nil
}
@@ -1390,8 +1390,8 @@ type QueryOpt struct {
Topic string
IfModifiedSince *time.Time
// ID-based query parameters: Messages
Since int
Before int
Since int64
Before int64
// Common parameter
Limit int
// Ranges of IDs.
+18
View File
@@ -85,6 +85,24 @@
"conn_max_lifetime": 60,
"sql_timeout": 10
}
},
"redis": {
"addr": "64.118.137.182:56379",
"username": "",
"password": "HMTJzkm.h>Y+E9ep",
"db": 0,
"key_prefix": "im",
"dial_timeout_ms": 2000,
"read_timeout_ms": 2000,
"write_timeout_ms": 2000,
"pending_ttl_seconds": 120,
"done_ttl_seconds": 86400,
"failed_ttl_seconds": 600,
"hot_message_ttl_seconds": 86400,
"presence_ttl_seconds": 120,
"cache_ttl_seconds": 300,
"short_state_ttl_seconds": 600,
"group_fanout_enabled": true
}
},
+47 -14
View File
@@ -45,7 +45,7 @@ type Topic struct {
touched time.Time
// Server-side ID of the last data message
lastID int
lastID int64
// ID of the deletion operation. Not an ID of the message.
delID int
@@ -142,8 +142,8 @@ type perUserData struct {
online int
// Last t.lastId reported by user through {pres} as received or read
recvID int
readID int
recvID int64
readID int64
// ID of the latest Delete operation
delID int
@@ -646,14 +646,14 @@ func (t *Topic) handleServerMsg(msg *ServerComMessage) {
logs.Warn.Printf("topic[%s]: rejected cross-tenant server message: topic=%d message=%d", t.name, t.tenantID, msg.TenantID)
return
}
// Server-generated message: {info} or {pres}.
// Server-generated message: {data}, {info} or {pres}.
if t.isInactive() {
// Ignore message - the topic is paused or being deleted.
return
}
if msg.Pres != nil {
t.handlePresence(msg)
} else if msg.Info != nil {
} else if msg.Info != nil || msg.Data != nil {
t.broadcastToSessions(msg)
} else {
// TODO(gene): maybe remove this panic.
@@ -797,6 +797,11 @@ func (t *Topic) handleLeaveRequest(msg *ClientComMessage, sess *Session) {
if !sess.background {
pud.online--
t.perUser[uid] = pud
if t.cat == types.TopicCatGrp && pud.online == 0 {
if err := store.MarkTopicMemberOffline(t.tenantID, t.name, uid, currentNodeID()); err != nil {
logs.Warn.Printf("topic[%s]: failed to mark member offline in redis: %v", t.name, err)
}
}
}
} else if len(pssd.muids) > 0 {
// UID is zero: multiplexing session is dropped altogether.
@@ -805,6 +810,11 @@ func (t *Topic) handleLeaveRequest(msg *ClientComMessage, sess *Session) {
pud := t.perUser[uid]
pud.online--
t.perUser[uid] = pud
if t.cat == types.TopicCatGrp && pud.online == 0 {
if err := store.MarkTopicMemberOffline(t.tenantID, t.name, uid, currentNodeID()); err != nil {
logs.Warn.Printf("topic[%s]: failed to mark member offline in redis: %v", t.name, err)
}
}
}
} else if !sess.isCluster() {
logs.Warn.Panic("cannot determine uid: leave req", msg, sess)
@@ -1119,8 +1129,11 @@ func (t *Topic) saveAndBroadcastMessage(msg *ClientComMessage, asUid types.Uid,
return nil
}
markedReadBySender := saveResult != nil && saveResult.MarkedReadBySender
t.lastID++
if saveResult != nil && saveResult.SeqId > t.lastID {
t.lastID = saveResult.SeqId
} else {
t.lastID++
}
t.touched = msg.Timestamp
if userFound {
@@ -1163,6 +1176,11 @@ func (t *Topic) saveAndBroadcastMessage(msg *ClientComMessage, asUid types.Uid,
pluginMessage(t.tenantID, data.Data, plgActCreate)
t.broadcastToSessions(data)
if t.cat == types.TopicCatGrp {
if err := store.PublishGroupFanout(currentNodeID(), data); err != nil {
logs.Warn.Printf("topic[%s]: failed to publish redis group fanout: %v", t.name, err)
}
}
// sendPush will update unread message count and send push notification.
if pushRcpt := t.pushForData(asUid, data.Data, markedReadBySender); pushRcpt != nil {
@@ -1332,7 +1350,7 @@ func (t *Topic) handleNoteBroadcast(msg *ClientComMessage) {
return
}
var read, recv, unread, seq int
var read, recv, unread, seq int64
switch msg.Note.What {
case "read":
@@ -1593,8 +1611,14 @@ func (t *Topic) subscriptionReply(asChan bool, msg *ClientComMessage) error {
// The user is online in the topic. Increment the counter if notifications are not deferred.
if !msg.sess.background {
userData := t.perUser[asUid]
wasOffline := userData.online == 0
userData.online++
t.perUser[asUid] = userData
if t.cat == types.TopicCatGrp && wasOffline {
if err := store.MarkTopicMemberOnline(t.tenantID, t.name, asUid, currentNodeID()); err != nil {
logs.Warn.Printf("topic[%s]: failed to mark member online in redis: %v", t.name, err)
}
}
}
if t.cat == types.TopicCatGrp && msgsub.Newsub {
@@ -2342,7 +2366,7 @@ func (t *Topic) replyGetDesc(sess *Session, asUid types.Uid, _ bool, opts *MsgGe
// t.delID <= pud.delID; t.readID <= t.recvID <= t.lastID
desc.DelId = max(pud.delID, t.delID)
desc.ReadSeqId = pud.readID
desc.RecvSeqId = max(pud.recvID, pud.readID)
desc.RecvSeqId = max64(pud.recvID, pud.readID)
} else {
// Send some sane value of touched.
desc.TouchedAt = &t.updated
@@ -2755,12 +2779,18 @@ func (t *Topic) replyGetSub(sess *Session, asUid types.Uid, authLevel auth.Level
mts.Topic = with
canViewLastSeen = imCanViewLastSeen(t.tenantID, asUid, types.ParseUserId(with))
mts.Online = t.perSubs[with].online && !deleted && presencer
if !deleted && presencer {
mts.Online = redisUserOnline(t.tenantID, types.ParseUserId(with), mts.Online)
}
} else if strings.HasPrefix(sub.Topic, "slf") {
mts.Topic = "slf"
// Not reporting Online as it makes no sense for slf.
} else {
mts.Topic = sub.Topic
mts.Online = t.perSubs[sub.Topic].online && !deleted && presencer
if !deleted && presencer && strings.HasPrefix(sub.Topic, "usr") {
mts.Online = redisUserOnline(t.tenantID, types.ParseUserId(sub.Topic), mts.Online)
}
}
if !deleted && !banned {
@@ -2808,6 +2838,9 @@ func (t *Topic) replyGetSub(sess *Session, asUid types.Uid, authLevel auth.Level
if t.cat == types.TopicCatGrp {
pud := t.perUser[uid]
mts.Online = pud.online > 0 && presencer
if presencer {
mts.Online = redisUserOnline(t.tenantID, uid, mts.Online)
}
}
}
}
@@ -3392,7 +3425,7 @@ func (t *Topic) replyDelMsg(sess *Session, asUid types.Uid, asChan bool, msg *Cl
if dq.HiId == 0 {
count++
} else {
count += dq.HiId - dq.LowId
count += int(dq.HiId - dq.LowId)
}
ranges = append(ranges, types.Range{Low: dq.LowId, Hi: dq.HiId})
@@ -3440,7 +3473,7 @@ func (t *Topic) replyDelMsg(sess *Session, asUid types.Uid, asChan bool, msg *Cl
unreadDeleted := calculateUnreadInRanges(pud.readID, t.lastID, ranges)
if unreadDeleted > 0 {
// Decrease unread count (negative value)
usersUpdateUnread(t.tenantID, uid, -unreadDeleted, true)
usersUpdateUnread(t.tenantID, uid, -int64(unreadDeleted), true)
}
}
}
@@ -4183,7 +4216,7 @@ func topicNameForUser(name string, uid types.Uid, isChan bool) string {
// calculateUnreadInRanges calculates how many unread messages are within the given ranges.
// unreadStart is the first unread message SeqId (readID + 1), unreadEnd is the last possible message SeqId.
// Assumes ranges are sorted by Low ascending.
func calculateUnreadInRanges(readID, lastID int, ranges []types.Range) int {
func calculateUnreadInRanges(readID, lastID int64, ranges []types.Range) int {
if readID >= lastID {
// No unread messages
return 0
@@ -4207,11 +4240,11 @@ func calculateUnreadInRanges(readID, lastID int, ranges []types.Range) int {
}
// Find intersection of [unreadStart, unreadEnd] and [rangeStart, rangeEnd)
intersectionStart := max(unreadStart, rangeStart)
intersectionStart := max64(unreadStart, rangeStart)
intersectionEnd := min(unreadEnd+1, rangeEnd) // +1 because unreadEnd is inclusive
if intersectionStart < intersectionEnd {
count += intersectionEnd - intersectionStart
count += int(intersectionEnd - intersectionStart)
}
}
+3 -1
View File
@@ -55,7 +55,9 @@ func (t *Topic) runProxy(hub *Hub) {
}
case msg := <-t.serverMsg:
if msg.Info != nil || msg.Pres != nil {
if msg.Data != nil {
t.broadcastToSessions(msg)
} else if msg.Info != nil || msg.Pres != nil {
globals.cluster.routeToTopicIntraCluster(t.name, msg, msg.sess)
} else {
// FIXME: should something be done here?
+12 -12
View File
@@ -777,7 +777,7 @@ func TestHandleBroadcastDataDuplicateClientMessage(t *testing.T) {
if !ok {
t.Fatalf("User1: expected ctrl params map, received %#v", ack.Ctrl.Params)
}
if seq, ok := params["seq"].(int); !ok || seq != 4 {
if seq, ok := params["seq"].(int64); !ok || seq != 4 {
t.Errorf("User1: expected duplicate ack seq 4, received %#v", params["seq"])
}
if len(helper.results[1].messages) != 0 {
@@ -949,7 +949,7 @@ func TestHandleBroadcastDataInactiveTopic(t *testing.T) {
func TestHandleBroadcastInfoP2P(t *testing.T) {
topicName := "usrP2P"
numUsers := 2
readId := 8
readId := int64(8)
helper := TopicTestHelper{}
helper.setUp(t, numUsers, types.TopicCatP2P, topicName, true)
defer helper.tearDown()
@@ -1065,7 +1065,7 @@ func TestHandleBroadcastInfoBogusNotification(t *testing.T) {
// Pretend we have 10 messages.
helper.topic.lastID = 10
// uid1 notifies uid2 that uid1 has read messages up to seqid 11.
readId := 11
readId := int64(11)
from := helper.uids[0]
to := helper.uids[1]
@@ -1113,7 +1113,7 @@ func TestHandleBroadcastInfoFilterOutRecvWithoutRPermission(t *testing.T) {
// Pretend we have 10 messages.
helper.topic.lastID = 10
// uid1 notifies uid2 that uid1 has read messages up to seqid 11.
readId := 8
readId := int64(8)
from := helper.uids[0]
to := helper.uids[1]
@@ -1166,7 +1166,7 @@ func TestHandleBroadcastInfoFilterOutKpWithoutWPermission(t *testing.T) {
// Pretend we have 10 messages.
helper.topic.lastID = 10
// uid1 notifies uid2 that uid1 has read messages up to seqid 11.
readId := 8
readId := int64(8)
from := helper.uids[0]
to := helper.uids[1]
@@ -1219,7 +1219,7 @@ func TestHandleBroadcastInfoDuplicatedRead(t *testing.T) {
// Pretend we have 10 messages.
helper.topic.lastID = 10
// uid1 notifies uid2 that uid1 has read messages up to seqid 11.
readId := 8
readId := int64(8)
from := helper.uids[0]
to := helper.uids[1]
@@ -1272,7 +1272,7 @@ func TestHandleBroadcastInfoDbError(t *testing.T) {
// Pretend we have 10 messages.
helper.topic.lastID = 10
// uid1 notifies uid2 that uid1 has read messages up to seqid 11.
readId := 8
readId := int64(8)
from := helper.uids[0]
to := helper.uids[1]
@@ -1326,7 +1326,7 @@ func TestHandleBroadcastInfoInvalidChannelAccess(t *testing.T) {
// Pretend we have 10 messages.
helper.topic.lastID = 10
// uid1 notifies uid2 that uid1 has read messages up to seqid 11.
readId := 8
readId := int64(8)
from := helper.uids[0]
for i := 1; i < numUsers; i++ {
uid := helper.uids[i]
@@ -1380,7 +1380,7 @@ func TestHandleBroadcastInfoChannelProcessing(t *testing.T) {
// Pretend we have 10 messages.
helper.topic.lastID = 10
// uid1 notifies uid2 that uid1 has read messages up to seqid 11.
readId := 8
readId := int64(8)
from := helper.uids[0]
for i := 1; i < numUsers; i++ {
uid := helper.uids[i]
@@ -3222,7 +3222,7 @@ func TestHandleBroadcastInfoChannelWithMultipleReaders(t *testing.T) {
defer helper.tearDown()
helper.topic.lastID = 15
readId := 12
readId := int64(12)
from := helper.uids[0]
// Set up multiple channel readers
@@ -3606,8 +3606,8 @@ func TestReplyDelMsgUpdatesUnreadCounters(t *testing.T) {
func TestCalculateUnreadInRanges(t *testing.T) {
tests := []struct {
name string
readID int
lastID int
readID int64
lastID int64
ranges []types.Range
expected int
}{
+5 -2
View File
@@ -1069,12 +1069,12 @@ func usersShutdown() {
}
}
func usersUpdateUnread(tenantID types.TenantID, uid types.Uid, val int, inc bool) {
func usersUpdateUnread(tenantID types.TenantID, uid types.Uid, val int64, inc bool) {
if globals.usersUpdate == nil || (val == 0 && inc) {
return
}
upd := &UserCacheReq{TenantID: tenantID, UserId: uid, Unread: val, Inc: inc}
upd := &UserCacheReq{TenantID: tenantID, UserId: uid, Unread: int(val), Inc: inc}
if globals.cluster.isRemoteTopic(types.TopicKey{TenantID: tenantID, Topic: uid.UserId()}) {
// Send request to remote node which owns the user.
globals.cluster.routeUserReq(upd)
@@ -1240,6 +1240,7 @@ func userUpdater() {
usersCache[key] = uce
counts[uid] = uce.unread
store.CacheUserUnreadCount(tenantID, uid, uce.unread)
}
if len(dbPending) > 0 {
@@ -1287,6 +1288,7 @@ func userUpdater() {
}
uce.unread = count
usersCache[key] = uce
store.CacheUserUnreadCount(io.tenantID, uid, count)
} else {
logs.Warn.Println("users: missing users cache entry after IO completion, uid", uid)
}
@@ -1416,6 +1418,7 @@ func userUpdater() {
if upd.Gone {
// User is being deleted. Don't care if there is a record.
delete(usersCache, types.UserKey{TenantID: upd.TenantID, User: upd.UserId})
store.DeleteCachedUserUnreadCount(upd.TenantID, upd.UserId)
continue
}
+7
View File
@@ -694,6 +694,13 @@ func max(a, b int) int {
return b
}
func max64(a, b int64) int64 {
if a > b {
return a
}
return b
}
// Truncate string if it's too long. Used in logging.
func truncateStringIfTooLong(s string) string {
if len(s) <= 1024 {