Skip to content

Commit

Permalink
Merge pull request #1881 from botpress/fl_history_flag_msg
Browse files Browse the repository at this point in the history
feat(history): Allow user to flag and unflag messages
  • Loading branch information
franklevasseur committed Jun 12, 2019
2 parents 1089bbb + 15e671e commit 3235f87
Show file tree
Hide file tree
Showing 15 changed files with 601 additions and 231 deletions.
4 changes: 4 additions & 0 deletions modules/history/package.json
Expand Up @@ -7,6 +7,10 @@
"license": "AGPL-3.0-only",
"main": "dist/backend/index.js",
"devDependencies": {
"@types/node": "^10.11.3",
"@types/react": "^16.8.17",
"@types/react-dom": "^16.8.4",
"@blueprintjs/core": "^3.15.1",
"@babel/helpers": "^7.4.4",
"module-builder": "../../build/module-builder"
},
Expand Down
46 changes: 28 additions & 18 deletions modules/history/src/backend/api.ts
@@ -1,8 +1,7 @@
import * as sdk from 'botpress/sdk'
import _ from 'lodash'
import moment from 'moment'

import Database from './db'
import { QueryFilters, MessageGroup } from './typings'

const N_MESSAGE_GROUPS_READ = 10

Expand All @@ -13,37 +12,48 @@ export default async (bp: typeof sdk, db: Database) => {
const { botId } = req.params
const { from, to } = req.query

const conversationsInfo = await db.getDistinctConversations(botId, from, to)
const conversations: string[] = await db.getDistinctConversations(botId, from, to)

const buildConversationInfo = async (c: string) => ({ id: c, count: await db.getConversationMessageCount(c) })
const conversationsInfo = await Promise.all(conversations.map(buildConversationInfo))

res.send(conversationsInfo)
})

router.get('/messages/:convId', async (req, res) => {
const convId = req.params.convId
const messageGroupsArray = await prepareMessagesRessource(db, convId, 0)
const { flag } = req.query

const filters: QueryFilters = { flag: flag === 'true' }
const messageGroups = await db.getMessagesOfConversation(convId, N_MESSAGE_GROUPS_READ, 0, filters)
const messageCount = await db.getConversationMessageCount(convId)
const messageGroupCount = await db.getConversationMessageGroupCount(convId)
res.send({ messageGroupsArray, messageCount, messageGroupCount })
const messageGroupCount = await db.getConversationMessageGroupCount(convId, filters)

res.send({ messageGroups, messageCount, messageGroupCount })
})

router.get('/more-messages/:convId', async (req, res) => {
const convId = req.params.convId
const { offset, clientCount } = req.query
const { offset, clientCount, flag } = req.query

const actualCount = await db.getConversationMessageGroupCount(convId)
const filters: QueryFilters = { flag: flag === 'true' }
const actualCount = await db.getConversationMessageGroupCount(convId, filters)
const unsyncOffset = Number(offset) + Math.max(actualCount - clientCount, 0)

const messageGroupsArray = await prepareMessagesRessource(db, convId, unsyncOffset)
res.send(messageGroupsArray)
const messageGroups = await db.getMessagesOfConversation(convId, N_MESSAGE_GROUPS_READ, unsyncOffset, filters)
res.send(messageGroups)
})
}

async function prepareMessagesRessource(db, convId, offset) {
const messages = await db.getMessagesOfConversation(convId, N_MESSAGE_GROUPS_READ, offset)

const messageGroupKeyBuild = (msg: sdk.IO.Event) =>
msg.direction === 'incoming' ? msg.id : (msg as sdk.IO.OutgoingEvent).incomingEventId
const messageGroups = _.groupBy(messages, messageGroupKeyBuild)
router.post('/flagged-messages', async (req, res) => {
const messageGroups: MessageGroup[] = req.body
const messageIds = messageGroups.map(m => m.userMessage.id)
await db.flagMessages(messageIds)
res.sendStatus(201)
})

return _.sortBy(_.values(messageGroups), mg => moment(mg[0].createdOn).unix()).reverse()
router.delete('/flagged-messages', async (req, res) => {
const messageGroups = req.body
await db.unflagMessages(messageGroups)
res.sendStatus(201)
})
}
111 changes: 86 additions & 25 deletions modules/history/src/backend/db.ts
@@ -1,7 +1,13 @@
import moment from 'moment'
import _ from 'lodash'
import { Dictionary } from 'lodash'

import * as sdk from 'botpress/sdk'

import moment from 'moment'
import { isNullOrUndefined } from 'util'
import { QueryFilters, MessageGroup, ConversationInfo } from './typings'

const FLAGS_TABLE_NAME = 'history_flags'
const EVENTS_TABLE_NAME = 'events'

export default class HistoryDb {
knex: any
Expand All @@ -10,11 +16,38 @@ export default class HistoryDb {
this.knex = bp.database
}

getDistinctConversations = async (botId: string, from?: number, to?: number) => {
initialize = async () => {
this.knex.createTableIfNotExists(FLAGS_TABLE_NAME, table => {
table.string('flaggedMessageId').primary()
})
}

flagMessages = async (messageIds: string[]) => {
const existingRows = await this.knex
.select()
.from(FLAGS_TABLE_NAME)
.whereIn('flaggedMessageId', messageIds)
.then(rows => rows.map(r => r.flaggedMessageId))

const newRows = messageIds
.filter(msgId => !existingRows.includes(msgId))
.map(msgId => ({ flaggedMessageId: msgId }))

await this.knex.batchInsert(FLAGS_TABLE_NAME, newRows, 30)
}

unflagMessages = async (messages: MessageGroup[]) => {
await this.knex
.del()
.from(FLAGS_TABLE_NAME)
.whereIn('flaggedMessageId', messages.map(m => m.userMessage.id))
}

async getDistinctConversations(botId: string, from?: number, to?: number): Promise<string[]> {
const query = this.knex
.select()
.distinct('sessionId')
.from('events')
.from(EVENTS_TABLE_NAME)
.whereNotNull('sessionId')
.andWhere({ botId })

Expand All @@ -30,41 +63,69 @@ export default class HistoryDb {
const queryResults = await query
const uniqueConversations: string[] = queryResults.map(x => x.sessionId)

const buildConversationInfo = async c => ({ id: c, count: await this.getConversationMessageCount(c) })
return Promise.all(uniqueConversations.map(buildConversationInfo))
return uniqueConversations
}

getMessagesOfConversation = async (sessionId, count, offset) => {
const incomingMessages: sdk.IO.Event[] = await this.knex
.select('event')
getMessagesOfConversation = async (
sessionId: string,
count: number,
offset: number,
filters: QueryFilters
): Promise<MessageGroup[]> => {
const incomingMessagesQuery = this.knex
.select('event', 'flaggedMessageId')
.from(EVENTS_TABLE_NAME)
.leftJoin(FLAGS_TABLE_NAME, `${EVENTS_TABLE_NAME}.incomingEventId`, `${FLAGS_TABLE_NAME}.flaggedMessageId`)
.orderBy('createdOn', 'desc')
.where({ sessionId, direction: 'incoming' })
.from('events')
.offset(offset)
.limit(count)
.then(rows => rows.map(r => this.knex.json.get(r.event)))

const messages = await this.knex('events')
.whereIn('incomingEventId', incomingMessages.map(x => x.id))
.then(rows => rows.map(r => this.knex.json.get(r.event)))
if (filters && filters.flag) {
incomingMessagesQuery.whereNotNull(`${FLAGS_TABLE_NAME}.flaggedMessageId`)
}

const incomingMessageRows: any[] = await incomingMessagesQuery.offset(offset).limit(count)

const messageGroupsMap: Dictionary<MessageGroup> = {}
_.forEach(incomingMessageRows, r => {
const userMessage: sdk.IO.IncomingEvent = this.knex.json.get(r.event)
messageGroupsMap[userMessage.id] = {
isFlagged: !!r.flaggedMessageId,
userMessage,
botMessages: []
}
})

return messages
const outgoingMessagesRows = await this.knex(EVENTS_TABLE_NAME)
.whereIn('incomingEventId', _.keys(messageGroupsMap))
.andWhere({ direction: 'outgoing' })

_.forEach(outgoingMessagesRows, r => {
messageGroupsMap[r.incomingEventId].botMessages.push(this.knex.json.get(r.event))
})

const messageGroups = _.values(messageGroupsMap)

return _.sortBy(messageGroups, (mg: MessageGroup) => moment(mg.userMessage.createdOn).unix()).reverse()
}

getConversationMessageCount = async (sessionId: string) => {
async getConversationMessageCount(sessionId: string): Promise<number> {
return this._getMessageCountWhere({ sessionId })
}

getConversationMessageGroupCount = async (sessionId: string) => {
return this._getMessageCountWhere({ sessionId, direction: 'incoming' })
getConversationMessageGroupCount = async (sessionId: string, filters: QueryFilters) => {
return this._getMessageCountWhere({ sessionId, direction: 'incoming' }, filters)
}

private async _getMessageCountWhere(whereParams) {
const messageCountObject = await this.knex
.from('events')
.count()
.where(whereParams)
private async _getMessageCountWhere(whereParams, filters?: QueryFilters) {
const messageCountQuery = this.knex
.from(EVENTS_TABLE_NAME)
.leftJoin(FLAGS_TABLE_NAME, `${EVENTS_TABLE_NAME}.incomingEventId`, `${FLAGS_TABLE_NAME}.flaggedMessageId`)

if (filters && filters.flag) {
messageCountQuery.whereNotNull(`${FLAGS_TABLE_NAME}.flaggedMessageId`)
}

const messageCountObject = await messageCountQuery.count().where(whereParams)
return messageCountObject.pop()['count(*)']
}
}
4 changes: 3 additions & 1 deletion modules/history/src/backend/index.ts
Expand Up @@ -8,6 +8,7 @@ const onServerStarted = async (bp: typeof sdk) => {}

const onServerReady = async (bp: typeof sdk) => {
const db = new Database(bp)
db.initialize()
api(bp, db)
}

Expand All @@ -18,7 +19,8 @@ const entryPoint: sdk.ModuleEntryPoint = {
name: 'history',
fullName: 'History',
homepage: 'https://botpress.io',
menuIcon: 'history'
menuIcon: 'history',
experimental: true
}
}

Expand Down
16 changes: 16 additions & 0 deletions modules/history/src/backend/typings.ts
@@ -0,0 +1,16 @@
import * as sdk from 'botpress/sdk'

export interface ConversationInfo {
id: string
count: number
}

export interface MessageGroup {
isFlagged: boolean
userMessage: sdk.IO.IncomingEvent
botMessages: sdk.IO.OutgoingEvent[]
}

export interface QueryFilters {
flag: boolean
}
2 changes: 1 addition & 1 deletion modules/history/src/views/full/ConversationPicker.jsx
Expand Up @@ -53,7 +53,7 @@ export class ConversationPicker extends React.Component {
defaultTo={this.props.defaultTo}
/>
)}
<div>
<div className={style.conversationsList}>
{this.props.conversations.map(conv => {
const convId = conv.id
const lastCharIndex = Math.min(convId.indexOf('::') + 6, convId.length)
Expand Down
69 changes: 69 additions & 0 deletions modules/history/src/views/full/MessageGroup.jsx
@@ -0,0 +1,69 @@
import React from 'react'
import style from './style.scss'
import { MdSearch } from 'react-icons/md'
import { IoMdFlag } from 'react-icons/io'
import classnames from 'classnames'

export class MessageGroup extends React.Component {
componentWillUnmount() {
this.props.handleSelection(false, this.props.group.userMessage)
}

handleSelection() {
const newState = !this.props.isSelected
this.props.handleSelection(newState, this.props.group)
}

render() {
if (!this.props.group.userMessage) {
return null
}
return (
<div className={style['message-group']}>
<div className={style['message-group-header']}>
{this.props.group.userMessage.decision && (
<div className={style['message-group-explanation']}>
<div className={style['message-group-confidence']}>{`${Math.round(
this.props.group.userMessage.decision.confidence * 10000
) / 100}% decision:`}</div>
<div className={style['message-group-decision']}>{` ${
this.props.group.userMessage.decision.sourceDetails
}`}</div>
</div>
)}
<div className={style['message-group-flag']}>
{this.props.group.isFlagged && <IoMdFlag />}
<input type="checkbox" checked={this.props.isSelected} onChange={() => this.handleSelection()} />
<div
className={style['message-inspect']}
onClick={() => this.props.focusMessage(this.props.group.userMessage)}
>
<MdSearch />
</div>
</div>
</div>
<div className={style['message-sender']}>User:</div>
{
<div className={classnames(style['message-elements'], style['message-incomming'])}>
{this.props.group.userMessage.preview}
</div>
}
<div className={style['message-sender']}>Bot:</div>
{this.props.group.botMessages.map(m => {
return (
<div
className={classnames(
style['message-elements'],
m.direction === 'outgoing' ? style['message-outgoing'] : style['message-incomming']
)}
key={`${m.id}: ${m.direction}`}
value={m.id}
>
{m.preview}
</div>
)
})}
</div>
)
}
}
25 changes: 25 additions & 0 deletions modules/history/src/views/full/MessageInspector.jsx
@@ -0,0 +1,25 @@
import React from 'react'
import style from './style.scss'

import { GoX } from 'react-icons/go'

import classnames from 'classnames'
import inspectorTheme from './inspectortheme'
import JSONTree from 'react-json-tree'

export function MessageInspector(props) {
return (
<div
className={classnames(style['message-inspector'], {
[style['message-inspector-hidden']]: props.inspectorIsShown
})}
>
<div className={style['quit-inspector']} onClick={props.closeInspector}>
<GoX />
</div>
{props.focusedMessage && (
<JSONTree theme={inspectorTheme} data={props.focusedMessage} invertTheme={false} hideRoot={true} />
)}
</div>
)
}

0 comments on commit 3235f87

Please sign in to comment.