Skip to content

Commit

Permalink
feat: add question answer protocol (#557)
Browse files Browse the repository at this point in the history
Signed-off-by: seajensen <[email protected]>
  • Loading branch information
sabejensen authored Jun 5, 2022
1 parent a717a58 commit b5a2536
Show file tree
Hide file tree
Showing 21 changed files with 783 additions and 0 deletions.
3 changes: 3 additions & 0 deletions packages/core/src/agent/Agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { GenericRecordsModule } from '../modules/generic-records/GenericRecordsM
import { LedgerModule } from '../modules/ledger/LedgerModule'
import { OutOfBandModule } from '../modules/oob/OutOfBandModule'
import { ProofsModule } from '../modules/proofs/ProofsModule'
import { QuestionAnswerModule } from '../modules/question-answer/QuestionAnswerModule'
import { MediatorModule } from '../modules/routing/MediatorModule'
import { RecipientModule } from '../modules/routing/RecipientModule'
import { StorageUpdateService } from '../storage'
Expand Down Expand Up @@ -58,6 +59,7 @@ export class Agent {
public readonly basicMessages: BasicMessagesModule
public readonly genericRecords: GenericRecordsModule
public readonly ledger: LedgerModule
public readonly questionAnswer!: QuestionAnswerModule
public readonly credentials: CredentialsModule
public readonly mediationRecipient: RecipientModule
public readonly mediator: MediatorModule
Expand Down Expand Up @@ -123,6 +125,7 @@ export class Agent {
this.mediator = this.container.resolve(MediatorModule)
this.mediationRecipient = this.container.resolve(RecipientModule)
this.basicMessages = this.container.resolve(BasicMessagesModule)
this.questionAnswer = this.container.resolve(QuestionAnswerModule)
this.genericRecords = this.container.resolve(GenericRecordsModule)
this.ledger = this.container.resolve(LedgerModule)
this.discovery = this.container.resolve(DiscoverFeaturesModule)
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export * from './modules/proofs'
export * from './modules/connections'
export * from './modules/ledger'
export * from './modules/routing'
export * from './modules/question-answer'
export * from './modules/oob'
export * from './utils/JsonTransformer'
export * from './logger'
Expand Down
14 changes: 14 additions & 0 deletions packages/core/src/modules/question-answer/QuestionAnswerEvents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { BaseEvent } from '../../agent/Events'
import type { QuestionAnswerState } from './models'
import type { QuestionAnswerRecord } from './repository'

export enum QuestionAnswerEventTypes {
QuestionAnswerStateChanged = 'QuestionAnswerStateChanged',
}
export interface QuestionAnswerStateChangedEvent extends BaseEvent {
type: typeof QuestionAnswerEventTypes.QuestionAnswerStateChanged
payload: {
previousState: QuestionAnswerState | null
questionAnswerRecord: QuestionAnswerRecord
}
}
97 changes: 97 additions & 0 deletions packages/core/src/modules/question-answer/QuestionAnswerModule.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import type { ValidResponse } from './models'

import { Lifecycle, scoped } from 'tsyringe'

import { Dispatcher } from '../../agent/Dispatcher'
import { MessageSender } from '../../agent/MessageSender'
import { createOutboundMessage } from '../../agent/helpers'
import { ConnectionService } from '../connections'

import { AnswerMessageHandler, QuestionMessageHandler } from './handlers'
import { QuestionAnswerService } from './services'

@scoped(Lifecycle.ContainerScoped)
export class QuestionAnswerModule {
private questionAnswerService: QuestionAnswerService
private messageSender: MessageSender
private connectionService: ConnectionService

public constructor(
dispatcher: Dispatcher,
questionAnswerService: QuestionAnswerService,
messageSender: MessageSender,
connectionService: ConnectionService
) {
this.questionAnswerService = questionAnswerService
this.messageSender = messageSender
this.connectionService = connectionService
this.registerHandlers(dispatcher)
}

/**
* Create a question message with possible valid responses, then send message to the
* holder
*
* @param connectionId connection to send the question message to
* @param config config for creating question message
* @returns QuestionAnswer record
*/
public async sendQuestion(
connectionId: string,
config: {
question: string
validResponses: ValidResponse[]
detail?: string
}
) {
const connection = await this.connectionService.getById(connectionId)
connection.assertReady()

const { questionMessage, questionAnswerRecord } = await this.questionAnswerService.createQuestion(connectionId, {
question: config.question,
validResponses: config.validResponses,
detail: config?.detail,
})
const outboundMessage = createOutboundMessage(connection, questionMessage)
await this.messageSender.sendMessage(outboundMessage)

return questionAnswerRecord
}

/**
* Create an answer message as the holder and send it in response to a question message
*
* @param questionRecordId the id of the questionAnswer record
* @param response response included in the answer message
* @returns QuestionAnswer record
*/
public async sendAnswer(questionRecordId: string, response: string) {
const questionRecord = await this.questionAnswerService.getById(questionRecordId)

const { answerMessage, questionAnswerRecord } = await this.questionAnswerService.createAnswer(
questionRecord,
response
)

const connection = await this.connectionService.getById(questionRecord.connectionId)

const outboundMessage = createOutboundMessage(connection, answerMessage)
await this.messageSender.sendMessage(outboundMessage)

return questionAnswerRecord
}

/**
* Get all QuestionAnswer records
*
* @returns list containing all QuestionAnswer records
*/
public getAll() {
return this.questionAnswerService.getAll()
}

private registerHandlers(dispatcher: Dispatcher) {
dispatcher.registerHandler(new QuestionMessageHandler(this.questionAnswerService))
dispatcher.registerHandler(new AnswerMessageHandler(this.questionAnswerService))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export enum QuestionAnswerRole {
Questioner = 'questioner',
Responder = 'responder',
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import type { AgentConfig } from '../../../agent/AgentConfig'
import type { Repository } from '../../../storage/Repository'
import type { QuestionAnswerStateChangedEvent } from '../QuestionAnswerEvents'
import type { ValidResponse } from '../models'

import { getAgentConfig, getMockConnection, mockFunction } from '../../../../tests/helpers'
import { EventEmitter } from '../../../agent/EventEmitter'
import { IndyWallet } from '../../../wallet/IndyWallet'
import { QuestionAnswerEventTypes } from '../QuestionAnswerEvents'
import { QuestionAnswerRole } from '../QuestionAnswerRole'
import { QuestionMessage } from '../messages'
import { QuestionAnswerState } from '../models'
import { QuestionAnswerRecord, QuestionAnswerRepository } from '../repository'
import { QuestionAnswerService } from '../services'

jest.mock('../repository/QuestionAnswerRepository')
const QuestionAnswerRepositoryMock = QuestionAnswerRepository as jest.Mock<QuestionAnswerRepository>

describe('QuestionAnswerService', () => {
const mockConnectionRecord = getMockConnection({
id: 'd3849ac3-c981-455b-a1aa-a10bea6cead8',
did: 'did:sov:C2SsBf5QUQpqSAQfhu3sd2',
})

let wallet: IndyWallet
let agentConfig: AgentConfig
let questionAnswerRepository: Repository<QuestionAnswerRecord>
let questionAnswerService: QuestionAnswerService
let eventEmitter: EventEmitter

const mockQuestionAnswerRecord = (options: {
questionText: string
questionDetail?: string
connectionId: string
role: QuestionAnswerRole
signatureRequired: boolean
state: QuestionAnswerState
threadId: string
validResponses: ValidResponse[]
}) => {
return new QuestionAnswerRecord({
questionText: options.questionText,
questionDetail: options.questionDetail,
connectionId: options.connectionId,
role: options.role,
signatureRequired: options.signatureRequired,
state: options.state,
threadId: options.threadId,
validResponses: options.validResponses,
})
}

beforeAll(async () => {
agentConfig = getAgentConfig('QuestionAnswerServiceTest')
wallet = new IndyWallet(agentConfig)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
await wallet.createAndOpen(agentConfig.walletConfig!)
})

beforeEach(async () => {
questionAnswerRepository = new QuestionAnswerRepositoryMock()
eventEmitter = new EventEmitter(agentConfig)
questionAnswerService = new QuestionAnswerService(questionAnswerRepository, eventEmitter, agentConfig)
})

afterAll(async () => {
await wallet.delete()
})

describe('create question', () => {
it(`emits a question with question text, valid responses, and question answer record`, async () => {
const eventListenerMock = jest.fn()
eventEmitter.on<QuestionAnswerStateChangedEvent>(
QuestionAnswerEventTypes.QuestionAnswerStateChanged,
eventListenerMock
)

const questionMessage = new QuestionMessage({
questionText: 'Alice, are you on the phone with Bob?',
signatureRequired: false,
validResponses: [{ text: 'Yes' }, { text: 'No' }],
})

await questionAnswerService.createQuestion(mockConnectionRecord.id, {
question: questionMessage.questionText,
validResponses: questionMessage.validResponses,
})

expect(eventListenerMock).toHaveBeenCalledWith({
type: 'QuestionAnswerStateChanged',
payload: {
previousState: null,
questionAnswerRecord: expect.objectContaining({
connectionId: mockConnectionRecord.id,
questionText: questionMessage.questionText,
role: QuestionAnswerRole.Questioner,
state: QuestionAnswerState.QuestionSent,
validResponses: questionMessage.validResponses,
}),
},
})
})
})
describe('create answer', () => {
let mockRecord: QuestionAnswerRecord

beforeAll(() => {
mockRecord = mockQuestionAnswerRecord({
questionText: 'Alice, are you on the phone with Bob?',
connectionId: mockConnectionRecord.id,
role: QuestionAnswerRole.Responder,
signatureRequired: false,
state: QuestionAnswerState.QuestionReceived,
threadId: '123',
validResponses: [{ text: 'Yes' }, { text: 'No' }],
})
})

it(`throws an error when invalid response is provided`, async () => {
expect(questionAnswerService.createAnswer(mockRecord, 'Maybe')).rejects.toThrowError(
`Response does not match valid responses`
)
})

it(`emits an answer with a valid response and question answer record`, async () => {
const eventListenerMock = jest.fn()
eventEmitter.on<QuestionAnswerStateChangedEvent>(
QuestionAnswerEventTypes.QuestionAnswerStateChanged,
eventListenerMock
)

mockFunction(questionAnswerRepository.getSingleByQuery).mockReturnValue(Promise.resolve(mockRecord))

await questionAnswerService.createAnswer(mockRecord, 'Yes')

expect(eventListenerMock).toHaveBeenCalledWith({
type: 'QuestionAnswerStateChanged',
payload: {
previousState: QuestionAnswerState.QuestionReceived,
questionAnswerRecord: expect.objectContaining({
connectionId: mockConnectionRecord.id,
role: QuestionAnswerRole.Responder,
state: QuestionAnswerState.AnswerSent,
response: 'Yes',
}),
},
})
})
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { Handler, HandlerInboundMessage } from '../../../agent/Handler'
import type { QuestionAnswerService } from '../services'

import { AnswerMessage } from '../messages'

export class AnswerMessageHandler implements Handler {
private questionAnswerService: QuestionAnswerService
public supportedMessages = [AnswerMessage]

public constructor(questionAnswerService: QuestionAnswerService) {
this.questionAnswerService = questionAnswerService
}

public async handle(messageContext: HandlerInboundMessage<AnswerMessageHandler>) {
await this.questionAnswerService.receiveAnswer(messageContext)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { Handler, HandlerInboundMessage } from '../../../agent/Handler'
import type { QuestionAnswerService } from '../services'

import { QuestionMessage } from '../messages'

export class QuestionMessageHandler implements Handler {
private questionAnswerService: QuestionAnswerService
public supportedMessages = [QuestionMessage]

public constructor(questionAnswerService: QuestionAnswerService) {
this.questionAnswerService = questionAnswerService
}

public async handle(messageContext: HandlerInboundMessage<QuestionMessageHandler>) {
await this.questionAnswerService.processReceiveQuestion(messageContext)
}
}
2 changes: 2 additions & 0 deletions packages/core/src/modules/question-answer/handlers/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './QuestionMessageHandler'
export * from './AnswerMessageHandler'
7 changes: 7 additions & 0 deletions packages/core/src/modules/question-answer/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export * from './messages'
export * from './models'
export * from './services'
export * from './repository'
export * from './QuestionAnswerEvents'
export * from './QuestionAnswerModule'
export * from './QuestionAnswerRole'
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { Expose } from 'class-transformer'
import { IsString } from 'class-validator'

import { AgentMessage } from '../../../agent/AgentMessage'
import { IsValidMessageType, parseMessageType } from '../../../utils/messageType'

export class AnswerMessage extends AgentMessage {
/**
* Create new AnswerMessage instance.
* @param options
*/
public constructor(options: { id?: string; response: string; threadId: string }) {
super()

if (options) {
this.id = options.id || this.generateId()
this.setThread({ threadId: options.threadId })
this.response = options.response
}
}

@IsValidMessageType(AnswerMessage.type)
public readonly type = AnswerMessage.type.messageTypeUri
public static readonly type = parseMessageType('https://didcomm.org/questionanswer/1.0/answer')

@Expose({ name: 'response' })
@IsString()
public response!: string
}
Loading

0 comments on commit b5a2536

Please sign in to comment.