Skip to content

Commit

Permalink
Merge pull request #1807 from botpress/fl_history_module
Browse files Browse the repository at this point in the history
feat(history): add history module which displays conversations history for bot
  • Loading branch information
franklevasseur committed May 24, 2019
2 parents 983780c + f814990 commit 251fcc0
Show file tree
Hide file tree
Showing 15 changed files with 6,295 additions and 0 deletions.
7 changes: 7 additions & 0 deletions modules/history/.gitignore
@@ -0,0 +1,7 @@
/bin
/node_modules
/dist
/assets/web/
/assets/config.schema.json
botpress.d.ts
global.d.ts
107 changes: 107 additions & 0 deletions modules/history/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

24 changes: 24 additions & 0 deletions modules/history/package.json
@@ -0,0 +1,24 @@
{
"name": "history",
"version": "1.0.0",
"description": "chat history",
"private": true,
"author": "Botpress, Inc.",
"license": "AGPL-3.0-only",
"main": "dist/backend/index.js",
"devDependencies": {
"module-builder": "../../build/module-builder"
},
"scripts": {
"build": "./node_modules/.bin/module-builder build",
"watch": "./node_modules/.bin/module-builder watch",
"package": "./node_modules/.bin/module-builder package"
},
"dependencies": {
"classnames": "^2.2.6",
"react-copy-to-clipboard": "^5.0.1",
"react-day-picker": "^7.3.0",
"react-icons": "^3.7.0",
"react-json-tree": "^0.11.2"
}
}
49 changes: 49 additions & 0 deletions modules/history/src/backend/api.ts
@@ -0,0 +1,49 @@
import * as sdk from 'botpress/sdk'
import _ from 'lodash'
import moment from 'moment'

import Database from './db'

const N_MESSAGE_GROUPS_READ = 10

export default async (bp: typeof sdk, db: Database) => {
const router = bp.http.createRouterForBot('history')

router.get('/conversations', async (req, res) => {
const { botId } = req.params
const { from, to } = req.query

const conversationsInfo = await db.getDistinctConversations(botId, from, to)

res.send(conversationsInfo)
})

router.get('/messages/:convId', async (req, res) => {
const convId = req.params.convId
const messageGroupsArray = await prepareMessagesRessource(db, convId, 0)
const messageCount = await db.getConversationMessageCount(convId)
const messageGroupCount = await db.getConversationMessageGroupCount(convId)
res.send({ messageGroupsArray, messageCount, messageGroupCount })
})

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

const actualCount = await db.getConversationMessageGroupCount(convId)
const unsyncOffset = Number(offset) + Math.max(actualCount - clientCount, 0)

const messageGroupsArray = await prepareMessagesRessource(db, convId, unsyncOffset)
res.send(messageGroupsArray)
})
}

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)

return _.sortBy(_.values(messageGroups), mg => moment(mg[0].createdOn).unix()).reverse()
}
70 changes: 70 additions & 0 deletions modules/history/src/backend/db.ts
@@ -0,0 +1,70 @@
import * as sdk from 'botpress/sdk'

import moment from 'moment'
import { isNullOrUndefined } from 'util'

export default class HistoryDb {
knex: any

constructor(private bp: typeof sdk) {
this.knex = bp.database
}

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

if (from) {
const fromDate = moment.unix(from).toDate()
query.andWhere(this.knex.date.isBefore(fromDate, 'createdOn'))
}
if (to) {
const toDate = moment.unix(to).toDate()
query.andWhere(this.knex.date.isAfter(toDate, 'createdOn'))
}

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))
}

getMessagesOfConversation = async (sessionId, count, offset) => {
const incomingMessages: sdk.IO.Event[] = await this.knex
.select('event')
.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)))

return messages
}

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

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

private async _getMessageCountWhere(whereParams) {
const messageCountObject = await this.knex
.from('events')
.count()
.where(whereParams)

return messageCountObject.pop()['count(*)']
}
}
25 changes: 25 additions & 0 deletions modules/history/src/backend/index.ts
@@ -0,0 +1,25 @@
import 'bluebird-global'
import * as sdk from 'botpress/sdk'

import api from './api'
import Database from './db'

const onServerStarted = async (bp: typeof sdk) => {}

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

const entryPoint: sdk.ModuleEntryPoint = {
onServerReady,
onServerStarted,
definition: {
name: 'history',
fullName: 'History',
homepage: 'https://botpress.io',
menuIcon: 'history'
}
}

export default entryPoint
76 changes: 76 additions & 0 deletions modules/history/src/views/full/ConversationPicker.jsx
@@ -0,0 +1,76 @@
import React from 'react'
import style from './style.scss'

import 'react-day-picker/lib/style.css'

import { TiRefresh } from 'react-icons/ti'
import { FaFilter } from 'react-icons/fa'
import DayPickerInput from 'react-day-picker/DayPickerInput'

function QueryOptions(props) {
return (
<div className={style['query-options']}>
<div className={style['query-options-daypick']}>
<div className={style['query-options-from_to']}>from:</div>
<div className={style['daypicker-item']}>
<DayPickerInput value={props.defaultFrom} onDayChange={props.handleFromChange} />
</div>
</div>
<div className={style['query-options-daypick']}>
<div className={style['query-options-from_to']}>to:</div>
<div className={style['daypicker-item']}>
<DayPickerInput value={props.defaultTo} onDayChange={props.handleToChange} />
</div>
</div>
</div>
)
}

export class ConversationPicker extends React.Component {
state = {
displayQueryOptions: false
}

toggleFilters() {
this.setState({ displayQueryOptions: !this.state.displayQueryOptions })
}

render() {
return (
<div className={style['conversations']}>
<div className={style['conversations-titlebar']}>
<div>Conversations</div>
<div className={style['conversations-icons']}>
<FaFilter className={style['conversations-filter']} onClick={() => this.toggleFilters()} />
<TiRefresh className={style['conversations-refresh']} onClick={this.props.refresh} />
</div>
</div>
{this.state.displayQueryOptions && (
<QueryOptions
handleFromChange={this.props.handleFromChange}
handleToChange={this.props.handleToChange}
defaultFrom={this.props.defaultFrom}
defaultTo={this.props.defaultTo}
/>
)}
<div>
{this.props.conversations.map(conv => {
const convId = conv.id
const lastCharIndex = Math.min(convId.indexOf('::') + 6, convId.length)
const convDisplayName = `${convId.substr(0, lastCharIndex)}...`
return (
<div
key={conv.id}
className={style['conversations-entry']}
onClick={() => this.props.onConversationChanged(conv.id)}
>
<span className={style['conversations-sessionId']}>{convDisplayName}</span>
<span className={style['conversations-count']}>({conv.count})</span>
</div>
)
})}
</div>
</div>
)
}
}

0 comments on commit 251fcc0

Please sign in to comment.