From 5b832bbea1c9a2e20d5e8e2e204202ab5d433175 Mon Sep 17 00:00:00 2001 From: Denis Arh Date: Wed, 7 Nov 2018 11:13:34 +0100 Subject: [PATCH] Implement mentions Extends internal type and outgoing structs --- internal/payload/outgoing.go | 7 + internal/payload/outgoing/message.go | 3 + sam/db/mysql/statik.go | 2 +- .../mysql/20181107080000.mentions.up.sql | 13 ++ sam/repository/mention.go | 80 ++++++++++++ sam/service/message.go | 95 ++++++++++++++ sam/service/message_test.go | 38 +++++- sam/types/main_test.go | 17 +++ sam/types/mention.go | 123 ++++++++++++++++++ sam/types/mention_test.go | 14 ++ sam/types/message.go | 1 + 11 files changed, 391 insertions(+), 2 deletions(-) create mode 100644 sam/db/schema/mysql/20181107080000.mentions.up.sql create mode 100644 sam/repository/mention.go create mode 100644 sam/types/main_test.go create mode 100644 sam/types/mention.go create mode 100644 sam/types/mention_test.go diff --git a/internal/payload/outgoing.go b/internal/payload/outgoing.go index 65928a13a..7ca8da32c 100644 --- a/internal/payload/outgoing.go +++ b/internal/payload/outgoing.go @@ -31,6 +31,7 @@ func Message(ctx context.Context, msg *samTypes.Message) *outgoing.Message { User: User(msg.User), Attachment: Attachment(msg.Attachment), + Mentions: messageMentionSet(msg.Mentions), Reactions: messageReactionSumSet(msg.Flags), IsPinned: msg.Flags.IsPinned(), IsBookmarked: msg.Flags.IsBookmarked(currentUserID), @@ -81,6 +82,12 @@ func messageReactionSumSet(flags samTypes.MessageFlagSet) outgoing.MessageReacti return rr } +// Converts slice of mentions into slice of strings containing all user IDs +// These are IDs of users mentioned in the message +func messageMentionSet(mm samTypes.MentionSet) outgoing.MessageMentionSet { + return Uint64stoa(mm.UserIDs()) +} + func MessageReaction(f *samTypes.MessageFlag) *outgoing.MessageReaction { return &outgoing.MessageReaction{ UserID: f.UserID, diff --git a/internal/payload/outgoing/message.go b/internal/payload/outgoing/message.go index 41764e0c0..7b940663e 100644 --- a/internal/payload/outgoing/message.go +++ b/internal/payload/outgoing/message.go @@ -16,6 +16,7 @@ type ( User *User `json:"user"` Attachment *Attachment `json:"att,omitempty"` + Mentions MessageMentionSet `json:"mentions,omitempty"` Reactions MessageReactionSumSet `json:"reactions,omitempty"` IsBookmarked bool `json:"isBookmarked"` IsPinned bool `json:"isPinned"` @@ -31,6 +32,8 @@ type ( MessageSet []*Message + MessageMentionSet []string + // Used for single reaction event notification MessageReactionSum struct { UserIDs []string `json:"userIDs"` diff --git a/sam/db/mysql/statik.go b/sam/db/mysql/statik.go index 913fba10e..55ddb6dd1 100644 --- a/sam/db/mysql/statik.go +++ b/sam/db/mysql/statik.go @@ -8,6 +8,6 @@ import ( ) func init() { - data := "PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x1a\x00 \x0020180704080000.base.up.sqlUT\x05\x00\x01\x80Cm8-- all known organisations (crust instances) and our relation towards them\nCREATE TABLE organisations (\n id BIGINT UNSIGNED NOT NULL,\n fqn TEXT NOT NULL, -- fully qualified name of the organisation\n name TEXT NOT NULL, -- display name of the organisation\n\n created_at DATETIME NOT NULL DEFAULT NOW(),\n updated_at DATETIME NULL,\n archived_at DATETIME NULL,\n deleted_at DATETIME NULL, -- organisation soft delete\n\n PRIMARY KEY (id)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\n-- Keeps all known teams\nCREATE TABLE teams (\n id BIGINT UNSIGNED NOT NULL,\n name TEXT NOT NULL, -- display name of the team\n handle TEXT NOT NULL, -- team handle string\n\n created_at DATETIME NOT NULL DEFAULT NOW(),\n updated_at DATETIME NULL,\n archived_at DATETIME NULL,\n deleted_at DATETIME NULL, -- team soft delete\n\n PRIMARY KEY (id)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\n-- Keeps all known channels\nCREATE TABLE channels (\n id BIGINT UNSIGNED NOT NULL,\n name TEXT NOT NULL, -- display name of the channel\n topic TEXT NOT NULL,\n meta JSON NOT NULL,\n\n type ENUM ('private', 'public', 'group') NOT NULL DEFAULT 'public',\n\n rel_organisation BIGINT UNSIGNED NOT NULL REFERENCES organisation(id),\n rel_creator BIGINT UNSIGNED NOT NULL,\n\n created_at DATETIME NOT NULL DEFAULT NOW(),\n updated_at DATETIME NULL,\n archived_at DATETIME NULL,\n deleted_at DATETIME NULL, -- channel soft delete\n\n rel_last_message BIGINT UNSIGNED NOT NULL DEFAULT 0,\n\n PRIMARY KEY (id)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\n-- Keeps team memberships\nCREATE TABLE team_members (\n rel_team BIGINT UNSIGNED NOT NULL REFERENCES organisation(id),\n rel_user BIGINT UNSIGNED NOT NULL,\n\n PRIMARY KEY (rel_team, rel_user)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\n-- handles channel membership\nCREATE TABLE channel_members (\n rel_channel BIGINT UNSIGNED NOT NULL REFERENCES channels(id),\n rel_user BIGINT UNSIGNED NOT NULL,\n\n type ENUM ('owner', 'member', 'invitee') NOT NULL DEFAULT 'member',\n\n created_at DATETIME NOT NULL DEFAULT NOW(),\n updated_at DATETIME NULL,\n\n PRIMARY KEY (rel_channel, rel_user)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nCREATE TABLE channel_views (\n rel_channel BIGINT UNSIGNED NOT NULL REFERENCES channels(id),\n rel_user BIGINT UNSIGNED NOT NULL,\n\n -- timestamp of last view, should be enough to find out which messaghr\n viewed_at DATETIME NOT NULL DEFAULT NOW(),\n\n -- new messages count since last view\n new_since INT UNSIGNED NOT NULL DEFAULT 0,\n\n PRIMARY KEY (rel_user, rel_channel)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nCREATE TABLE channel_pins (\n rel_channel BIGINT UNSIGNED NOT NULL REFERENCES channels(id),\n rel_message BIGINT UNSIGNED NOT NULL REFERENCES messages(id),\n rel_user BIGINT UNSIGNED NOT NULL,\n\n created_at DATETIME NOT NULL DEFAULT NOW(),\n\n PRIMARY KEY (rel_channel, rel_message)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nCREATE TABLE messages (\n id BIGINT UNSIGNED NOT NULL,\n type TEXT,\n message TEXT NOT NULL,\n meta JSON,\n rel_user BIGINT UNSIGNED NOT NULL,\n rel_channel BIGINT UNSIGNED NOT NULL REFERENCES channels(id),\n reply_to BIGINT UNSIGNED NULL REFERENCES messages(id),\n\n created_at DATETIME NOT NULL DEFAULT NOW(),\n updated_at DATETIME NULL,\n deleted_at DATETIME NULL,\n\n PRIMARY KEY (id)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nCREATE TABLE reactions (\n id BIGINT UNSIGNED NOT NULL,\n rel_user BIGINT UNSIGNED NOT NULL,\n rel_message BIGINT UNSIGNED NOT NULL REFERENCES messages(id),\n rel_channel BIGINT UNSIGNED NOT NULL REFERENCES channels(id),\n reaction TEXT NOT NULL,\n\n created_at DATETIME NOT NULL DEFAULT NOW(),\n\n PRIMARY KEY (id)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nCREATE TABLE attachments (\n id BIGINT UNSIGNED NOT NULL,\n rel_user BIGINT UNSIGNED NOT NULL,\n\n url VARCHAR(512),\n preview_url VARCHAR(512),\n\n size INT UNSIGNED,\n mimetype VARCHAR(255),\n name TEXT,\n\n meta JSON,\n\n created_at DATETIME NOT NULL DEFAULT NOW(),\n updated_at DATETIME NULL,\n deleted_at DATETIME NULL,\n\n PRIMARY KEY (id)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nCREATE TABLE message_attachment (\n rel_message BIGINT UNSIGNED NOT NULL REFERENCES messages(id),\n rel_attachment BIGINT UNSIGNED NOT NULL REFERENCES attachment(id),\n\n PRIMARY KEY (rel_message)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nCREATE TABLE event_queue (\n id BIGINT UNSIGNED NOT NULL,\n origin BIGINT UNSIGNED NOT NULL,\n subscriber TEXT,\n payload JSON,\n\n PRIMARY KEY (id)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nCREATE TABLE event_queue_synced (\n origin BIGINT UNSIGNED NOT NULL,\n rel_last BIGINT UNSIGNED NOT NULL,\n\n PRIMARY KEY (origin)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\nPK\x07\x08\xd2g\xcd\xce\x9f\x15\x00\x00\x9f\x15\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00$\x00 \x0020181009080000.altering_types.up.sqlUT\x05\x00\x01\x80Cm8update channels set type = 'group' where type = 'direct';\nalter table channels CHANGE type type enum('private', 'public', 'group');\nalter table channel_members CHANGE type type enum('owner', 'member', 'invitee');\nPK\x07\x08E1\xf5\xa4\xd7\x00\x00\x00\xd7\x00\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00#\x00 \x0020181013080000.channel_views.up.sqlUT\x05\x00\x01\x80Cm8ALTER TABLE channel_views DROP viewed_at;\nALTER TABLE channel_views ADD rel_last_message_id BIGINT UNSIGNED;\nALTER TABLE channel_views CHANGE new_since new_messages_count INT UNSIGNED;\n\n-- Table structure after these changes:\n-- +---------------------+---------------------+------+-----+---------+-------+\n-- | Field | Type | Null | Key | Default | Extra |\n-- +---------------------+---------------------+------+-----+---------+-------+\n-- | rel_channel | bigint(20) unsigned | NO | PRI | NULL | |\n-- | rel_user | bigint(20) unsigned | NO | PRI | NULL | |\n-- | rel_last_message_id | bigint(20) unsigned | YES | | NULL | |\n-- | new_messages_count | int(10) unsigned | NO | | 0 | |\n-- +---------------------+---------------------+------+-----+---------+-------+\n\n-- Prefill with data\nINSERT INTO channel_views (rel_channel, rel_user, rel_last_message_id)\n SELECT cm.rel_channel, cm.rel_user, max(m.ID)\n FROM channel_members AS cm INNER JOIN messages AS m ON (m.rel_channel = cm.rel_channel)\n GROUP BY cm.rel_channel, cm.rel_user;\n\nPK\x07\x08`\xcbP\xf9t\x04\x00\x00t\x04\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x1d\x00 \x0020181013080000.replies.up.sqlUT\x05\x00\x01\x80Cm8ALTER TABLE messages CHANGE reply_to reply_to BIGINT UNSIGNED NOT NULL DEFAULT 0;\nALTER TABLE messages ADD replies INT UNSIGNED NOT NULL DEFAULT 0;\nPK\x07\x08m\xedWA\x94\x00\x00\x00\x94\x00\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00(\x00 \x0020181101080000.pins_and_reactions.up.sqlUT\x05\x00\x01\x80Cm8DROP TABLE channel_pins;\nDROP TABLE reactions;\n\nCREATE TABLE message_flags (\n id BIGINT UNSIGNED NOT NULL,\n rel_channel BIGINT UNSIGNED NOT NULL,\n rel_message BIGINT UNSIGNED NOT NULL,\n rel_user BIGINT UNSIGNED NOT NULL,\n flag TEXT,\n\n created_at DATETIME NOT NULL DEFAULT NOW(),\n\n PRIMARY KEY (id)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\nPK\x07\x08eA\x1eo\x90\x01\x00\x00\x90\x01\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0e\x00 \x00migrations.sqlUT\x05\x00\x01\x80Cm8CREATE TABLE IF NOT EXISTS `migrations` (\n `project` varchar(16) NOT NULL COMMENT 'sam, crm, ...',\n `filename` varchar(255) NOT NULL COMMENT 'yyyymmddHHMMSS.sql',\n `statement_index` int(11) NOT NULL COMMENT 'Statement number from SQL file',\n `status` TEXT NOT NULL COMMENT 'ok or full error message',\n PRIMARY KEY (`project`,`filename`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nPK\x07\x08\x0d\xa5T2x\x01\x00\x00x\x01\x00\x00PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\xd2g\xcd\xce\x9f\x15\x00\x00\x9f\x15\x00\x00\x1a\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\x00\x00\x00\x0020180704080000.base.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(E1\xf5\xa4\xd7\x00\x00\x00\xd7\x00\x00\x00$\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\xf0\x15\x00\x0020181009080000.altering_types.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(`\xcbP\xf9t\x04\x00\x00t\x04\x00\x00#\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\"\x17\x00\x0020181013080000.channel_views.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(m\xedWA\x94\x00\x00\x00\x94\x00\x00\x00\x1d\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\xf0\x1b\x00\x0020181013080000.replies.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(eA\x1eo\x90\x01\x00\x00\x90\x01\x00\x00(\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\xd8\x1c\x00\x0020181101080000.pins_and_reactions.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\x0d\xa5T2x\x01\x00\x00x\x01\x00\x00\x0e\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\xc7\x1e\x00\x00migrations.sqlUT\x05\x00\x01\x80Cm8PK\x05\x06\x00\x00\x00\x00\x06\x00\x06\x00\xfe\x01\x00\x00\x84 \x00\x00\x00\x00" + data := "PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x1a\x00 \x0020180704080000.base.up.sqlUT\x05\x00\x01\x80Cm8-- all known organisations (crust instances) and our relation towards them\nCREATE TABLE organisations (\n id BIGINT UNSIGNED NOT NULL,\n fqn TEXT NOT NULL, -- fully qualified name of the organisation\n name TEXT NOT NULL, -- display name of the organisation\n\n created_at DATETIME NOT NULL DEFAULT NOW(),\n updated_at DATETIME NULL,\n archived_at DATETIME NULL,\n deleted_at DATETIME NULL, -- organisation soft delete\n\n PRIMARY KEY (id)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\n-- Keeps all known teams\nCREATE TABLE teams (\n id BIGINT UNSIGNED NOT NULL,\n name TEXT NOT NULL, -- display name of the team\n handle TEXT NOT NULL, -- team handle string\n\n created_at DATETIME NOT NULL DEFAULT NOW(),\n updated_at DATETIME NULL,\n archived_at DATETIME NULL,\n deleted_at DATETIME NULL, -- team soft delete\n\n PRIMARY KEY (id)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\n-- Keeps all known channels\nCREATE TABLE channels (\n id BIGINT UNSIGNED NOT NULL,\n name TEXT NOT NULL, -- display name of the channel\n topic TEXT NOT NULL,\n meta JSON NOT NULL,\n\n type ENUM ('private', 'public', 'group') NOT NULL DEFAULT 'public',\n\n rel_organisation BIGINT UNSIGNED NOT NULL REFERENCES organisation(id),\n rel_creator BIGINT UNSIGNED NOT NULL,\n\n created_at DATETIME NOT NULL DEFAULT NOW(),\n updated_at DATETIME NULL,\n archived_at DATETIME NULL,\n deleted_at DATETIME NULL, -- channel soft delete\n\n rel_last_message BIGINT UNSIGNED NOT NULL DEFAULT 0,\n\n PRIMARY KEY (id)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\n-- Keeps team memberships\nCREATE TABLE team_members (\n rel_team BIGINT UNSIGNED NOT NULL REFERENCES organisation(id),\n rel_user BIGINT UNSIGNED NOT NULL,\n\n PRIMARY KEY (rel_team, rel_user)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\n-- handles channel membership\nCREATE TABLE channel_members (\n rel_channel BIGINT UNSIGNED NOT NULL REFERENCES channels(id),\n rel_user BIGINT UNSIGNED NOT NULL,\n\n type ENUM ('owner', 'member', 'invitee') NOT NULL DEFAULT 'member',\n\n created_at DATETIME NOT NULL DEFAULT NOW(),\n updated_at DATETIME NULL,\n\n PRIMARY KEY (rel_channel, rel_user)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nCREATE TABLE channel_views (\n rel_channel BIGINT UNSIGNED NOT NULL REFERENCES channels(id),\n rel_user BIGINT UNSIGNED NOT NULL,\n\n -- timestamp of last view, should be enough to find out which messaghr\n viewed_at DATETIME NOT NULL DEFAULT NOW(),\n\n -- new messages count since last view\n new_since INT UNSIGNED NOT NULL DEFAULT 0,\n\n PRIMARY KEY (rel_user, rel_channel)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nCREATE TABLE channel_pins (\n rel_channel BIGINT UNSIGNED NOT NULL REFERENCES channels(id),\n rel_message BIGINT UNSIGNED NOT NULL REFERENCES messages(id),\n rel_user BIGINT UNSIGNED NOT NULL,\n\n created_at DATETIME NOT NULL DEFAULT NOW(),\n\n PRIMARY KEY (rel_channel, rel_message)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nCREATE TABLE messages (\n id BIGINT UNSIGNED NOT NULL,\n type TEXT,\n message TEXT NOT NULL,\n meta JSON,\n rel_user BIGINT UNSIGNED NOT NULL,\n rel_channel BIGINT UNSIGNED NOT NULL REFERENCES channels(id),\n reply_to BIGINT UNSIGNED NULL REFERENCES messages(id),\n\n created_at DATETIME NOT NULL DEFAULT NOW(),\n updated_at DATETIME NULL,\n deleted_at DATETIME NULL,\n\n PRIMARY KEY (id)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nCREATE TABLE reactions (\n id BIGINT UNSIGNED NOT NULL,\n rel_user BIGINT UNSIGNED NOT NULL,\n rel_message BIGINT UNSIGNED NOT NULL REFERENCES messages(id),\n rel_channel BIGINT UNSIGNED NOT NULL REFERENCES channels(id),\n reaction TEXT NOT NULL,\n\n created_at DATETIME NOT NULL DEFAULT NOW(),\n\n PRIMARY KEY (id)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nCREATE TABLE attachments (\n id BIGINT UNSIGNED NOT NULL,\n rel_user BIGINT UNSIGNED NOT NULL,\n\n url VARCHAR(512),\n preview_url VARCHAR(512),\n\n size INT UNSIGNED,\n mimetype VARCHAR(255),\n name TEXT,\n\n meta JSON,\n\n created_at DATETIME NOT NULL DEFAULT NOW(),\n updated_at DATETIME NULL,\n deleted_at DATETIME NULL,\n\n PRIMARY KEY (id)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nCREATE TABLE message_attachment (\n rel_message BIGINT UNSIGNED NOT NULL REFERENCES messages(id),\n rel_attachment BIGINT UNSIGNED NOT NULL REFERENCES attachment(id),\n\n PRIMARY KEY (rel_message)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nCREATE TABLE event_queue (\n id BIGINT UNSIGNED NOT NULL,\n origin BIGINT UNSIGNED NOT NULL,\n subscriber TEXT,\n payload JSON,\n\n PRIMARY KEY (id)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nCREATE TABLE event_queue_synced (\n origin BIGINT UNSIGNED NOT NULL,\n rel_last BIGINT UNSIGNED NOT NULL,\n\n PRIMARY KEY (origin)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\nPK\x07\x08\xd2g\xcd\xce\x9f\x15\x00\x00\x9f\x15\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00$\x00 \x0020181009080000.altering_types.up.sqlUT\x05\x00\x01\x80Cm8update channels set type = 'group' where type = 'direct';\nalter table channels CHANGE type type enum('private', 'public', 'group');\nalter table channel_members CHANGE type type enum('owner', 'member', 'invitee');\nPK\x07\x08E1\xf5\xa4\xd7\x00\x00\x00\xd7\x00\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00#\x00 \x0020181013080000.channel_views.up.sqlUT\x05\x00\x01\x80Cm8ALTER TABLE channel_views DROP viewed_at;\nALTER TABLE channel_views ADD rel_last_message_id BIGINT UNSIGNED;\nALTER TABLE channel_views CHANGE new_since new_messages_count INT UNSIGNED;\n\n-- Table structure after these changes:\n-- +---------------------+---------------------+------+-----+---------+-------+\n-- | Field | Type | Null | Key | Default | Extra |\n-- +---------------------+---------------------+------+-----+---------+-------+\n-- | rel_channel | bigint(20) unsigned | NO | PRI | NULL | |\n-- | rel_user | bigint(20) unsigned | NO | PRI | NULL | |\n-- | rel_last_message_id | bigint(20) unsigned | YES | | NULL | |\n-- | new_messages_count | int(10) unsigned | NO | | 0 | |\n-- +---------------------+---------------------+------+-----+---------+-------+\n\n-- Prefill with data\nINSERT INTO channel_views (rel_channel, rel_user, rel_last_message_id)\n SELECT cm.rel_channel, cm.rel_user, max(m.ID)\n FROM channel_members AS cm INNER JOIN messages AS m ON (m.rel_channel = cm.rel_channel)\n GROUP BY cm.rel_channel, cm.rel_user;\n\nPK\x07\x08`\xcbP\xf9t\x04\x00\x00t\x04\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x1d\x00 \x0020181013080000.replies.up.sqlUT\x05\x00\x01\x80Cm8ALTER TABLE messages CHANGE reply_to reply_to BIGINT UNSIGNED NOT NULL DEFAULT 0;\nALTER TABLE messages ADD replies INT UNSIGNED NOT NULL DEFAULT 0;\nPK\x07\x08m\xedWA\x94\x00\x00\x00\x94\x00\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00(\x00 \x0020181101080000.pins_and_reactions.up.sqlUT\x05\x00\x01\x80Cm8DROP TABLE channel_pins;\nDROP TABLE reactions;\n\nCREATE TABLE message_flags (\n id BIGINT UNSIGNED NOT NULL,\n rel_channel BIGINT UNSIGNED NOT NULL,\n rel_message BIGINT UNSIGNED NOT NULL,\n rel_user BIGINT UNSIGNED NOT NULL,\n flag TEXT,\n\n created_at DATETIME NOT NULL DEFAULT NOW(),\n\n PRIMARY KEY (id)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\nPK\x07\x08eA\x1eo\x90\x01\x00\x00\x90\x01\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x1e\x00 \x0020181107080000.mentions.up.sqlUT\x05\x00\x01\x80Cm8CREATE TABLE mentions (\n id BIGINT UNSIGNED NOT NULL,\n rel_channel BIGINT UNSIGNED NOT NULL,\n rel_message BIGINT UNSIGNED NOT NULL,\n rel_user BIGINT UNSIGNED NOT NULL,\n rel_mentioned_by BIGINT UNSIGNED NOT NULL,\n\n created_at DATETIME NOT NULL DEFAULT NOW(),\n\n PRIMARY KEY (id)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nCREATE INDEX lookup_mentions ON mentions (rel_mentioned_by)\nPK\x07\x08\xfb\xe8\x9b\x98\xac\x01\x00\x00\xac\x01\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0e\x00 \x00migrations.sqlUT\x05\x00\x01\x80Cm8CREATE TABLE IF NOT EXISTS `migrations` (\n `project` varchar(16) NOT NULL COMMENT 'sam, crm, ...',\n `filename` varchar(255) NOT NULL COMMENT 'yyyymmddHHMMSS.sql',\n `statement_index` int(11) NOT NULL COMMENT 'Statement number from SQL file',\n `status` TEXT NOT NULL COMMENT 'ok or full error message',\n PRIMARY KEY (`project`,`filename`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nPK\x07\x08\x0d\xa5T2x\x01\x00\x00x\x01\x00\x00PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\xd2g\xcd\xce\x9f\x15\x00\x00\x9f\x15\x00\x00\x1a\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\x00\x00\x00\x0020180704080000.base.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(E1\xf5\xa4\xd7\x00\x00\x00\xd7\x00\x00\x00$\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\xf0\x15\x00\x0020181009080000.altering_types.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(`\xcbP\xf9t\x04\x00\x00t\x04\x00\x00#\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\"\x17\x00\x0020181013080000.channel_views.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(m\xedWA\x94\x00\x00\x00\x94\x00\x00\x00\x1d\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\xf0\x1b\x00\x0020181013080000.replies.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(eA\x1eo\x90\x01\x00\x00\x90\x01\x00\x00(\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\xd8\x1c\x00\x0020181101080000.pins_and_reactions.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\xfb\xe8\x9b\x98\xac\x01\x00\x00\xac\x01\x00\x00\x1e\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\xc7\x1e\x00\x0020181107080000.mentions.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\x0d\xa5T2x\x01\x00\x00x\x01\x00\x00\x0e\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\xc8 \x00\x00migrations.sqlUT\x05\x00\x01\x80Cm8PK\x05\x06\x00\x00\x00\x00\x07\x00\x07\x00S\x02\x00\x00\x85\"\x00\x00\x00\x00" fs.Register(data) } diff --git a/sam/db/schema/mysql/20181107080000.mentions.up.sql b/sam/db/schema/mysql/20181107080000.mentions.up.sql new file mode 100644 index 000000000..1a62c28c6 --- /dev/null +++ b/sam/db/schema/mysql/20181107080000.mentions.up.sql @@ -0,0 +1,13 @@ +CREATE TABLE mentions ( + id BIGINT UNSIGNED NOT NULL, + rel_channel BIGINT UNSIGNED NOT NULL, + rel_message BIGINT UNSIGNED NOT NULL, + rel_user BIGINT UNSIGNED NOT NULL, + rel_mentioned_by BIGINT UNSIGNED NOT NULL, + + created_at DATETIME NOT NULL DEFAULT NOW(), + + PRIMARY KEY (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE INDEX lookup_mentions ON mentions (rel_mentioned_by) diff --git a/sam/repository/mention.go b/sam/repository/mention.go new file mode 100644 index 000000000..636a561a5 --- /dev/null +++ b/sam/repository/mention.go @@ -0,0 +1,80 @@ +package repository + +import ( + "context" + "fmt" + "time" + + "github.com/jmoiron/sqlx" + "github.com/titpetric/factory" + + "github.com/crusttech/crust/sam/types" +) + +type ( + MentionRepository interface { + With(ctx context.Context, db *factory.DB) MentionRepository + + FindByUserIDs(IDs ...uint64) (mm types.MentionSet, err error) + FindByMessageIDs(IDs ...uint64) (mm types.MentionSet, err error) + Create(m *types.Mention) (*types.Mention, error) + DeleteByMessageID(ID uint64) error + DeleteByID(ID uint64) error + } + + mention struct { + *repository + } +) + +var ( + ErrMentionNotFound = repositoryError("MentionNotFound") +) + +func Mention(ctx context.Context, db *factory.DB) MentionRepository { + return (&mention{}).With(ctx, db) +} + +func (r *mention) With(ctx context.Context, db *factory.DB) MentionRepository { + return &mention{ + repository: r.repository.With(ctx, db), + } +} + +func (r *mention) FindByUserIDs(IDs ...uint64) (types.MentionSet, error) { + return r.findByIDs("rel_user", IDs...) +} + +func (r *mention) FindByMessageIDs(IDs ...uint64) (types.MentionSet, error) { + return r.findByIDs("rel_message", IDs...) +} + +func (r *mention) findByIDs(col string, IDs ...uint64) (mm types.MentionSet, err error) { + mm = types.MentionSet{} + + if len(IDs) == 0 { + return + } + + sql := fmt.Sprintf(`SELECT * FROM mentions WHERE %s IN (?)`, col) + + if sql, args, err := sqlx.In(sql, IDs); err != nil { + return nil, err + } else { + return mm, r.db().Select(&mm, sql, args...) + } +} + +func (r *mention) Create(m *types.Mention) (*types.Mention, error) { + m.ID = factory.Sonyflake.NextID() + m.CreatedAt = time.Now() + return m, r.db().Insert("mentions", m) +} + +func (r *mention) DeleteByMessageID(ID uint64) error { + return exec(r.db().Exec("DELETE FROM mentions WHERE rel_message = ?", ID)) +} + +func (r *mention) DeleteByID(ID uint64) error { + return exec(r.db().Exec("DELETE FROM mentions WHERE id = ?", ID)) +} diff --git a/sam/service/message.go b/sam/service/message.go index fe05ab683..a03a5a61a 100644 --- a/sam/service/message.go +++ b/sam/service/message.go @@ -2,10 +2,12 @@ package service import ( "context" + "regexp" "strings" "github.com/pkg/errors" + "github.com/crusttech/crust/internal/payload" "github.com/crusttech/crust/sam/repository" "github.com/crusttech/crust/sam/types" systemService "github.com/crusttech/crust/system/service" @@ -23,6 +25,7 @@ type ( cview repository.ChannelViewRepository message repository.MessageRepository mflag repository.MessageFlagRepository + mentions repository.MentionRepository usr systemService.UserService evl EventService @@ -52,6 +55,11 @@ type ( const ( settingsMessageBodyLength = 0 + mentionRE = `<([@#])(\d+)((?:\s)([^>]+))?>` +) + +var ( + mentionsFinder = regexp.MustCompile(mentionRE) ) func Message() MessageService { @@ -76,6 +84,7 @@ func (svc *message) With(ctx context.Context) MessageService { cview: repository.ChannelView(ctx, db), message: repository.Message(ctx, db), mflag: repository.MessageFlag(ctx, db), + mentions: repository.Mention(ctx, db), } } @@ -176,6 +185,10 @@ func (svc *message) Create(in *types.Message) (message *types.Message, err error return } + if err = svc.updateMentions(message.ID, svc.extractMentions(message)); err != nil { + return + } + if err = svc.cview.Inc(message.ChannelID, message.UserID); err != nil { return } @@ -226,6 +239,10 @@ func (svc *message) Update(in *types.Message) (message *types.Message, err error return err } + if err = svc.updateMentions(message.ID, svc.extractMentions(message)); err != nil { + return + } + return svc.sendEvent(message) }) } @@ -281,6 +298,10 @@ func (svc *message) Delete(ID uint64) error { deletedMsg.DeletedAt = timeNowPtr() } + if err = svc.updateMentions(ID, nil); err != nil { + return + } + return svc.sendEvent(append(bq, deletedMsg)...) }) } @@ -397,6 +418,10 @@ func (svc *message) preload(mm types.MessageSet) (err error) { return } + if err = svc.preloadMentions(mm); err != nil { + return + } + return } @@ -437,6 +462,21 @@ func (svc *message) preloadFlags(mm types.MessageSet) (err error) { }) } +// Preload for all messages +func (svc *message) preloadMentions(mm types.MessageSet) (err error) { + var mentions types.MentionSet + + mentions, err = svc.mentions.FindByMessageIDs(mm.IDs()...) + if err != nil { + return + } + + return mm.Walk(func(m *types.Message) error { + m.Mentions = mentions.FindByMessageID(m.ID) + return nil + }) +} + func (svc *message) preloadAttachments(mm types.MessageSet) (err error) { var ( ids []uint64 @@ -500,4 +540,59 @@ func (svc *message) sendFlagEvent(ff ...*types.MessageFlag) (err error) { return } +func (svc *message) extractMentions(m *types.Message) (mm types.MentionSet) { + const reSubID = 2 + mm = types.MentionSet{} + + match := mentionsFinder.FindAllStringSubmatch(m.Message, -1) + + // Prepopulated with all we know from message + tpl := types.Mention{ + ChannelID: m.ChannelID, + MessageID: m.ID, + MentionedByID: m.UserID, + } + + for m := 0; m < len(match); m++ { + uid := payload.ParseUInt64(match[m][reSubID]) + if len(mm.FindByUserID(uid)) == 0 { + // Copy template & assign user id + mnt := tpl + mnt.UserID = uid + mm = append(mm, &mnt) + } + } + + return +} + +func (svc *message) updateMentions(messageID uint64, mm types.MentionSet) error { + if existing, err := svc.mentions.FindByMessageIDs(messageID); err != nil { + return errors.Wrap(err, "Could not update mentions") + } else if len(mm) > 0 { + add, _, del := existing.Diff(mm) + + err = add.Walk(func(m *types.Mention) error { + m, err = svc.mentions.Create(m) + return err + }) + + if err != nil { + return errors.Wrap(err, "Could not create mentions") + } + + err = del.Walk(func(m *types.Mention) error { + return svc.mentions.DeleteByID(m.ID) + }) + + if err != nil { + return errors.Wrap(err, "Could not delete mentions") + } + } else { + return svc.mentions.DeleteByMessageID(messageID) + } + + return nil +} + var _ MessageService = &message{} diff --git a/sam/service/message_test.go b/sam/service/message_test.go index f0ff88368..cdaa93a1a 100644 --- a/sam/service/message_test.go +++ b/sam/service/message_test.go @@ -28,7 +28,7 @@ import ( // svc.Create() // } -func TesMessageLength(t *testing.T) { +func TestMessageLength(t *testing.T) { // mockCtrl := gomock.NewController(t) // defer mockCtrl.Finish() @@ -43,3 +43,39 @@ func TesMessageLength(t *testing.T) { assert(t, e(svc.Create(&types.Message{})) != nil, "Should not allow to create unnamed channels") assert(t, e(svc.Create(&types.Message{Message: longText})) != nil, "Should not allow to create channel with really long name") } + +func TestMentionsExtraction(t *testing.T) { + var ( + svc = message{} + mm types.MentionSet + cases = []struct { + text string + ids []uint64 + }{ + {"abcde", + []uint64{}}, + {"<@4095834095>", + []uint64{4095834095}}, + {"<@4095834095> <@4095834095>", + []uint64{4095834095}}, + {"<@4095834095> <@4095834097>", + []uint64{4095834095, 4095834097}}, + {"dfsf<@4095834095>dsfsd<@4095834097>sdfs", + []uint64{4095834095, 4095834097}}, + {"dfsf<@4095834095>dsfsd<@40958340dfsZ", + []uint64{4095834095}}, + {"<@4095834095 label> <@4095834097>", + []uint64{4095834095, 4095834097}}, + } + ) + + for _, c := range cases { + mm = svc.extractMentions(&types.Message{Message: c.text}) + + assert(t, len(mm) == len(c.ids), "Number of extracted (%d) and expected (%d) user IDs do not match (%s)", len(mm), len(c.ids), c.text) + + for _, id := range c.ids { + assert(t, len(mm.FindByUserID(id)) == 1, "User ID (%d) was not extracted (%s)", id, c.text) + } + } +} diff --git a/sam/types/main_test.go b/sam/types/main_test.go new file mode 100644 index 000000000..3625f8a10 --- /dev/null +++ b/sam/types/main_test.go @@ -0,0 +1,17 @@ +package types + +import ( + "fmt" + "runtime" + "testing" +) + +func assert(t *testing.T, ok bool, format string, args ...interface{}) bool { + if !ok { + _, file, line, _ := runtime.Caller(1) + caller := fmt.Sprintf("\nAsserted at:%s:%d", file, line) + + t.Fatalf(format+caller, args...) + } + return ok +} diff --git a/sam/types/mention.go b/sam/types/mention.go new file mode 100644 index 000000000..18bd443b6 --- /dev/null +++ b/sam/types/mention.go @@ -0,0 +1,123 @@ +package types + +import ( + "time" +) + +type ( + Mention struct { + ID uint64 `db:"id"` + MessageID uint64 `db:"rel_message"` + ChannelID uint64 `db:"rel_channel"` + UserID uint64 `db:"rel_user"` + MentionedByID uint64 `db:"rel_mentioned_by"` + CreatedAt time.Time `db:"created_at"` + } + + MentionSet []*Mention + + MentionFilter struct { + // All mentions by this user + MentionedByID uint64 + + // All mentions of this user + UserID uint64 + + // How many entries + Limit uint + } +) + +func (mm MentionSet) Walk(w func(*Mention) error) (err error) { + for i := range mm { + if err = w(mm[i]); err != nil { + return + } + } + + return +} + +func (mm MentionSet) FindByID(ID uint64) (out *Mention) { + out = &Mention{} + + for i := range mm { + if mm[i].ID == ID { + return + } + } + + return nil +} + +func (mm MentionSet) FindByUserID(ID uint64) (out MentionSet) { + out = MentionSet{} + + for i := range mm { + if mm[i].UserID == ID { + out = append(out, mm[i]) + } + } + + return +} + +func (mm MentionSet) FindByMessageID(ID uint64) (out MentionSet) { + out = MentionSet{} + + for i := range mm { + if mm[i].MessageID == ID { + out = append(out, mm[i]) + } + } + + return +} + +func (mm MentionSet) IDs() (IDs []uint64) { + IDs = make([]uint64, len(mm)) + + for i := range mm { + IDs[i] = mm[i].ID + } + + return +} + +func (mm MentionSet) UserIDs() (IDs []uint64) { + IDs = make([]uint64, len(mm)) + + for i := range mm { + IDs[i] = mm[i].UserID + } + + return +} + +func (mm MentionSet) Diff(in MentionSet) (add, upd, del MentionSet) { + add, upd, del = MentionSet{}, MentionSet{}, MentionSet{} + + for _, m := range in { + if m.ID == 0 { + // Mark for adding all new + add = append(add, m) + } + } + + for _, m := range mm { + if m.ID == 0 { + // Ignore all unsaved + continue + } + + if in.FindByID(m.ID) == nil { + // Mark for removal all that are not added + del = append(del, m) + } else { + // Mark for update all that are still there + upd = append(upd, m) + } + } + + return +} diff --git a/sam/types/mention_test.go b/sam/types/mention_test.go new file mode 100644 index 000000000..713124c2a --- /dev/null +++ b/sam/types/mention_test.go @@ -0,0 +1,14 @@ +package types + +import ( + "testing" +) + +func TestMentionSet_Diff(t *testing.T) { + ex := MentionSet{&Mention{ID: 1000}, &Mention{ID: 1001}} + add, upd, del := ex.Diff(MentionSet{&Mention{ID: 1001}, &Mention{UserID: 1}}) + + assert(t, len(add) == 1 && len(add.FindByUserID(1)) == 1, "Did not find expected mention (UserID:1) for creation") + assert(t, len(upd) == 1 && upd.FindByID(1001) != nil, "Did not find expected mention (id:1001) for update") + assert(t, len(del) == 1 && del.FindByID(1000) != nil, "Did not find expected mention (id:1000) for removal") +} diff --git a/sam/types/message.go b/sam/types/message.go index 6ba0febab..69bcdca06 100644 --- a/sam/types/message.go +++ b/sam/types/message.go @@ -22,6 +22,7 @@ type ( Attachment *Attachment `json:"attachment,omitempty"` User *systemTypes.User `json:"user,omitempty"` Flags MessageFlagSet `json:"flags,omitempty"` + Mentions MentionSet } MessageSet []*Message