diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 7140162..9820efe 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -1,6 +1,8 @@ -// eslint-disable-next-line semi - const rules = { + 'array-bracket-spacing': [ + 'error', + 'always', + ], } module.exports = { diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..bc6eb1c --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,27 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "pwa-node", + "request": "launch", + "name": "Launch Program", + "runtimeExecutable": "/home/huan/.nvm/versions/node/v16.13.2/bin/node", + "runtimeArgs": [ + "--loader", + "ts-node/esm", + "--no-warnings", + ], + "skipFiles": [ + "/**", + "${workspaceFolder}/node_modules/**/*.js", + ], + "program": "${workspaceFolder}/src/application-actors/message-to-text/machine.spec.ts", + "outFiles": [ + "${workspaceFolder}/**/*.js" + ] + } + ] +} diff --git a/README.md b/README.md index 3262c6e..23f5f36 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,135 @@ BOT5 Meeting Assistant BOT powered by RSVP.ai & Wechaty & XState. ![BOT5 Club](docs/images/caq-bot5-qingyu.webp) +## BOT5 Club Seminar Flow Chart + +```mermaid +flowchart TD + Initializing --> Loading + subgraph Loading + LoadSavedState + end + + Loading --> Restoring + subgraph Restoring + SetContext + TransitionToLastStateOrCalling + end + + Restoring --> Calling + subgraph Calling + SetChairs + SetTalks + SetSchedule + end + + Calling --> Registering + subgraph Registering + attendees + EveryDay + 8h + 4h + 2h + 1h + 20m + 10m + end + + Registering --> Checkining + Checkining --> Starting + Starting --> Introducing + Introducing --> Retrospecting + Retrospecting --> Welcoming + subgraph Welcoming + Joining + introducing + end + + Welcoming --> Presenting + Presenting --> Promoting + subgraph Promoting + Newcomer + TrialMember + Member + TrialChair + Chair + end + + Promoting --> Brainstorming + Brainstorming --> Roasting + Roasting --> Chairing + subgraph Chairing + Electing + Naming + Voting + Deciding + Summarizing + Pledging + end + + Chairing --> Photoing + subgraph Photoing + ShootingChairs + ShootingAll + end + + Photoing --> Housekeeping + Housekeeping --> AfterParty + subgraph AfterParty + Chatting + Drinking + ShootingDrinkers + Paying + end + + AfterParty --> Completing + Completing --> Completed +``` + +``` + + + +``` + +See also: [BOT5 Club Chair Manual](http://bot5.ml/manuals/chair/) + +## User Journey Diagram + +Learn more from [BOT Friday Club Seminar Chair Manual](http://bot5.ml/manuals/chair/) + +To be writen... + +### Registering + +```mermaid +journey + title Registering - BOT Friday Club Seminar + section Register + Make tea: 5: Chair + Go upstairs: 3: Chair + Do work: 1: Member + Miao: 3: Cat, Member + section Go home + Go downstairs: 5: Chair + Sit down: 5: Member +``` + +### Mindstorming + +```mermaid +journey + title Mindstorming - BOT Friday Club Seminar + section Mindsotming + Make tea: 5: Chair + Go upstairs: 3: Chair + Do work: 1: Member + Miao: 3: Cat, Member + section Member PTT + Go downstairs: 5: Chair + Sit down: 5: Member +``` + ## Getting Started ### Step 1: Install @@ -69,6 +198,19 @@ We are trying to use [XState](https://xstate.js.org/) to implement the FSM and u Currently we are just getting started, you can learn more about the BOT5 Club Meeting FSM at +## Resources + +### Projects + +- [Dialogs modeled as finite state machines, Giorgio Robino, Jul 14, 2016](https://solyarisoftware.medium.com/dialoghi-come-macchine-a-stati-41bb748fd5b0) + +### Papers + +- [Dialog Management for Credit Card Selling via Finite State Machine Using Sentiment Classification in Turkish Language, Gizem Sogancıo ˘ glu et. al., INTELLI 2017 : The Sixth International Conference on Intelligent Systems and Applications (includes InManEnt)](https://www.thinkmind.org/articles/intelli_2017_2_30_60066.pdf) +- [State Machine Based Human-Bot Conversation Model and Services, Shayan Zamanirad et. al., May 9, 2020, CAiSE 2020](https://www.semanticscholar.org/paper/State-Machine-Based-Human-Bot-Conversation-Model-Zamanirad-Benatallah/ffa524c4e247a9f532ea4ddb6407be0c9cc8d301) +- [Tartan: A retrieval-based socialbot powered by a dynamic finite-state machine architecture, George Larionov, 4 Dec 2018](https://arxiv.org/abs/1812.01260) +- [A Chatbot by Combining Finite State Machine , Information Retrieval , and Bot-Initiative Strategy, Sanghyun Yi, Published 2017](https://www.semanticscholar.org/paper/A-Chatbot-by-Combining-Finite-State-Machine-%2C-%2C-and-Yi/1fc7c24d80ede54871696e8e44a60fb6d0c8a475) + ## History ### main v0.3 (Nov 29, 2021) diff --git a/package.json b/package.json index f1d91e8..f18b16b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wechaty-bot5-assistant", - "version": "0.3.8", + "version": "1.11.0", "description": "BOT Friday Club Assistant", "type": "module", "exports": { @@ -31,9 +31,16 @@ "wechaty" ], "author": { - "name": "Anqi CUI", - "url": "https://wechaty.js.org/contributors/caq" + "name": "Huan LI", + "url": "https://wechaty.js.org/contributors/huan", + "email": "zixia@zixia.net" }, + "contributors": [ + { + "name": "Anqi CUI", + "url": "https://wechaty.js.org/contributors/caq" + } + ], "repository": { "type": "git", "url": "git+https://github.com/wechaty/bot5-assistant.git" @@ -45,26 +52,36 @@ "license": "Apache-2.0", "devDependencies": { "@chatie/eslint-config": "^1.0.4", - "@chatie/git-scripts": "^0.6.2", + "@chatie/git-scripts": "^0.7.7", "@chatie/semver": "^0.4.7", - "@chatie/tsconfig": "^4.6.2", - "@types/lru-cache": "^5.1.1", - "@types/request": "^2.48.7", - "@types/uuid": "^8.3.3", - "wechaty": "^1", - "wechaty-mocker": "^1.10.2", - "wechaty-puppet-mock": "^1" + "@chatie/tsconfig": "^4.6.3", + "@types/lru-cache": "^7.4.0", + "@types/request": "^2.48.8", + "@types/uuid": "^8.3.4", + "@types/ws": "^8.5.3", + "@xstate/inspect": "^0.6.4", + "typescript": "^4.6.2", + "utility-types": "^3.10.0", + "wechaty": "^1.18.1", + "wechaty-mocker": "^1.11.3", + "wechaty-puppet-mock": "^1.19.3", + "ws": "^8.5.0" }, "peerDependencies": { - "wechaty-plugin-contrib": "^1" + "wechaty-plugin-contrib": "^1.11.1" }, "readme": "README.md", "dependencies": { - "lru-cache": "^6.0.0", + "file-box": "^1.4.15", + "lru-cache": "^7.5.0", + "mailbox": "^0.10.9", "request": "^2.88.2", - "tencentcloud-sdk-nodejs": "^4.0.246", + "tencentcloud-sdk-nodejs": "^4.0.306", + "typed-inject": "^3.0.1", + "typesafe-actions": "^5.1.0", "uuid": "^8.3.2", - "xstate": "^4.26.1" + "wechaty-cqrs": "^0.15.4", + "xstate": "^4.31.0" }, "git": { "scripts": { diff --git a/src/actor-utils/invoke-id.spec.ts b/src/actor-utils/invoke-id.spec.ts new file mode 100755 index 0000000..1f66ae5 --- /dev/null +++ b/src/actor-utils/invoke-id.spec.ts @@ -0,0 +1,17 @@ +#!/usr/bin/env -S node --no-warnings --loader ts-node/esm +import { test } from 'tstest' + +import { invokeId } from './invoke-id.js' + +test('invokeId() smoke testing', async t => { + const FIXTURES = [ + [ + [ 'childId', 'parentId', 'id1', 'id2' ], + 'childId@parentId@id1@id2', + ], + ] as const + + for (const [ ids, expected ] of FIXTURES) { + t.equal(invokeId(ids[0], ids[1], ...ids.slice(2)), expected, `invokeId(${ids}) should be ${expected}`) + } +}) diff --git a/src/actor-utils/invoke-id.ts b/src/actor-utils/invoke-id.ts new file mode 100644 index 0000000..f50860f --- /dev/null +++ b/src/actor-utils/invoke-id.ts @@ -0,0 +1,13 @@ +/** + * Generate an invoke id in an machine for make it distinct + * + * @param childId { string } - the id of the child actor + * @param parentId { string } - the id of the parent actor + * @param ids - additional ids + * @returns generated invoke id + */ +export function invokeId (childId: string, parentId: string, ...ids: string[]) { + return [ parentId, ...ids ].reduce((acc, id) => { + return acc + '@' + id + }, childId) +} diff --git a/src/actor-utils/mod.ts b/src/actor-utils/mod.ts new file mode 100644 index 0000000..7ce8e00 --- /dev/null +++ b/src/actor-utils/mod.ts @@ -0,0 +1 @@ +export { responseStates } from './response-states.js' diff --git a/src/actor-utils/response-states.spec.ts b/src/actor-utils/response-states.spec.ts new file mode 100755 index 0000000..b209a1a --- /dev/null +++ b/src/actor-utils/response-states.spec.ts @@ -0,0 +1,155 @@ +#!/usr/bin/env -S node --no-warnings --loader ts-node/esm +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +/* eslint-disable sort-keys */ +import { + AnyEventObject, + createMachine, + interpret, + actions, + Interpreter, +} from 'xstate' +import { test } from 'tstest' +import * as Mailbox from 'mailbox' +import { isActionOf } from 'typesafe-actions' + +import * as duck from '../duck/mod.js' + +import { responseStates } from './response-states.js' + +test('statesResponse() smoke testing', async t => { + const childId = 'child-id' + const childMachine = createMachine({ + id: childId, + initial: duck.State.Idle, + states: { + [duck.State.Idle]: { + on: { + [duck.Type.TEST] : duck.State.Responding, + [duck.Type.BATCH] : duck.State.Responding, + [duck.Type.GERROR] : duck.State.Erroring, + }, + }, + ...responseStates(childId), + }, + }) + + const parentId = 'parent-id' + const parentMachine = createMachine({ + id: parentId, + invoke: { + id: childId, + src: childMachine, + }, + on: { + [duck.Type.TEST]: { + actions: [ + actions.send((_, e) => e, { to: childId }), + ], + }, + [duck.Type.GERROR]: { + actions: [ + actions.send((_, e) => e, { to: childId }), + ], + }, + [duck.Type.BATCH]: { + actions: [ + actions.send((_, e) => e, { to: childId }), + ], + }, + }, + }) + + const eventList: AnyEventObject[] = [] + const interpreter = interpret(parentMachine) + .onEvent(e => eventList.push(e)) + .start() + + const childInterpreter = interpreter.children.get(childId) as Interpreter + const childState = () => childInterpreter.getSnapshot().value + + /** + * Responding + */ + const TEST = duck.Event.TEST() + interpreter.send(TEST) + await new Promise(resolve => setTimeout(resolve, 0)) + + // eventList.forEach(e => console.info(e)) + t.same( + eventList.filter(isActionOf([ + duck.Event.TEST, + Mailbox.Event.ACTOR_REPLY, + ])), + [ + TEST, + Mailbox.Event.ACTOR_REPLY(duck.Event.TEST()), + ], + 'should process TEST with Responding and respond ACTOR_REPLY', + ) + t.equal(childState(), duck.State.Idle, 'should back to State.Idle') + + /** + * Erroring + */ + eventList.length = 0 + const GERROR = duck.Event.GERROR('test') + interpreter.send(GERROR) + await new Promise(resolve => setTimeout(resolve, 0)) + + // eventList.forEach(e => console.info(e)) + t.same( + eventList.filter(isActionOf([ + duck.Event.GERROR, + Mailbox.Event.ACTOR_REPLY, + ])), + [ + GERROR, + Mailbox.Event.ACTOR_REPLY(GERROR), + ], + 'should process GERROR with Responding and respond ACTOR_REPLY', + ) + t.equal(childState(), duck.State.Idle, 'should back to State.Idle') + + /** + * Batching + */ + eventList.length = 0 + const BATCH = duck.Event.BATCH([ duck.Event.TEST(), duck.Event.TEST() ]) + interpreter.send(BATCH) + await new Promise(resolve => setTimeout(resolve, 0)) + + eventList.forEach(e => console.info(e)) + t.same( + eventList.filter(isActionOf([ + duck.Event.BATCH, + Mailbox.Event.ACTOR_REPLY, + ])), + [ + BATCH, + Mailbox.Event.ACTOR_REPLY(duck.Event.TEST()), + Mailbox.Event.ACTOR_REPLY(duck.Event.TEST()), + ], + 'should process BATCH with Responding and respond two ACTOR_REPLYs', + ) + t.equal(childState(), duck.State.Idle, 'should back to State.Idle') + + interpreter.stop() +}) diff --git a/src/actor-utils/response-states.ts b/src/actor-utils/response-states.ts new file mode 100644 index 0000000..189607e --- /dev/null +++ b/src/actor-utils/response-states.ts @@ -0,0 +1,42 @@ +/* eslint-disable sort-keys */ +import { actions, AnyEventObject, EventObject } from 'xstate' +import * as Mailbox from 'mailbox' +import { isActionOf } from 'typesafe-actions' +import { GError } from 'gerror' + +import * as duck from '../duck/mod.js' + +/** + * Extend the machine states to support `Responding` and `Erroring` states. + * + * send an [EVENT] that need to be responded to `State.Responding` + * send the [GERROR] that need to be responded to `State.Erroring` + * + * - State.Responding: respond events to the parent machine wrapped within Mailbox.Event.ACTOR_REPLY. + * - State.Erroring: respond events to the parent machine wrapped within GERROR. + * + * @param id { string } - duckula.id + * @returns { states } standard `Responding` & `Erroring` states. + */ +export const responseStates = (id: string) => ({ + [duck.State.Responding]: { + entry: [ + actions.log((_, e) => `states.Responding.entry [${e.type}]`, id), + Mailbox.actions.reply((_, e) => e), + ], + always: duck.State.Idle, + }, + + [duck.State.Erroring]: { + entry: [ + actions.log>((_, e) => `states.Erroring.entry [${e.type}] ${e.payload.gerror}`, id), + Mailbox.actions.reply( + (_, e) => isActionOf(duck.Event.GERROR, e) + ? e + : duck.Event.GERROR(GError.stringify(e)) + , + ), + ], + always: duck.State.Idle, + }, +}) diff --git a/src/application-actors/message-to-file/duckula.ts b/src/application-actors/message-to-file/duckula.ts new file mode 100644 index 0000000..5dc8b8f --- /dev/null +++ b/src/application-actors/message-to-file/duckula.ts @@ -0,0 +1,68 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +/* eslint-disable sort-keys */ +import * as Mailbox from 'mailbox' +import * as CQRS from 'wechaty-cqrs' +import type* as PUPPET from 'wechaty-puppet' + +import * as duck from '../../duck/mod.js' + +export interface Context { + message?: PUPPET.payloads.Message + actors: { + wechaty: string + } +} + +const duckula = Mailbox.duckularize({ + id: 'MessageToFile', + events: [ { ...duck.Event, ...CQRS.duck.actions }, [ + /** + * Request + */ + 'MESSAGE', + /** + * Response + */ + 'FILE', + 'NO_FILE', + 'GERROR', + /** + * Internal + */ + ] ], + states: [ duck.State, [ + 'Classifying', + 'Erroring', + 'Idle', + 'Initializing', + 'Loaded', + 'Loading', + 'Responding', + ] ], + initialContext: {} as Context, +}) + +export type Event = ReturnType +export type Events = { + [key in keyof typeof duckula.Event]: ReturnType +} + +export default duckula diff --git a/src/application-actors/message-to-file/file-message-types.ts b/src/application-actors/message-to-file/file-message-types.ts new file mode 100644 index 0000000..b937e5b --- /dev/null +++ b/src/application-actors/message-to-file/file-message-types.ts @@ -0,0 +1,28 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import * as PUPPET from 'wechaty-puppet' + +export const fileMessageTypes = [ + PUPPET.types.Message.Attachment, + PUPPET.types.Message.Audio, + PUPPET.types.Message.Emoticon, + PUPPET.types.Message.Image, + PUPPET.types.Message.Video, +] diff --git a/src/application-actors/message-to-file/machine.spec.ts b/src/application-actors/message-to-file/machine.spec.ts new file mode 100755 index 0000000..5c17ff9 --- /dev/null +++ b/src/application-actors/message-to-file/machine.spec.ts @@ -0,0 +1,146 @@ +#!/usr/bin/env -S node --no-warnings --loader ts-node/esm +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +/* eslint-disable sort-keys */ +import { + AnyEventObject, + createMachine, + interpret, +} from 'xstate' +import { test } from 'tstest' +import * as Mailbox from 'mailbox' +import { filter, map, mergeMap } from 'rxjs/operators' +import { isActionOf } from 'typesafe-actions' +import * as CQRS from 'wechaty-cqrs' +import { FileBox } from 'file-box' + +import * as WechatyActor from '../../wechaty-actor/mod.js' +import { bot5Fixtures } from '../../fixtures/bot5-fixture.js' + +import machine from './machine.js' +import duckula from './duckula.js' + +test('MessageToFile actor smoke testing', async t => { + for await (const fixtures of bot5Fixtures()) { + const bus$ = CQRS.from(fixtures.wechaty.wechaty) + const wechatyActor = WechatyActor.from(bus$, fixtures.wechaty.wechaty.puppet.id) + + const mailbox = Mailbox.from(machine.withContext({ + actors: { + wechaty: String(wechatyActor.address), + }, + })) + mailbox.open() + + const consumerMachine = createMachine({ + on: { + '*': { + actions: [ + Mailbox.actions.proxy('TestMachine')(mailbox), + ], + }, + }, + }) + + const eventList: AnyEventObject[] = [] + const interpreter = interpret(consumerMachine) + .onEvent(e => eventList.push(e)) + .start() + + ;(mailbox as Mailbox.impls.Mailbox).internal.actor.interpreter?.subscribe(s => { + console.info(s.value) + console.info('>>> transition:', [ + `(${s.history?.value || ''})`.padEnd(30, ' '), + ' + ', + `[${s.event.type}]`.padEnd(30, ' '), + ' = ', + `(${s.value})`.padEnd(30, ' '), + ].join('')) + }) + + bus$.pipe( + // tap(e => console.info('### bus$', e)), + filter(CQRS.is(CQRS.events.MessageReceivedEvent)), + map(e => CQRS.queries.GetMessagePayloadQuery(fixtures.wechaty.wechaty.puppet.id, e.payload.messageId)), + mergeMap(CQRS.execute$(bus$)), + map(response => response.payload.message), + filter(Boolean), + map(messagePayload => duckula.Event.MESSAGE(messagePayload)), + ).subscribe(e => { + // console.info('### duckula.Event.MESSAGE', e) + interpreter.send(e) + }) + + const FILE_BOX_FIXTURE = FileBox.fromBase64( + await FileBox.fromBuffer( + Buffer.from('test', 'utf-8'), + ).toBase64(), + 'test.jpg', + ) + + const FIXTURES = [ + [ 'hello world', duckula.Event.NO_FILE() ], + [ FILE_BOX_FIXTURE, duckula.Event.FILE(JSON.stringify(FILE_BOX_FIXTURE)) ], + ] as const + + for (const [ sayable, expected ] of FIXTURES) { + + eventList.length = 0 + + const future = new Promise(resolve => + interpreter.onEvent(e => + isActionOf([ + duckula.Event.FILE, + duckula.Event.NO_FILE, + ], e) && resolve(e), + ), + ) + + fixtures.mocker.player.say(sayable).to(fixtures.mocker.bot) + await future + + // eventList.forEach(e => console.info(e)) + t.same( + eventList + .filter(isActionOf([ + duckula.Event.FILE, + duckula.Event.NO_FILE, + ])) + , + [ + { + ...expected, + payload: { + ...expected.payload, + message: eventList + .filter(isActionOf(duckula.Event.MESSAGE)) + .at(-1)! + .payload + .message, + }, + }, + ], + `should get expected "${expected}" for "${FileBox.valid(sayable) ? sayable.name : sayable}"`, + ) + } + + interpreter.stop() + } +}) diff --git a/src/application-actors/message-to-file/machine.ts b/src/application-actors/message-to-file/machine.ts new file mode 100644 index 0000000..8d40773 --- /dev/null +++ b/src/application-actors/message-to-file/machine.ts @@ -0,0 +1,154 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +/* eslint-disable sort-keys */ +import { createMachine, actions } from 'xstate' +import * as Mailbox from 'mailbox' +import * as CQRS from 'wechaty-cqrs' +import * as PUPPET from 'wechaty-puppet' + +import { responseStates } from '../../actor-utils/mod.js' +import * as WechatyActor from '../../wechaty-actor/mod.js' + +import duckula, { Context, Event, Events } from './duckula.js' +import { fileMessageTypes } from './file-message-types.js' + +const machine = createMachine< + Context, + Event | ReturnType | WechatyActor.Events['GERROR'] +>({ + id: duckula.id, + context: duckula.initialContext, + + initial: duckula.State.Initializing, + states: { + [duckula.State.Initializing]: { + entry: [ + actions.log(ctx => `states.Initializing.entry context ${JSON.stringify(ctx)}`, duckula.id), + ], + always: duckula.State.Idle, + }, + + /** + * + * Idle + * + * 1. receive MESSAGE -> transition to Classifying + * + */ + [duckula.State.Idle]: { + entry: [ + Mailbox.actions.idle(duckula.id), + actions.assign({ message: undefined }), + ], + on: { + [duckula.Type.MESSAGE]: { + actions: actions.assign({ message: (_, e) => e.payload.message }), + target: duckula.State.Classifying, + }, + }, + }, + + /** + * Classifying + * + * 1. received MESSAGE -> MESSAGE / NO_FILE + */ + + [duckula.State.Classifying]: { + entry: [ + actions.log((_, e) => `states.Classifying.entry ${PUPPET.types.Message[e.payload.message.type]}`, duckula.id), + actions.choose([ + { + cond: (_, e) => fileMessageTypes.includes(e.payload.message.type), + actions: actions.send((_, e) => e), + }, + { actions: actions.send(duckula.Event.NO_FILE()) }, + ]), + ], + on: { + [duckula.Type.MESSAGE] : duckula.State.Loading, + [duckula.Type.NO_FILE] : duckula.State.Loaded, + }, + }, + + /** + * Load + * + * 1. received MESSAGE -> emit GET_MESSAGE_FILE_QUERY_RESPONSE + * 2. received GET_MESSAGE_FILE_QUERY_RESPONSE -> emit FILE_BOX / GERROR + * + * 3. received FILE_BOX -> transition to Loaded + * 4. received GERROR -> transition to Erroring + */ + [duckula.State.Loading]: { + entry: [ + actions.log('states.Loading.entry', duckula.id), + actions.send( + (_, e) => CQRS.duck.actions.GET_MESSAGE_FILE_QUERY( + CQRS.uuid.NIL, + e.payload.message.id, + ), + { to: ctx => ctx.actors.wechaty }, + ), + ], + on: { + [CQRS.duck.types.GET_MESSAGE_FILE_QUERY_RESPONSE]: { + actions: [ + actions.log('states.Loading.on.GET_MESSAGE_FILE_QUERY_RESPONSE', duckula.id), + actions.send((_, e) => e.payload.file + ? duckula.Event.FILE(e.payload.file) + : duckula.Event.NO_FILE() + , + ), + ], + }, + [WechatyActor.Type.GERROR]: { + actions: actions.send((_, e) => duckula.Event.GERROR(e.payload.gerror)), + }, + [duckula.Type.FILE] : duckula.State.Loaded, + [duckula.Type.NO_FILE] : duckula.State.Loaded, + [duckula.Type.GERROR] : duckula.State.Erroring, + }, + }, + + [duckula.State.Loaded]: { + entry: [ + actions.log((_, e) => `states.Loaded.entry [${e.type}]`, duckula.id), + actions.send( + (ctx, e) => ({ + ...e, + payload: { + ...e.payload, + message: ctx.message, + }, + }), + ), + ], + on: { + [duckula.Type.FILE] : duckula.State.Responding, + [duckula.Type.NO_FILE] : duckula.State.Responding, + }, + }, + + ...responseStates(duckula.id), + }, +}) + +export default machine diff --git a/src/application-actors/message-to-file/mod.spec.ts b/src/application-actors/message-to-file/mod.spec.ts new file mode 100755 index 0000000..9ae5ead --- /dev/null +++ b/src/application-actors/message-to-file/mod.spec.ts @@ -0,0 +1,35 @@ +#!/usr/bin/env -S node --no-warnings --loader ts-node/esm +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import { test } from 'tstest' + +import type { Duckula } from 'mailbox' + +import * as mod from './mod.js' + +test('mod is a Duckula', async t => { + const duckula: Duckula = mod + t.ok(duckula, 'should satisfy Duckula Interface for mod export') +}) + +test('mod.Context', async t => { + const context: mod.Context = {} as any + t.ok(context, 'should has Context interface') +}) diff --git a/src/application-actors/message-to-file/mod.ts b/src/application-actors/message-to-file/mod.ts new file mode 100644 index 0000000..698e041 --- /dev/null +++ b/src/application-actors/message-to-file/mod.ts @@ -0,0 +1,34 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import duckula, { type Context } from './duckula.js' +import machine from './machine.js' + +export const { + id, + Event, + State, + Type, + initialContext, +} = duckula + +export { + machine, + type Context, +} diff --git a/src/application-actors/message-to-intents/duckula.ts b/src/application-actors/message-to-intents/duckula.ts new file mode 100644 index 0000000..00aa797 --- /dev/null +++ b/src/application-actors/message-to-intents/duckula.ts @@ -0,0 +1,77 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +/* eslint-disable sort-keys */ +import * as Mailbox from 'mailbox' +import type * as PUPPET from 'wechaty-puppet' + +import * as duck from '../../duck/mod.js' + +export interface Context { + message?: PUPPET.payloads.Message + actors: { + wechaty: string, + }, +} + +const duckula = Mailbox.duckularize({ + id: 'MessageToIntent', + events: [ duck.Event, [ + /** + * Request + */ + 'MESSAGE', + /** + * Response + */ + 'INTENTS', + 'GERROR', + /** + * Internal + */ + 'TEXT', + 'NO_TEXT', + ] ], + states: [ duck.State, [ + /** + * Request + */ + 'Idle', + /** + * Response + */ + 'Responding', + 'Erroring', + /** + * Internal + */ + 'Initializing', + 'Textualizing', + 'Understanding', + 'Understood', + ] ], + initialContext: {}, +}) + +export type Event = ReturnType +export type Events = { + [key in keyof typeof duckula.Event]: ReturnType +} + +export default duckula diff --git a/src/application-actors/message-to-intents/machine.spec.ts b/src/application-actors/message-to-intents/machine.spec.ts new file mode 100755 index 0000000..653f279 --- /dev/null +++ b/src/application-actors/message-to-intents/machine.spec.ts @@ -0,0 +1,126 @@ +#!/usr/bin/env -S node --no-warnings --loader ts-node/esm +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +/* eslint-disable sort-keys */ +import { + AnyEventObject, + createMachine, + interpret, +} from 'xstate' +import { test } from 'tstest' +import * as Mailbox from 'mailbox' +import { filter, map, mergeMap } from 'rxjs/operators' +import { isActionOf } from 'typesafe-actions' +import * as CQRS from 'wechaty-cqrs' +import { FileBox } from 'file-box' + +import * as duck from '../../duck/mod.js' + +import * as WechatyActor from '../../wechaty-actor/mod.js' +import { bot5Fixtures } from '../../fixtures/bot5-fixture.js' +import { FileToText, TextToIntents } from '../../infrastructure-actors/mod.js' + +import machine from './machine.js' +import duckula from './duckula.js' + +test('MessageToIntents actor smoke testing', async t => { + for await (const fixtures of bot5Fixtures()) { + const bus$ = CQRS.from(fixtures.wechaty.wechaty) + const wechatyActor = WechatyActor.from(bus$, fixtures.wechaty.wechaty.puppet.id) + + const mailbox = Mailbox.from(machine.withContext({ + actors: { + wechaty: String(wechatyActor.address), + }, + })) + mailbox.open() + + const consumerMachine = createMachine({ + on: { + '*': { + actions: [ + Mailbox.actions.proxy('TestMachine')(mailbox), + ], + }, + }, + }) + + const eventList: AnyEventObject[] = [] + const interpreter = interpret(consumerMachine) + .onEvent(e => eventList.push(e)) + .start() + + bus$.pipe( + // tap(e => console.info('### bus$', e)), + filter(CQRS.is(CQRS.events.MessageReceivedEvent)), + map(e => CQRS.queries.GetMessagePayloadQuery(fixtures.wechaty.wechaty.puppet.id, e.payload.messageId)), + mergeMap(CQRS.execute$(bus$)), + map(response => response.payload.message), + filter(Boolean), + map(messagePayload => duckula.Event.MESSAGE(messagePayload)), + ).subscribe(e => { + // console.info('### duckula.Event.MESSAGE', e) + interpreter.send(e) + }) + + const fileFixtures = await FileToText.FIXTURES() + + const FIXTURES = [ + ...TextToIntents.FIXTURES(), + [ fileFixtures[0][0], [ duck.Intent.CocaCola ] ], + ] as const + + for (const [ sayable, intents ] of FIXTURES) { + + eventList.length = 0 + + const future = new Promise(resolve => + interpreter.onEvent(e => + isActionOf([ + duckula.Event.INTENTS, + duckula.Event.GERROR, + ], e) && resolve(e), + ), + ) + + fixtures.mocker.player.say(sayable).to(fixtures.mocker.bot) + await future + + // eventList.forEach(e => console.info(e)) + t.same( + eventList.filter(isActionOf([ duckula.Event.INTENTS, duckula.Event.GERROR ])), + [ + duckula.Event.INTENTS( + intents, + eventList + .filter(isActionOf(duckula.Event.MESSAGE)) + .at(-1)! + .payload + .message + , + ), + ], + `should get expected "${intents}" for "${FileBox.valid(sayable) ? sayable.name : sayable}"`, + ) + } + + interpreter.stop() + } +}) diff --git a/src/application-actors/message-to-intents/machine.ts b/src/application-actors/message-to-intents/machine.ts new file mode 100644 index 0000000..4b569c4 --- /dev/null +++ b/src/application-actors/message-to-intents/machine.ts @@ -0,0 +1,144 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +/* eslint-disable sort-keys */ +import { createMachine, actions } from 'xstate' +import * as Mailbox from 'mailbox' +import { GError } from 'gerror' +import * as PUPPET from 'wechaty-puppet' + +import { responseStates } from '../../actor-utils/response-states.js' +import { TextToIntents } from '../../infrastructure-actors/mod.js' + +import * as MessageToText from '../message-to-text/mod.js' + +import duckula, { Context, Event, Events } from './duckula.js' + +const machine = createMachine({ + id: duckula.id, + initial: duckula.State.Initializing, + states: { + [duckula.State.Initializing]: { + entry: [ + actions.log(ctx => `states.Initializing.entry context ${JSON.stringify(ctx)}`, duckula.id), + ], + always: duckula.State.Idle, + }, + + /** + * + * Idle + * + * 1. receive MESSAGE -> transition to Filing + * + */ + [duckula.State.Idle]: { + entry: [ + actions.assign({ message: undefined }), + Mailbox.actions.idle(duckula.id), + ], + on: { + [duckula.Type.MESSAGE]: { + actions: actions.assign({ message: (_, e) => e.payload.message }), + target: duckula.State.Textualizing, + }, + }, + }, + + /** + * Textualizing + * + * 1. received MESSAGE -> emit TEXT / NO_TEXT / GERROR + * + * 2. received TEXT -> transition to Understanding + * 3. received NO_TEXT -> transition to Idle + */ + [duckula.State.Textualizing]: { + invoke: { + id: MessageToText.id + '<' + duckula.id + '>', + src: ctx => Mailbox.wrap( + MessageToText.machine.withContext({ + ...MessageToText.initialContext(), + actors: { + wechaty: ctx.actors.wechaty, + }, + }), + ), + onDone: { actions: actions.send((_, e) => duckula.Event.GERROR(GError.stringify(e.data))) }, + onError: { actions: actions.send((_, e) => duckula.Event.GERROR(GError.stringify(e.data))) }, + }, + entry: [ + actions.log( + (_, e) => `state.Textualizing.entry MessageType: ${PUPPET.types.Message[e.payload.message.type]}`, + duckula.id, + ), + actions.send((_, e) => e, { to: MessageToText.id + '<' + duckula.id + '>' }), + ], + on: { + [duckula.Type.NO_TEXT]: { + actions: actions.send(duckula.Event.INTENTS([])), + }, + [duckula.Type.TEXT] : duckula.State.Understanding, + [duckula.Type.INTENTS] : duckula.State.Understood, + [duckula.Type.GERROR] : duckula.State.Erroring, + }, + }, + + /** + * Understanding + * + * 1. received TEXT -> emit INTENTS + */ + [duckula.State.Understanding]: { + invoke: { + id: TextToIntents.id + '<' + duckula.id + '>', + src: Mailbox.wrap( + TextToIntents.machine.withContext(TextToIntents.initialContext()), + ), + onDone: { actions: actions.send((_, e) => duckula.Event.GERROR(GError.stringify(e.data))) }, + onError: { actions: actions.send((_, e) => duckula.Event.GERROR(GError.stringify(e.data))) }, + }, + entry: [ + actions.log((_, e) => `state.Understanding.entry TEXT: ${e.payload.text}`, duckula.id), + actions.send((_, e) => e, { to: TextToIntents.id + '<' + duckula.id + '>' }), + ], + on: { + [duckula.Type.INTENTS] : duckula.State.Understood, + [duckula.Type.GERROR] : duckula.State.Erroring, + }, + }, + + [duckula.State.Understood]: { + entry: actions.send((ctx, e) => ({ + ...e, + payload: { + ...e.payload, + message: ctx.message, + }, + })), + on: { + [duckula.Type.INTENTS]: duckula.State.Responding, + }, + }, + + ...responseStates(duckula.id), + }, +}) + +export default machine diff --git a/src/application-actors/message-to-intents/mod.spec.ts b/src/application-actors/message-to-intents/mod.spec.ts new file mode 100755 index 0000000..9ae5ead --- /dev/null +++ b/src/application-actors/message-to-intents/mod.spec.ts @@ -0,0 +1,35 @@ +#!/usr/bin/env -S node --no-warnings --loader ts-node/esm +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import { test } from 'tstest' + +import type { Duckula } from 'mailbox' + +import * as mod from './mod.js' + +test('mod is a Duckula', async t => { + const duckula: Duckula = mod + t.ok(duckula, 'should satisfy Duckula Interface for mod export') +}) + +test('mod.Context', async t => { + const context: mod.Context = {} as any + t.ok(context, 'should has Context interface') +}) diff --git a/src/application-actors/message-to-intents/mod.ts b/src/application-actors/message-to-intents/mod.ts new file mode 100644 index 0000000..698e041 --- /dev/null +++ b/src/application-actors/message-to-intents/mod.ts @@ -0,0 +1,34 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import duckula, { type Context } from './duckula.js' +import machine from './machine.js' + +export const { + id, + Event, + State, + Type, + initialContext, +} = duckula + +export { + machine, + type Context, +} diff --git a/src/application-actors/message-to-mentions/duckula.ts b/src/application-actors/message-to-mentions/duckula.ts new file mode 100644 index 0000000..445cc0d --- /dev/null +++ b/src/application-actors/message-to-mentions/duckula.ts @@ -0,0 +1,75 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +/* eslint-disable sort-keys */ +import * as Mailbox from 'mailbox' +import * as CQRS from 'wechaty-cqrs' +import type * as PUPPET from 'wechaty-puppet' + +import * as ACTOR from '../../wechaty-actor/mod.js' +import * as duck from '../../duck/mod.js' + +export interface Context { + message?: PUPPET.payloads.Message + actors: { + wechaty: string, + } +} + +const duckula = Mailbox.duckularize({ + id: 'MessageToMentions', + events: [ { ...duck.Event, ...CQRS.duck.actions, ...ACTOR.Event }, [ + /** + * Request + */ + 'MESSAGE', + /** + * Response + */ + 'MENTIONS', + 'NO_MENTION', + 'GERROR', + /** + * Internal + */ + 'CONTACTS', + 'LOAD', + 'GET_CONTACT_PAYLOAD_QUERY_RESPONSE', + 'BATCH_RESPONSE', + ] ], + states: [ duck.State, [ + 'Erroring', + 'Errored', + 'Idle', + 'Initializing', + 'Loading', + 'Loaded', + 'Messaging', + 'Responding', + 'Responded', + ] ], + initialContext: {} as Context, +}) + +export type Event = ReturnType +export type Events = { + [key in keyof typeof duckula.Event]: ReturnType +} + +export default duckula diff --git a/src/application-actors/message-to-mentions/machine.spec.ts b/src/application-actors/message-to-mentions/machine.spec.ts new file mode 100755 index 0000000..1c76324 --- /dev/null +++ b/src/application-actors/message-to-mentions/machine.spec.ts @@ -0,0 +1,138 @@ +#!/usr/bin/env -S node --no-warnings --loader ts-node/esm +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +/* eslint-disable sort-keys */ +import { + AnyEventObject, + createMachine, + interpret, +} from 'xstate' +import { test } from 'tstest' +import * as Mailbox from 'mailbox' +import { filter, map, mergeMap } from 'rxjs/operators' +import { isActionOf } from 'typesafe-actions' +import * as CQRS from 'wechaty-cqrs' + +import * as WechatyActor from '../../wechaty-actor/mod.js' +import { bot5Fixtures } from '../../fixtures/bot5-fixture.js' + +import machine from './machine.js' +import duckula from './duckula.js' + +test('MessageToMentions actor smoke testing', async t => { + for await (const fixtures of bot5Fixtures()) { + const bus$ = CQRS.from(fixtures.wechaty.wechaty) + const wechatyActor = WechatyActor.from(bus$, fixtures.wechaty.wechaty.puppet.id) + + const mailbox = Mailbox.from(machine.withContext({ + actors: { + wechaty: String(wechatyActor.address), + }, + })) + mailbox.open() + + const consumerMachine = createMachine({ + on: { + '*': { + actions: [ + Mailbox.actions.proxy('TestMachine')(mailbox), + ], + }, + }, + }) + + const eventList: AnyEventObject[] = [] + const interpreter = interpret(consumerMachine) + .onEvent(e => eventList.push(e)) + .start() + + bus$.pipe( + // tap(e => console.info('### bus$', e)), + filter(CQRS.is(CQRS.events.MessageReceivedEvent)), + map(e => CQRS.queries.GetMessagePayloadQuery(fixtures.wechaty.wechaty.puppet.id, e.payload.messageId)), + mergeMap(CQRS.execute$(bus$)), + map(response => response.payload.message), + filter(Boolean), + map(messagePayload => duckula.Event.MESSAGE(messagePayload)), + ).subscribe(e => { + // console.info('### duckula.Event.MESSAGE', e) + interpreter.send(e) + }) + + const FIXTURES = [ + [ undefined, duckula.Event.NO_MENTION() ], + [ [], duckula.Event.NO_MENTION() ], + + [ + [ + fixtures.mocker.mary, + fixtures.mocker.mike, + ], + duckula.Event.MENTIONS([ + fixtures.wechaty.mary.payload!, + fixtures.wechaty.mike.payload!, + ]), + ], + ] as const + + for (const [ mentionList, expected ] of FIXTURES) { + + eventList.length = 0 + + const future = new Promise(resolve => + interpreter.onEvent(e => + isActionOf([ + duckula.Event.MENTIONS, + duckula.Event.NO_MENTION, + ], e) && resolve(e), + ), + ) + + fixtures.mocker.player.say('test', mentionList as any).to(fixtures.mocker.groupRoom) + await future + + // eventList.forEach(e => console.info(e)) + t.same( + eventList + .filter(isActionOf([ + duckula.Event.MENTIONS, + duckula.Event.NO_MENTION, + ])) + , + [ + { + ...expected, + payload: { + ...expected.payload, + message: eventList + .filter(isActionOf(duckula.Event.MESSAGE)) + .at(-1)! + .payload + .message, + }, + }, + ], + `should get expected event for "${mentionList}"`, + ) + } + + interpreter.stop() + } +}) diff --git a/src/application-actors/message-to-mentions/machine.ts b/src/application-actors/message-to-mentions/machine.ts new file mode 100644 index 0000000..4071feb --- /dev/null +++ b/src/application-actors/message-to-mentions/machine.ts @@ -0,0 +1,139 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +/* eslint-disable sort-keys */ +import { createMachine, actions } from 'xstate' +import * as Mailbox from 'mailbox' +import * as CQRS from 'wechaty-cqrs' + +import * as WechatyActor from '../../wechaty-actor/mod.js' +import { isDefined } from '../../pure-functions/is-defined.js' +import { responseStates } from '../../actor-utils/mod.js' + +import duckula, { Context, Event, Events } from './duckula.js' +import * as selectors from './selectors.js' + +const machine = createMachine({ + id: duckula.id, + context: duckula.initialContext, + + initial: duckula.State.Initializing, + states: { + [duckula.State.Initializing]: { + entry: [ + actions.log(ctx => `states.Initializing.entry context ${JSON.stringify(ctx)}`, duckula.id), + ], + always: duckula.State.Idle, + }, + + /** + * + * Idle + * + * 1. receive MESSAGE -> transition to Loading + * + */ + [duckula.State.Idle]: { + entry: [ + Mailbox.actions.idle(duckula.id), + actions.assign({ message: undefined }), + ], + on: { + [duckula.Type.MESSAGE]: { + target: duckula.State.Loading, + actions: actions.assign({ message: (_, e) => e.payload.message }), + }, + }, + }, + + /** + * Loading: + * + * 1. received MESSAGE -> emit BATCH(GET_CONTACT_PAYLOAD_QUERY) / GERROR / CONTACTS + * 2. received BATCH_RESPONSE(GET_CONTACT_PAYLOAD_QUERY_RESPONSE) -> emit CONTACTS + * + * 3. received CONTACTS -> transition to Responding + * 4. received GERROR -> transition to Errored + */ + + [duckula.State.Loading]: { + entry: [ + actions.choose([ + { + cond: (_, e) => selectors.mentionIdList(e.payload.message).length > 0, + actions: actions.send( + (_, e) => WechatyActor.Event.BATCH( + selectors.mentionIdList(e.payload.message) + .map(contactId => CQRS.duck.actions.GET_CONTACT_PAYLOAD_QUERY( + CQRS.uuid.NIL, + contactId, + )), + ), + { to: ctx => ctx.actors.wechaty }, + ), + }, + { + actions: actions.send(duckula.Event.CONTACTS([])), + }, + ]), + ], + on: { + [WechatyActor.Type.BATCH_RESPONSE] : { + actions: [ + actions.send((_, e) => duckula.Event.CONTACTS( + e.payload.responseList + .map(response => + (response as ReturnType) + .payload + .contact, + ) + .filter(isDefined), + )), + ], + }, + [WechatyActor.Type.GERROR] : { + actions: actions.send((_, e) => duckula.Event.GERROR(e.payload.gerror)), + }, + [duckula.Type.GERROR] : duckula.State.Erroring, + [duckula.Type.CONTACTS] : duckula.State.Loaded, + }, + }, + + [duckula.State.Loaded]: { + entry: [ + actions.send( + (ctx, e) => e.payload.contacts[0] + ? duckula.Event.MENTIONS( + [ e.payload.contacts[0], ...e.payload.contacts.slice(1) ], + ctx.message, + ) + : duckula.Event.NO_MENTION(ctx.message), + ), + ], + on: { + [duckula.Type.MENTIONS] : duckula.State.Responding, + [duckula.Type.NO_MENTION] : duckula.State.Responding, + }, + }, + + ...responseStates(duckula.id), + }, +}) + +export default machine diff --git a/src/application-actors/message-to-mentions/mod.spec.ts b/src/application-actors/message-to-mentions/mod.spec.ts new file mode 100755 index 0000000..9ae5ead --- /dev/null +++ b/src/application-actors/message-to-mentions/mod.spec.ts @@ -0,0 +1,35 @@ +#!/usr/bin/env -S node --no-warnings --loader ts-node/esm +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import { test } from 'tstest' + +import type { Duckula } from 'mailbox' + +import * as mod from './mod.js' + +test('mod is a Duckula', async t => { + const duckula: Duckula = mod + t.ok(duckula, 'should satisfy Duckula Interface for mod export') +}) + +test('mod.Context', async t => { + const context: mod.Context = {} as any + t.ok(context, 'should has Context interface') +}) diff --git a/src/application-actors/message-to-mentions/mod.ts b/src/application-actors/message-to-mentions/mod.ts new file mode 100644 index 0000000..698e041 --- /dev/null +++ b/src/application-actors/message-to-mentions/mod.ts @@ -0,0 +1,34 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import duckula, { type Context } from './duckula.js' +import machine from './machine.js' + +export const { + id, + Event, + State, + Type, + initialContext, +} = duckula + +export { + machine, + type Context, +} diff --git a/src/application-actors/message-to-mentions/selectors.ts b/src/application-actors/message-to-mentions/selectors.ts new file mode 100644 index 0000000..e34524c --- /dev/null +++ b/src/application-actors/message-to-mentions/selectors.ts @@ -0,0 +1,3 @@ +import type * as PUPPET from 'wechaty-puppet' + +export const mentionIdList = (message: PUPPET.payloads.Message) => 'mentionIdList' in message ? (message.mentionIdList ?? []) : [] diff --git a/src/application-actors/message-to-room/duckula.ts b/src/application-actors/message-to-room/duckula.ts new file mode 100644 index 0000000..3414850 --- /dev/null +++ b/src/application-actors/message-to-room/duckula.ts @@ -0,0 +1,69 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +/* eslint-disable sort-keys */ +import * as Mailbox from 'mailbox' +import * as CQRS from 'wechaty-cqrs' +import type * as PUPPET from 'wechaty-puppet' + +import * as ACTOR from '../../wechaty-actor/mod.js' +import * as duck from '../../duck/mod.js' + +export interface Context { + message?: PUPPET.payloads.Message + actors: { + wechaty: string, + } +} + +const duckula = Mailbox.duckularize({ + id: 'MessageToRoom', + events: [ duck.Event, [ + /** + * Request + */ + 'MESSAGE', + /** + * Response + */ + 'ROOM', + 'NO_ROOM', + 'GERROR', + /** + * Internal + */ + 'LOAD', + ] ], + states: [ duck.State, [ + 'Idle', + 'Initializing', + 'Loading', + 'Loaded', + 'Erroring', + 'Responding', + ] ], + initialContext: {} as Context, +}) + +export type Event = ReturnType +export type Events = { + [key in keyof typeof duckula.Event]: ReturnType +} + +export default duckula diff --git a/src/application-actors/message-to-room/machine.spec.ts b/src/application-actors/message-to-room/machine.spec.ts new file mode 100755 index 0000000..5c64c44 --- /dev/null +++ b/src/application-actors/message-to-room/machine.spec.ts @@ -0,0 +1,122 @@ +#!/usr/bin/env -S node --no-warnings --loader ts-node/esm +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +/* eslint-disable sort-keys */ +import { + AnyEventObject, + createMachine, + interpret, +} from 'xstate' +import { test } from 'tstest' +import * as Mailbox from 'mailbox' +import { filter, map, mergeMap } from 'rxjs/operators' +import { isActionOf } from 'typesafe-actions' +import * as CQRS from 'wechaty-cqrs' + +import * as WechatyActor from '../../wechaty-actor/mod.js' +import { bot5Fixtures } from '../../fixtures/bot5-fixture.js' + +import machine from './machine.js' +import duckula from './duckula.js' + +test('MessageToRoom actor smoke testing', async t => { + for await (const fixtures of bot5Fixtures()) { + const bus$ = CQRS.from(fixtures.wechaty.wechaty) + const wechatyActor = WechatyActor.from(bus$, fixtures.wechaty.wechaty.puppet.id) + + const mailbox = Mailbox.from(machine.withContext({ + actors: { + wechaty: String(wechatyActor.address), + }, + })) + mailbox.open() + + const consumerMachine = createMachine({ + on: { + '*': { + actions: [ + Mailbox.actions.proxy('TestMachine')(mailbox), + ], + }, + }, + }) + + const eventList: AnyEventObject[] = [] + const interpreter = interpret(consumerMachine) + .onEvent(e => eventList.push(e)) + .start() + + bus$.pipe( + // tap(e => console.info('### bus$', e)), + filter(CQRS.is(CQRS.events.MessageReceivedEvent)), + map(e => CQRS.queries.GetMessagePayloadQuery(fixtures.wechaty.wechaty.puppet.id, e.payload.messageId)), + mergeMap(CQRS.execute$(bus$)), + map(response => response.payload.message), + filter(Boolean), + map(messagePayload => duckula.Event.MESSAGE(messagePayload)), + ).subscribe(e => { + // console.info('### duckula.Event.MESSAGE', e) + interpreter.send(e) + }) + + const FIXTURES = [ + [ fixtures.mocker.mike, duckula.Event.NO_ROOM() ], + [ fixtures.mocker.groupRoom, duckula.Event.ROOM(fixtures.mocker.groupRoom.payload) ], + ] as const + + for (const [ target, event ] of FIXTURES) { + eventList.length = 0 + + const future = new Promise(resolve => + interpreter.onEvent(e => + isActionOf([ + duckula.Event.ROOM, + duckula.Event.NO_ROOM, + ], e) && resolve(e), + ), + ) + + fixtures.mocker.player.say('test').to(target) + await future + + // eventList.forEach(e => console.info(e)) + t.same( + eventList + .filter(isActionOf([ duckula.Event.ROOM, duckula.Event.NO_ROOM ])), + [ + { + ...event, + payload: { + ...event.payload, + message: eventList + .filter(isActionOf(duckula.Event.MESSAGE)) + .at(-1)! + .payload + .message, + }, + }, + ], + `should get expected [${event.type}] event for ${target}`, + ) + } + + interpreter.stop() + } +}) diff --git a/src/application-actors/message-to-room/machine.ts b/src/application-actors/message-to-room/machine.ts new file mode 100644 index 0000000..7fb5173 --- /dev/null +++ b/src/application-actors/message-to-room/machine.ts @@ -0,0 +1,133 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +/* eslint-disable sort-keys */ +import { createMachine, actions } from 'xstate' +import * as Mailbox from 'mailbox' +import * as CQRS from 'wechaty-cqrs' + +import * as WechatyActor from '../../wechaty-actor/mod.js' +import { responseStates } from '../../actor-utils/mod.js' + +import duckula, { Context, Event, Events } from './duckula.js' + +const machine = createMachine< + Context, + Event | WechatyActor.Events[keyof WechatyActor.Events] | ReturnType +>({ + id: duckula.id, + context: duckula.initialContext, + + initial: duckula.State.Initializing, + states: { + [duckula.State.Initializing]: { + entry: [ + actions.log(ctx => `states.Initializing.entry context ${JSON.stringify(ctx)}`, duckula.id), + ], + always: duckula.State.Idle, + }, + + /** + * + * Idle + * + * 1. receive MESSAGE -> transition to Loading + * + */ + [duckula.State.Idle]: { + entry: [ + Mailbox.actions.idle(duckula.id), + actions.assign({ message: undefined }), + ], + on: { + [duckula.Type.MESSAGE]: { + target: duckula.State.Loading, + actions: actions.assign({ message: (_, e) => e.payload.message }), + }, + }, + }, + + /** + * Loading: + * + * 1. received MESSAGE -> emit BATCH(GET_CONTACT_PAYLOAD_QUERY) / GERROR / CONTACTS + * 2. received BATCH_RESPONSE(GET_CONTACT_PAYLOAD_QUERY_RESPONSE) -> emit CONTACTS + * + * 3. received CONTACTS -> transition to Responding + * 4. received GERROR -> transition to Errored + */ + + [duckula.State.Loading]: { + entry: [ + actions.choose([ + { + cond: (_, e) => !!e.payload.message.roomId, + actions: actions.send( + (_, e) => CQRS.duck.actions.GET_ROOM_PAYLOAD_QUERY( + CQRS.uuid.NIL, + e.payload.message.roomId!, + ), + { to: ctx => ctx.actors.wechaty }, + ), + }, + { + actions: actions.send(duckula.Event.NO_ROOM()), + }, + ]), + ], + on: { + [CQRS.duck.types.GET_ROOM_PAYLOAD_QUERY_RESPONSE] : { + actions: actions.send( + (_, e) => e.payload.room + ? duckula.Event.ROOM(e.payload.room) + : duckula.Event.NO_ROOM()) + , + }, + [WechatyActor.Type.GERROR] : { + actions: (_, e) => actions.send(duckula.Event.GERROR(e.payload.gerror)), + }, + [duckula.Type.ROOM] : duckula.State.Loaded, + [duckula.Type.NO_ROOM] : duckula.State.Loaded, + [duckula.Type.GERROR] : duckula.State.Erroring, + }, + }, + + [duckula.State.Loaded]: { + entry: [ + actions.send( + (ctx, e) => ({ + ...e, + payload: { + ...e.payload, + message: ctx.message, + }, + }), + ), + ], + on: { + [duckula.Type.ROOM] : duckula.State.Responding, + [duckula.Type.NO_ROOM] : duckula.State.Responding, + }, + }, + + ...responseStates(duckula.id), + }, +}) + +export default machine diff --git a/src/application-actors/message-to-room/mod.spec.ts b/src/application-actors/message-to-room/mod.spec.ts new file mode 100755 index 0000000..9ae5ead --- /dev/null +++ b/src/application-actors/message-to-room/mod.spec.ts @@ -0,0 +1,35 @@ +#!/usr/bin/env -S node --no-warnings --loader ts-node/esm +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import { test } from 'tstest' + +import type { Duckula } from 'mailbox' + +import * as mod from './mod.js' + +test('mod is a Duckula', async t => { + const duckula: Duckula = mod + t.ok(duckula, 'should satisfy Duckula Interface for mod export') +}) + +test('mod.Context', async t => { + const context: mod.Context = {} as any + t.ok(context, 'should has Context interface') +}) diff --git a/src/application-actors/message-to-room/mod.ts b/src/application-actors/message-to-room/mod.ts new file mode 100644 index 0000000..698e041 --- /dev/null +++ b/src/application-actors/message-to-room/mod.ts @@ -0,0 +1,34 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import duckula, { type Context } from './duckula.js' +import machine from './machine.js' + +export const { + id, + Event, + State, + Type, + initialContext, +} = duckula + +export { + machine, + type Context, +} diff --git a/src/application-actors/message-to-text/duckula.ts b/src/application-actors/message-to-text/duckula.ts new file mode 100644 index 0000000..e591da6 --- /dev/null +++ b/src/application-actors/message-to-text/duckula.ts @@ -0,0 +1,85 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +/* eslint-disable sort-keys */ +import * as Mailbox from 'mailbox' +import * as CQRS from 'wechaty-cqrs' +import type * as PUPPET from 'wechaty-puppet' + +import * as duck from '../../duck/mod.js' + +export interface Context { + message?: PUPPET.payloads.Message + actors: { + wechaty: string, + }, +} + +const duckula = Mailbox.duckularize({ + id: 'MessageToText', + events: [ { ...duck.Event, ...CQRS.duck.actions }, [ + /** + * Request + */ + 'MESSAGE', + /** + * Response + */ + 'TEXT', + 'NO_TEXT', + 'GERROR', + /** + * Internal + */ + 'FILE', + 'NO_FILE', + 'LOAD', + // CQRS.duck.actions + 'GET_MESSAGE_FILE_QUERY_RESPONSE', + ] ], + states: [ duck.State, [ + /** + * Request + */ + 'Idle', + /** + * Response + */ + 'Responding', + 'Erroring', + /** + * Internal + */ + 'Messaging', + 'Initializing', + 'Filing', + 'Recognizing', + 'Recognized', + 'Classifying', + 'Textualized', + ] ], + initialContext: {} as Context, +}) + +export type Event = ReturnType +export type Events = { + [key in keyof typeof duckula.Event]: ReturnType +} + +export default duckula diff --git a/src/application-actors/message-to-text/machine.spec.ts b/src/application-actors/message-to-text/machine.spec.ts new file mode 100755 index 0000000..414a98f --- /dev/null +++ b/src/application-actors/message-to-text/machine.spec.ts @@ -0,0 +1,140 @@ +#!/usr/bin/env -S node --no-warnings --loader ts-node/esm +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +/* eslint-disable sort-keys */ +import { + AnyEventObject, + createMachine, + interpret, +} from 'xstate' +import { test } from 'tstest' +import * as Mailbox from 'mailbox' +import { filter, map, mergeMap } from 'rxjs/operators' +import { isActionOf } from 'typesafe-actions' +import * as CQRS from 'wechaty-cqrs' +import { FileBox } from 'file-box' + +import * as duck from '../../duck/mod.js' +import * as WechatyActor from '../../wechaty-actor/mod.js' +import { bot5Fixtures } from '../../fixtures/bot5-fixture.js' +import { FileToText } from '../../infrastructure-actors/mod.js' + +import machine from './machine.js' +import duckula from './duckula.js' + +test('MessageToText actor smoke testing', async t => { + for await (const fixtures of bot5Fixtures()) { + const bus$ = CQRS.from(fixtures.wechaty.wechaty) + const wechatyActor = WechatyActor.from(bus$, fixtures.wechaty.wechaty.puppet.id) + + const mailbox = Mailbox.from(machine.withContext({ + actors: { + wechaty: String(wechatyActor.address), + }, + })) + mailbox.open() + + const testMachine = createMachine({ + on: { + '*': { + actions: [ + Mailbox.actions.proxy('TestMachine')(mailbox), + ], + }, + }, + }) + + const eventList: AnyEventObject[] = [] + const interpreter = interpret(testMachine) + .onEvent(e => eventList.push(e)) + .start() + + bus$.pipe( + // tap(e => console.info('### bus$', e)), + filter(CQRS.is(CQRS.events.MessageReceivedEvent)), + map(e => CQRS.queries.GetMessagePayloadQuery(fixtures.wechaty.wechaty.puppet.id, e.payload.messageId)), + mergeMap(CQRS.execute$(bus$)), + map(response => response.payload.message), + filter(Boolean), + map(messagePayload => duckula.Event.MESSAGE(messagePayload)), + ).subscribe(e => { + // console.info('### duckula.Event.MESSAGE', e) + interpreter.send(e) + }) + + const JPG_FILE_NAME = 'test.jpg' + const FILE_TO_TEXT_FIXTURES = await FileToText.FIXTURES() + const JPG_FILE_BOX_FIXTURE_BASE64 = FileBox.fromBase64('aGVsbG8=', JPG_FILE_NAME) + + const FIXTURES = [ + [ 'hello world', 'hello world' ], + ...FILE_TO_TEXT_FIXTURES, + [ JPG_FILE_BOX_FIXTURE_BASE64, undefined ], + ] as const + + for (const [ sayable, expectedText ] of FIXTURES) { + + eventList.length = 0 + + const future = new Promise(resolve => + interpreter.onEvent(e => + isActionOf([ + duckula.Event.TEXT, + duckula.Event.NO_TEXT, + duckula.Event.GERROR, + ], e) && resolve(e), + ), + ) + + console.info('############') + fixtures.mocker.player.say(sayable).to(fixtures.mocker.bot) + await future + + // eventList.forEach(e => console.info(e)) + t.same( + eventList.filter(isActionOf([ + duckula.Event.TEXT, + duckula.Event.NO_TEXT, + ])), + [ + expectedText + ? duckula.Event.TEXT( + expectedText, + eventList + .filter(isActionOf(duck.Event.MESSAGE)) + .at(-1)! + .payload + .message, + ) + : duckula.Event.NO_TEXT( + eventList + .filter(isActionOf(duck.Event.MESSAGE)) + .at(-1)! + .payload + .message, + ), + ], + `should get expected [TEXT(${fixtures.mocker.player.id}, "${expectedText}")] for "${FileBox.valid(sayable) ? sayable.name : sayable}"`, + ) + } + + interpreter.stop() + } +}) diff --git a/src/application-actors/message-to-text/machine.ts b/src/application-actors/message-to-text/machine.ts new file mode 100644 index 0000000..1252c4d --- /dev/null +++ b/src/application-actors/message-to-text/machine.ts @@ -0,0 +1,207 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +/* eslint-disable sort-keys */ +import { createMachine, actions } from 'xstate' +import * as Mailbox from 'mailbox' +import { GError } from 'gerror' +import * as PUPPET from 'wechaty-puppet' + +import { FileToText } from '../../infrastructure-actors/mod.js' +import { responseStates } from '../../actor-utils/response-states.js' + +import * as MessageToFile from '../message-to-file/mod.js' + +import duckula, { Context, Event, Events } from './duckula.js' + +const machine = createMachine< + Context, + Event +>({ + id: duckula.id, + context: duckula.initialContext, + + initial: duckula.State.Initializing, + states: { + [duckula.State.Initializing]: { + entry: [ + actions.log(ctx => `states.Initializing.entry context ${JSON.stringify(ctx)}`, duckula.id), + ], + always: duckula.State.Idle, + }, + + /** + * Idle + * + * 1. receive MESSAGE -> transition to Filing + */ + [duckula.State.Idle]: { + entry: [ + Mailbox.actions.idle(duckula.id), + actions.assign({ message: undefined }), + ], + on: { + [duckula.Type.MESSAGE]: { + target: duckula.State.Messaging, + actions: actions.assign({ message: (_, e) => e.payload.message }), + }, + }, + }, + + /** + * Messaging + * + * 1. receive MESSAGE with MessageType.Text -> emit TEXT + * 2. receive MESSAGE with MessageType.* -> emit MESSAGE + * + * 3. receive TEXT -> transition to Textualized + * 4. receive MESSAGE -> transition to Filing + */ + [duckula.State.Messaging]: { + entry: [ + actions.log( + (_, e) => `states.Messaging.entry MessageType: ${PUPPET.types.Message[e.payload.message.type]}`, + duckula.id, + ), + actions.choose([ + { + cond: (_, e) => e.payload.message.type === PUPPET.types.Message.Text, + actions: [ + actions.send( + (_, e) => e.payload.message.text + ? duckula.Event.TEXT(e.payload.message.text) + : duckula.Event.NO_TEXT() + , + ), + ], + }, + { + actions: actions.send((_, e) => e), + }, + ]), + ], + on: { + [duckula.Type.MESSAGE] : duckula.State.Filing, + [duckula.Type.NO_TEXT] : duckula.State.Textualized, + [duckula.Type.TEXT] : duckula.State.Textualized, + }, + }, + + /** + * Filing: invoke MessageToFile actor + * + * 1. received MESSAGE -> emit FILE / NO_FILE / GERROR + * + * 2. received FILE -> transition to Recognizing + * 3. received NO_FILE -> emit NO_TEXT + * 4. received GERROR -> transition to Erroring + */ + [duckula.State.Filing]: { + invoke: { + id: MessageToFile.id, + src: ctx => Mailbox.wrap( + MessageToFile.machine.withContext({ + ...MessageToFile.initialContext(), + actors: { wechaty: ctx.actors.wechaty }, + }), + ), + onDone: { actions: actions.send((_, e) => duckula.Event.GERROR(GError.stringify(e.data))) }, + onError: { actions: actions.send((_, e) => duckula.Event.GERROR(GError.stringify(e.data))) }, + }, + entry: [ + actions.log('states.Filing.entry', duckula.id), + actions.send((_, e) => e, { to: MessageToFile.id }), + ], + on: { + '*': { + actions: actions.log((_, e) => `states.Filing.on ${JSON.stringify(e)}`, duckula.id), + }, + [duckula.Type.NO_FILE]: { + actions: actions.send(duckula.Event.NO_TEXT()), + }, + [duckula.Type.FILE] : duckula.State.Recognizing, + [duckula.Type.GERROR] : duckula.State.Erroring, + [duckula.Type.NO_TEXT] : duckula.State.Textualized, + }, + }, + + /** + * Recognizing: invoke FileToText actor + * + * 1. received FILE -> emit TEXT / NO_TEXT / GERROR + */ + [duckula.State.Recognizing]: { + invoke: { + id: FileToText.id, + src: Mailbox.wrap( + FileToText.machine.withContext(FileToText.initialContext()), + ), + onDone: { actions: actions.send((_, e) => duckula.Event.GERROR(GError.stringify(e.data))) }, + onError: { actions: actions.send((_, e) => duckula.Event.GERROR(GError.stringify(e.data))) }, + }, + entry: [ + actions.log( + (_, e) => [ + 'states.Recognizing.entry fileBox: "', + JSON.parse( + (e as ReturnType) + .payload + .box, + ).name, + '"', + ].join(''), + duckula.id, + ), + actions.send((_, e) => e, { to: FileToText.id }), + ], + on: { + [duckula.Type.TEXT] : duckula.State.Textualized, + [duckula.Type.NO_TEXT] : duckula.State.Textualized, + [duckula.Type.GERROR] : { + actions: [ + actions.log((_, e) => `state.Recognizing.on GERROR ${e.payload.gerror}`, duckula.id), + actions.send(duckula.Event.NO_TEXT()), + ], + }, + }, + }, + + [duckula.State.Textualized]: { + entry: [ + actions.send( + (ctx, e) => ({ + ...e, + payload: { + ...e.payload, + message: ctx.message, + }, + }), + ), + ], + on: { + [duckula.Type.TEXT] : duckula.State.Responding, + [duckula.Type.NO_TEXT] : duckula.State.Responding, + }, + }, + + ...responseStates(duckula.id), + }, +}) + +export default machine diff --git a/src/application-actors/message-to-text/mod.spec.ts b/src/application-actors/message-to-text/mod.spec.ts new file mode 100755 index 0000000..9ae5ead --- /dev/null +++ b/src/application-actors/message-to-text/mod.spec.ts @@ -0,0 +1,35 @@ +#!/usr/bin/env -S node --no-warnings --loader ts-node/esm +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import { test } from 'tstest' + +import type { Duckula } from 'mailbox' + +import * as mod from './mod.js' + +test('mod is a Duckula', async t => { + const duckula: Duckula = mod + t.ok(duckula, 'should satisfy Duckula Interface for mod export') +}) + +test('mod.Context', async t => { + const context: mod.Context = {} as any + t.ok(context, 'should has Context interface') +}) diff --git a/src/application-actors/message-to-text/mod.ts b/src/application-actors/message-to-text/mod.ts new file mode 100644 index 0000000..698e041 --- /dev/null +++ b/src/application-actors/message-to-text/mod.ts @@ -0,0 +1,34 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import duckula, { type Context } from './duckula.js' +import machine from './machine.js' + +export const { + id, + Event, + State, + Type, + initialContext, +} = duckula + +export { + machine, + type Context, +} diff --git a/src/application-actors/mod.ts b/src/application-actors/mod.ts new file mode 100644 index 0000000..8dd689b --- /dev/null +++ b/src/application-actors/mod.ts @@ -0,0 +1,24 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +export * as MessageToFile from './message-to-file/mod.js' +export * as MessageToIntents from './message-to-intents/mod.js' +export * as MessageToMentions from './message-to-mentions/mod.js' +export * as MessageToRoom from './message-to-room/mod.js' +export * as MessageToText from './message-to-text/mod.js' diff --git a/src/bak/store.spec.ts b/src/bak/store.spec.ts deleted file mode 100755 index ae7587e..0000000 --- a/src/bak/store.spec.ts +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env -S node --no-warnings --loader ts-node/esm - -import { test } from 'tstest' - -import type { - Room, - Contact, -} from 'wechaty' - -import * as store from './store.js' - -test('store.* smoke testing', async t => { - store.init() - - const room1 = { id: '18673011105@chatroom' } as any as Room - const votee1 = { id: 'wxid_a8d806dzznm822' } as any as Contact - const votee2 = { id: 'wxid_a8d806dzznm8ab' } as any as Contact - - let payload1 = store.get(room1, votee1) - t.equal(payload1.downIdList.length, 0, 'should be 0 for downIdList.length') - t.equal(payload1.downNum, 0, 'should get downNum 0') - t.equal(payload1.upIdList.length, 0, 'should be 0 for upIdList.length') - t.equal(payload1.upNum, 0, 'should get upNum 0') - - payload1 = { - ...payload1, - downIdList: ['1'], - downNum: 1, - } - store.set(room1, votee1, payload1) - - const payload2 = store.get(room1, votee1) - t.equal(payload2.downIdList.length, 1, 'should be 1 for downIdList.length') - t.equal(payload2.downNum, 1, 'should get downNum 1') - t.equal(payload2.upIdList.length, 0, 'should be 0 for upIdList.length') - t.equal(payload2.upNum, 0, 'should get upNum 0') - - const payload3 = store.get(room1, votee2) - t.equal(payload3.downIdList.length, 0, 'should be 0 for downIdList.length') - t.equal(payload3.downNum, 0, 'should get downNum 0') - t.equal(payload3.upIdList.length, 0, 'should be 0 for upIdList.length') - t.equal(payload3.upNum, 0, 'should get upNum 0') -}) diff --git a/src/bak/store.ts b/src/bak/store.ts deleted file mode 100644 index 1d55c5d..0000000 --- a/src/bak/store.ts +++ /dev/null @@ -1,62 +0,0 @@ -/* eslint-disable brace-style */ -import { - Room, - Contact, - log, -} from 'wechaty' -import LRUCache from 'lru-cache' - -export interface VotePayload { - downIdList : string[], - downNum : number, - upIdList : string[], - upNum : number, -} - -const DEFAULT_PAYLOAD: Readonly = { - downIdList : [], - downNum : 0, - upIdList : [], - upNum : 0, -} - -let lruCache: LRUCache - -const init = () => { - const lruOptions: LRUCache.Options = { - // length: function (n) { return n * 2}, - dispose (key, val) { - log.silly('WechatyPluginContrib', 'VoteOut() lruOptions.dispose(%s, %s)', key, JSON.stringify(val)) - }, - max: 1000, - maxAge: 60 * 60 * 1000, - } - lruCache = new LRUCache(lruOptions) -} - -const buildKey = (room: Room, votee: Contact) => `${room.id}-${votee.id}-vote` - -const get = (room: Room, votee: Contact): Readonly => { - const key = buildKey(room, votee) - return lruCache.get(key) || DEFAULT_PAYLOAD -} - -const set = (room: Room, votee: Contact, payload: VotePayload) => { - const key = buildKey(room, votee) - lruCache.set(key, payload) -} - -const del = (room: Room, votee: Contact) => { - const key = buildKey(room, votee) - lruCache.del(key) -} - -const prune = () => lruCache.prune() - -export { - init, - get, - set, - del, - prune, -} diff --git a/src/config.ts b/src/config.ts index 75e55f1..90f12c9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,3 +1,22 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ import type { matchers, } from 'wechaty-plugin-contrib' diff --git a/src/domain-actors/assistant-actor.ts b/src/domain-actors/assistant-actor.ts new file mode 100644 index 0000000..40ab554 --- /dev/null +++ b/src/domain-actors/assistant-actor.ts @@ -0,0 +1,135 @@ +/* eslint-disable no-redeclare */ +/* eslint-disable sort-keys */ +/** + * Finite State Machine for BOT Friday Club Meeting + * @link https://github.com/wechaty/bot5-assistant + */ +import { createMachine, actions } from 'xstate' + +import * as duck from '../duck/mod.js' +import * as Mailbox from 'mailbox' +import { InjectionToken } from '../ioc/tokens.js' + +import * as Actors from './mod.js' + +interface Context { +} + +function initialContext (): Context { + const context: Context = { + } + return JSON.parse(JSON.stringify(context)) +} + +const Type = { + MESSAGE: duck.Type.MESSAGE, + REPORT: duck.Type.REPORT, + MINUTE: duck.Type.MINUTE, +} as const + +type Type = typeof Type[keyof typeof Type] + +const Event = { + MESSAGE : duck.Event.MESSAGE, + REPORT : duck.Event.REPORT, + MINUTE : duck.Event.MINUTE, +} + +type Event = { + [key in keyof typeof Event]: ReturnType +} + +const State = { + initializing: duck.State.initializing, + idle: duck.State.Idle, + reporting: duck.State.reporting, + processing: duck.State.processing, + meeting: duck.State.mentioning, + finished: duck.State.finished, +} as const + +type State = typeof State[keyof typeof State] + +const MACHINE_NAME = 'AssistantMachine' + +const machineFactory = ( + meetingAddress : Mailbox.Address, + noticeAddress : Mailbox.Address, +) => createMachine< + Context, + Event[keyof Event] +>({ + id: MACHINE_NAME, + context: () => initialContext(), + initial: State.initializing, + states: { + [State.initializing]: { + always: State.idle, + }, + [State.idle]: { + on: { + [Type.MESSAGE]: State.processing, + [Type.REPORT]: State.reporting, + }, + }, + [State.reporting]: { + entry: [ + + ], + }, + [State.processing]: { + on: { + }, + }, + [State.meeting]: { + entry: [ + meetingAddress.send(Event.REPORT()), + ], + on: { + [Type.MESSAGE]: { + actions: [ + actions.forwardTo(String(meetingAddress)), + ], + }, + [Type.MINUTE]: State.finished, + }, + }, + [State.finished]: { + entry: [ + actions.log('State.finished.entry', MACHINE_NAME), + noticeAddress.send(ctx => + Actors.notice.Events.NOTICE( + `【Friday系统】会议简报已生成:\nTODO: ${ctx}`, + ), + ), + ], + always: State.idle, + }, + }, +}) + +mailboxFactory.inject = [ + InjectionToken.Logger, + InjectionToken.MeetingMailbox, +] as const + +function mailboxFactory ( + logger: Mailbox.Options['logger'], + meetingMailbox: Mailbox.Interface, + noticeMailbox: Mailbox.Interface, +) { + const machine = machineFactory( + meetingMailbox.address, + noticeMailbox.address, + ) + + const mailbox = Mailbox.from(machine, { logger }) + return mailbox +} + +export { + type Context, + machineFactory, + mailboxFactory, + Event as Events, +} diff --git a/src/domain-actors/brainstorming/duckula.ts b/src/domain-actors/brainstorming/duckula.ts new file mode 100644 index 0000000..ae80b2f --- /dev/null +++ b/src/domain-actors/brainstorming/duckula.ts @@ -0,0 +1,109 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +/* eslint-disable sort-keys */ +import type * as PUPPET from 'wechaty-puppet' +import * as Mailbox from 'mailbox' + +import * as duck from '../../duck/mod.js' + +export interface Context { + /** + * Required + */ + actors: { + wechaty : string + notice : string + } + room : PUPPET.payloads.Room + chairs : { [id: string]: PUPPET.payloads.Contact } + contacts : { [id: string]: PUPPET.payloads.Contact } + /** + * To-be-filled + */ + feedbacks: { [id: string]: string } +} + +const duckula = Mailbox.duckularize({ + id: 'Brainstorming', + events: [ { ...duck.Event }, [ + /** + * Requests + */ + 'REPORT', + 'MESSAGE', + /** + * Responses + */ + 'FEEDBACKS', + 'GERROR', + /** + * Internal + */ + 'CONTACTS', + 'IDLE', + 'NEXT', + 'HELP', + 'RESET', + 'REGISTER', + // Notice Actor + 'NOTICE', + ] ], + states: [ duck.State, [ + /** + * Request + */ + 'Idle', + /** + * Response + */ + 'Responding', + 'Responded', + 'Erroring', + 'Errored', + /** + * Internal + */ + 'Initializing', + 'Resetting', + 'Reporting', + 'Completing', + 'Completed', + /** + * Register Actor + */ + 'Registering', + 'Registered', + /** + * Feedback Actor + */ + 'Feedbacking', + 'Feedbacked', + ] ], + initialContext: ({ + feedbacks: {}, + }), +}) + +export type Event = ReturnType +export type Events = { + [key in keyof typeof duckula.Event]: ReturnType +} + +export default duckula diff --git a/src/domain-actors/brainstorming/machine.spec.ts b/src/domain-actors/brainstorming/machine.spec.ts new file mode 100755 index 0000000..9e66d8b --- /dev/null +++ b/src/domain-actors/brainstorming/machine.spec.ts @@ -0,0 +1,264 @@ +#!/usr/bin/env -S node --no-warnings --loader ts-node/esm +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +/* eslint-disable sort-keys */ + +import { + AnyEventObject, + interpret, + createMachine, + EventObject, + StateValue, +} from 'xstate' +import { map } from 'rxjs/operators' +import { test, sinon } from 'tstest' +import * as CQRS from 'wechaty-cqrs' +import { inspect } from '@xstate/inspect/lib/server.js' +import { WebSocketServer } from 'ws' +import * as Mailbox from 'mailbox' + +import { FileToText } from '../../infrastructure-actors/mod.js' +import * as WechatyActor from '../../wechaty-actor/mod.js' +import { skipSelfMessagePayload$ } from '../../wechaty-actor/cqrs/skip-self-message-payload$.js' +import { bot5Fixtures } from '../../fixtures/bot5-fixture.js' + +import * as Notice from '../notice/mod.js' + +import duckula, { Context } from './duckula.js' +import machine from './machine.js' + +test('Brainstorming actor smoke testing', async t => { + for await ( + const { + mocker: mockerFixture, + wechaty: wechatyFixture, + } of bot5Fixtures() + ) { + const sandbox = sinon.createSandbox({ + useFakeTimers: { now: Date.now() }, + }) + + const bus$ = CQRS.from(wechatyFixture.wechaty) + const wechatyActor = WechatyActor.from(bus$, wechatyFixture.wechaty.puppet.id) + + const noticeMailbox = Mailbox.from(Notice.machine.withContext({ + ...Notice.initialContext(), + conversation: wechatyFixture.groupRoom.id, + actors: { + wechaty: String(wechatyActor.address), + }, + })) + noticeMailbox.open() + + const server = new WebSocketServer({ + port: 8888, + }) + + inspect({ server }) + + const [ [ _FILE, TEXT ] ] = await FileToText.FIXTURES() + + const FEEDBACKS = { + [mockerFixture.mary.id]: [ 'im mary', 'im mary' ], + [mockerFixture.mike.id]: [ 'im mike', 'im mike' ], + [mockerFixture.player.id]: [ TEXT, TEXT ], + } as const + + const CONTACTS = { + [mockerFixture.mary.id]: mockerFixture.mary.payload, + [mockerFixture.mike.id]: mockerFixture.mike.payload, + [mockerFixture.player.id]: mockerFixture.player.payload, + } as const + + const mailbox = Mailbox.from(machine.withContext({ + ...duckula.initialContext(), + room: mockerFixture.groupRoom.payload, + contacts: CONTACTS, + chairs: {}, + actors: { + wechaty : String(wechatyActor.address), + notice : String(noticeMailbox.address), + }, + })) + mailbox.open() + + const actorInterpreter = (mailbox as Mailbox.impls.Mailbox).internal.actor.interpreter! + const actorSnapshot = () => actorInterpreter.getSnapshot() + const actorContext = () => actorSnapshot().context as Context + const actorState = () => actorSnapshot().value + + const actorEventList: EventObject[] = [] + const actorStateList: StateValue[] = [] + actorInterpreter.subscribe(s => { + actorEventList.push(s.event) + actorStateList.push(s.value) + + console.info(`>>> ${s.machine?.id}:`, [ + `(${s.history?.value || ''})`.padEnd(30, ' '), + ' + ', + `[${s.event.type}]`.padEnd(30, ' '), + ' = ', + `(${s.value})`.padEnd(30, ' '), + ].join('')) + }) + + const TEST_ID = 'TestMachine' + const testMachine = createMachine({ + id: TEST_ID, + on: { + '*': { + actions: Mailbox.actions.proxy(TEST_ID)(mailbox), + }, + }, + }) + + const testEventList: AnyEventObject[] = [] + const testInterpreter = interpret(testMachine) + .onEvent(e => { + testEventList.push(e) + console.info('<<<', testMachine.id, ':', `[${e.type}]`) + }) + .start() + + const messageEventList: ReturnType[] = [] + + skipSelfMessagePayload$(bus$)(wechatyFixture.wechaty.puppet.id).pipe( + map(messagePayload => duckula.Event.MESSAGE(messagePayload)), + ).subscribe(e => { + console.info('### duckula.Event.MESSAGE', e) + messageEventList.push(e) + testInterpreter.send(e) + }) + + /** + * XState Issue #2931 - https://github.com/statelyai/xstate/issues/2931 + * "An unexpected error has occurred" with statecharts.io/inspect #2931 + */ + // await new Promise(resolve => setTimeout(resolve, 10000)) + + /** + * REPORT + */ + t.equal(actorState(), duckula.State.Idle, 'should in State.Feedbacking before received REPORT') + testInterpreter.send(duckula.Event.REPORT()) + await sandbox.clock.runAllAsync() + t.equal(actorState(), duckula.State.Feedbacking, 'should transition to State.Feedbacking after received REPORT') + + /** + * FEEDBACK: 1 + */ + // console.info(targetSnapshot().context) + // console.info(targetSnapshot().value) + testEventList.length = 0 + actorEventList.length = 0 + actorStateList.length = 0 + mockerFixture.mary + .say(FEEDBACKS[mockerFixture.mary.id]![0]) + .to(mockerFixture.groupRoom) + await sandbox.clock.runAllAsync() + t.same( + testEventList, + [ + messageEventList.at(-1), + ], + 'should get MESSAGE events after first feedback', + ) + t.same( + actorContext().feedbacks, + {}, + 'should no feedbacks because it will updated only all members have replied', + ) + t.same(actorStateList, [ + duckula.State.Feedbacking, + ], 'should in state.Feedbacking') + + /** + * FEEDBACK: +2 (total 3) + */ + actorEventList.length = 0 + actorStateList.length = 0 + mockerFixture.mike + .say(FEEDBACKS[mockerFixture.mike.id]![0]) + .to(mockerFixture.groupRoom) + // await sandbox.clock.runAllAsync() + mockerFixture.player + .say(FEEDBACKS[mockerFixture.player.id]![0]) + .to(mockerFixture.groupRoom) + await sandbox.clock.runAllAsync() + t.same( + actorContext().feedbacks, + { + [mockerFixture.mary.id]: FEEDBACKS[mockerFixture.mary.id]![1], + [mockerFixture.mike.id]: FEEDBACKS[mockerFixture.mike.id]![1], + [mockerFixture.player.id]: FEEDBACKS[mockerFixture.player.id]![1], + }, + 'should set feedbacks because all members have replied', + ) + t.same(actorStateList, [ + duckula.State.Feedbacking, + duckula.State.Feedbacking, + duckula.State.Feedbacked, + duckula.State.Feedbacked, + duckula.State.Responding, + duckula.State.Idle, + ], 'should transition through Feedbacking,Feedbacked, Responding, Idle states') + t.same(actorEventList.map(e => e.type), [ + duckula.Type.MESSAGE, + duckula.Type.MESSAGE, + duckula.Type.FEEDBACKS, + duckula.Type.NOTICE, + duckula.Type.FEEDBACKS, + duckula.Type.NEXT, + ], 'should have MESSAGE, FEEDBACKS, NEXT, NOTICE events') + // testEventList + // .filter(e => e.type === duckula.Type.FEEDBACKS) + // .forEach(e => console.info(e)) + t.same( + testEventList + .filter(e => e.type === duckula.Type.FEEDBACKS), + [ + duckula.Event.FEEDBACKS({ + [mockerFixture.mary.id]: FEEDBACKS[mockerFixture.mary.id]![1], + [mockerFixture.mike.id]: FEEDBACKS[mockerFixture.mike.id]![1], + [mockerFixture.player.id]: FEEDBACKS[mockerFixture.player.id]![1], + }), + ], + 'should have FEEDBACKS event with feedbacks', + ) + + // // console.info('Room message log:') + // // for (const msg of messageList) { + // // const mentionText = (await msg.mentionList()) + // // .map(c => '@' + c.name()).join(' ') + + // // console.info( + // // '-------\n', + // // msg.talker().name(), + // // ':', + // // mentionText, + // // msg.text(), + // // ) + // // } + + await sandbox.clock.runAllAsync() + sandbox.restore() + server.close() + } +}) diff --git a/src/domain-actors/brainstorming/machine.ts b/src/domain-actors/brainstorming/machine.ts new file mode 100644 index 0000000..dd9a4e3 --- /dev/null +++ b/src/domain-actors/brainstorming/machine.ts @@ -0,0 +1,200 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +/* eslint-disable sort-keys */ +import { actions, createMachine } from 'xstate' +import * as Mailbox from 'mailbox' + +import { responseStates } from '../../actor-utils/mod.js' + +import * as Notice from '../notice/mod.js' +import * as Feedback from '../feedback/mod.js' + +import duckula, { Context, Event } from './duckula.js' +import * as selectors from './selectors.js' + +const machine = createMachine({ + id: duckula.id, + + /** + * Internal Events (not exposed to outside) + * or the Mailbox queue will be blocked forever + * because of breaking of the `Mailbox.actions.idle()` protocol. + */ + on: { + [duckula.Type.NOTICE]: { + actions: [ + actions.log((_, e) => `on.NOTICE ${e.payload.text}`, duckula.id), + actions.send( + (_, e) => duckula.Event.NOTICE('【头脑风暴】' + e.payload.text, e.payload.mentions), + { to: ctx => ctx.actors.notice }, + ), + ], + }, + [duckula.Type.HELP]: { + actions: [ + actions.log('on.HELP', duckula.id), + actions.send(ctx => Notice.Event.NOTICE( + [ + '头脑风暴环节:每位参会者按照报名确认顺序,在 BOT Friday Club 微信群中,通过“按住说话”功能,把自己在活动中得到的新点子与大家分享。', + `当前主席:${Object.values(ctx.chairs).map(c => c.name).join(',')}`, + `当前参会者:${Object.values(ctx.contacts).map(c => c.name).join(',')}`, + `已经完成头脑风暴的参会者:${Object.keys(ctx.feedbacks).map(c => ctx.contacts[c]?.name).join(',')}`, + `还没有完成头脑风暴的参会者:${Object.values(ctx.contacts).filter(c => !ctx.feedbacks[c.id]).map(c => c.name).join(',')}`, + ].join('\n'), + Object.keys(ctx.contacts), + )), + ], + }, + }, + + initial: duckula.State.Initializing, + states: { + [duckula.State.Initializing]: { + entry: [ + actions.log(ctx => `states.Initializing.entry context ${JSON.stringify(ctx)}`, duckula.id), + ], + always: duckula.State.Idle, + }, + [duckula.State.Resetting]: { + entry: [ + actions.log('states.Resetting.entry', duckula.id), + actions.assign(duckula.initialContext()), + // actions.send(Feedback.Event.RESET(), { to: Feedback.id }), + ], + always: duckula.State.Initializing, + }, + + /** + * Idle + * + * 1. received REPORT -> transition to Reporting + */ + [duckula.State.Idle]: { + entry: [ + actions.log('states.Idle.entry', duckula.id), + Mailbox.actions.idle(duckula.id), + ], + on: { + '*' : duckula.State.Idle, // enforce external transition for Mailbox actor protocol + [duckula.Type.REPORT] : duckula.State.Reporting, + [duckula.Type.RESET] : duckula.State.Resetting, + }, + }, + + /** + * Reporting + * + * 1. no contacts -> emit REGISTER + * 2. feedbacks >= contacts ? emit FEEDBACKS : NEXT + * + * 3. received FEEDBACKS -> transition to Responding + * 4. received NEXT -> transition to Feedbacking + * 5. received REGISTER -> transition to Registering + */ + [duckula.State.Reporting]: { + entry: [ + actions.log(ctx => `states.Reporting.entry feedbacks/contacts #${selectors.feedbacksNum(ctx)}/${selectors.contactsNum(ctx)}`, duckula.id), + actions.choose([ + { + cond: ctx => selectors.feedbacksNum(ctx) >= selectors.contactsNum(ctx), + actions: actions.send(ctx => duckula.Event.FEEDBACKS(ctx.feedbacks)), + }, + { actions: actions.send(duckula.Event.NEXT()) }, + ]), + ], + on: { + [duckula.Type.FEEDBACKS] : duckula.State.Responding, + [duckula.Type.NEXT] : duckula.State.Feedbacking, + }, + }, + + /** + * + * Ask Feedbacks from Feedback Actor + * + */ + [duckula.State.Feedbacking]: { + entry: [ + actions.log('states.Feedbacking.entry', duckula.id), + ], + invoke: { + id: Feedback.id, + src: ctx => Mailbox.wrap( + Feedback.machine.withContext({ + ...Feedback.initialContext(), + contacts: { + ...ctx.chairs, + ...ctx.contacts, + }, + actors: { + notice: ctx.actors.notice, + wechaty: ctx.actors.wechaty, + }, + }), + ), + }, + on: { + /** + * 1. Forward [MESSAGE] to FeedbackActor + */ + [Feedback.Type.MESSAGE]: { + actions: actions.send((_, e) => e, { to: Feedback.id }), + }, + /** + * 2. Expect [FEEDBACKS] from FeedbackActor + */ + [Feedback.Type.FEEDBACKS]: { + actions: [ + actions.assign({ + feedbacks: (_, e) => e.payload.feedbacks, + }), + ], + target: duckula.State.Feedbacked, + }, + [Feedback.Type.GERROR]: duckula.State.Erroring, + }, + }, + + [duckula.State.Feedbacked]: { + entry: [ + actions.send(ctx => Notice.Event.NOTICE( + [ + '头脑风暴环节完成,感谢大家的参与!每个参会成员的反馈,都将被收集并分享。', + ...Object.entries(ctx.feedbacks) + .map(([ id, feedback ]) => [ + `${{ ...ctx.contacts, ...ctx.chairs }[id]?.name ?? id}`, + ':', + feedback, + ].join('')), + ].join('\n'), + Object.keys(ctx.contacts), + )), + actions.send(ctx => duckula.Event.FEEDBACKS(ctx.feedbacks)), + ], + on: { + [duckula.Type.FEEDBACKS]: duckula.State.Responding, + }, + }, + + ...responseStates(duckula.id), + }, +}) + +export default machine diff --git a/src/domain-actors/brainstorming/mod.spec.ts b/src/domain-actors/brainstorming/mod.spec.ts new file mode 100755 index 0000000..82b2931 --- /dev/null +++ b/src/domain-actors/brainstorming/mod.spec.ts @@ -0,0 +1,30 @@ +#!/usr/bin/env -S node --no-warnings --loader ts-node/esm +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import { test } from 'tstest' + +import type { Duckula } from 'mailbox' + +import * as mod from './mod.js' + +test('mod is a Duckula', async t => { + const duckula: Duckula = mod + t.ok(duckula, 'should satisfy Duckula Interface for mod export') +}) diff --git a/src/domain-actors/brainstorming/mod.ts b/src/domain-actors/brainstorming/mod.ts new file mode 100644 index 0000000..733ae86 --- /dev/null +++ b/src/domain-actors/brainstorming/mod.ts @@ -0,0 +1,34 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import duckula, { Context } from './duckula.js' +import machine from './machine.js' + +export const { + id, + Event, + State, + Type, + initialContext, +} = duckula + +export { + machine, + type Context, +} diff --git a/src/domain-actors/brainstorming/selectors.ts b/src/domain-actors/brainstorming/selectors.ts new file mode 100644 index 0000000..43da7b9 --- /dev/null +++ b/src/domain-actors/brainstorming/selectors.ts @@ -0,0 +1,29 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import { isDefined } from '../../pure-functions/is-defined.js' + +import type { Context } from './duckula.js' + +export const feedbacksNum = (ctx: Context) => Object.values(ctx.feedbacks) + .filter(isDefined) + .length + +export const contactsNum = (ctx: Context) => Object.keys(ctx.contacts) + .length diff --git a/src/domain-actors/feedback/duckula.ts b/src/domain-actors/feedback/duckula.ts new file mode 100644 index 0000000..d0e5d0d --- /dev/null +++ b/src/domain-actors/feedback/duckula.ts @@ -0,0 +1,96 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +/* eslint-disable sort-keys */ +import type * as PUPPET from 'wechaty-puppet' +import * as CQRS from 'wechaty-cqrs' +import * as Mailbox from 'mailbox' + +import * as duck from '../../duck/mod.js' + +export interface Context { + admins : string[] + contacts : { [id: string]: PUPPET.payloads.Contact } + feedbacks : { [id: string]: string } + message?: PUPPET.payloads.Message + actors: { + wechaty: string + notice: string + } +} + +const duckula = Mailbox.duckularize({ + id: 'Feedback', + events: [ { ...duck.Event, ...CQRS.duck.actions, ...Mailbox.Event }, [ + /** + * Config + */ + 'RESET', + /** + * Requests + */ + 'MESSAGE', + 'REPORT', + /** + * Responses + */ + 'FEEDBACKS', + /** + * Internal + */ + 'TEXT', + 'NO_CONTACT', + 'GERROR', + 'IDLE', + 'PROCESS', + 'NEXT', + 'NO_TEXT', + 'NOTICE', + // Mailbox + 'ACTOR_REPLY', + ] ], + states: [ duck.State, [ + 'Feedbacking', + 'Idle', + 'Resetting', + 'Initializing', + 'Textualizing', + 'Processing', + 'Registering', + 'Reporting', + 'Nexting', + 'Completing', + 'Erroring', + 'Errored', + 'Responding', + 'Responded', + ] ], + initialContext: ({ + admins: [ 'lizhuohuan' ], + feedbacks: {}, + message: undefined, + }), +}) + +export type Event = ReturnType +export type Events = { + [key in keyof typeof duckula.Event]: ReturnType +} + +export default duckula diff --git a/src/domain-actors/feedback/machine.spec.ts b/src/domain-actors/feedback/machine.spec.ts new file mode 100755 index 0000000..66a9bf1 --- /dev/null +++ b/src/domain-actors/feedback/machine.spec.ts @@ -0,0 +1,433 @@ +#!/usr/bin/env -S node --no-warnings --loader ts-node/esm +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +/* eslint-disable sort-keys */ + +import { + AnyEventObject, + interpret, + createMachine, + Interpreter, + // spawn, +} from 'xstate' +import { test, sinon } from 'tstest' +import type * as WECHATY from 'wechaty' +import { firstValueFrom, from, of } from 'rxjs' +import { filter, map, mergeMap } from 'rxjs/operators' +import * as Mailbox from 'mailbox' +import * as CQRS from 'wechaty-cqrs' +import { isActionOf } from 'typesafe-actions' + +import * as WechatyActor from '../../wechaty-actor/mod.js' +import { isDefined } from '../../pure-functions/is-defined.js' +import { FileToText } from '../../infrastructure-actors/mod.js' +import { bot5Fixtures } from '../../fixtures/bot5-fixture.js' +import { skipSelfMessagePayload$ } from '../../wechaty-actor/cqrs/skip-self-message-payload$.js' + +import * as Notice from '../notice/mod.js' + +import duckula, { Context } from './duckula.js' +import machine from './machine.js' + +const awaitMessageWechaty = (wechaty: WECHATY.Wechaty) => (sayFn: () => any) => { + const future = new Promise(resolve => wechaty.once('message', resolve)) + sayFn() + return future +} + +test('feedback machine smoke testing', async t => { + for await (const { + mocker: mockerFixtures, + wechaty: wechatyFixtures, + } of bot5Fixtures()) { + + const [ [ FILE, TEXT ] ] = await FileToText.FIXTURES() + + const FIXTURES = { + room: wechatyFixtures.groupRoom, + members: [ + wechatyFixtures.mary, + wechatyFixtures.mike, + wechatyFixtures.player, + ], + feedbacks: { + mary : [ 'im mary', 'im mary' ], + mike : [ 'im mike', 'im mike' ], + player : [ FILE, TEXT ], + }, + } as const + + const sandbox = sinon.createSandbox({ + useFakeTimers: { now: Date.now() }, // for make TencentCloud API timestamp happy + }) + + const bus$ = CQRS.from(wechatyFixtures.wechaty) + const wechatyMailbox = WechatyActor.from(bus$, wechatyFixtures.wechaty.puppet.id) + + ;(wechatyMailbox as Mailbox.impls.Mailbox).internal.interpreter.subscribe(s => { + console.info('>>> wechaty mailbox:', [ + `(${s.history?.value || ''})`.padEnd(30, ' '), + ' + ', + `[${s.event.type}]`.padEnd(30, ' '), + ' = ', + `(${s.value})`.padEnd(30, ' '), + ].join('')) + }) + + ;(wechatyMailbox as Mailbox.impls.Mailbox).internal.actor.interpreter?.subscribe(s => { + console.info('>>> wechaty actor:', [ + `(${s.history?.value || ''})`.padEnd(30, ' '), + ' + ', + `[${s.event.type}]`.padEnd(30, ' '), + ' = ', + `(${s.value})`.padEnd(30, ' '), + ].join('')) + }) + + const noticeMailbox = Mailbox.from( + Notice.machine.withContext({ + ...Notice.initialContext(), + conversation: mockerFixtures.groupRoom.id, + actors: { + wechaty: String(wechatyMailbox.address), + }, + }), + ) + noticeMailbox.open() + + const CONTACTS = FIXTURES.members.reduce((acc, member) => ({ ...acc, [member.id]: member.payload }), {}) + + const feedbackMailbox = Mailbox.from( + machine.withContext({ + ...duckula.initialContext(), + contacts: CONTACTS, + actors: { + wechaty: String(wechatyMailbox.address), + notice: String(noticeMailbox.address), + }, + }), + ) + feedbackMailbox.open() + + const TEST_ID = 'TestMachine' + const testMachine = createMachine({ + id: TEST_ID, + on: { + '*': { + actions: Mailbox.actions.proxy(TEST_ID)(feedbackMailbox), + }, + }, + }) + + const testEventList: AnyEventObject[] = [] + const testInterpreter = interpret(testMachine) + .onEvent(e => testEventList.push(e)) + .start() + + const feedbackInterpreter = (feedbackMailbox as Mailbox.impls.Mailbox).internal.actor.interpreter! + const feedbackState = () => feedbackInterpreter.getSnapshot().value + const feedbackContext = () => feedbackInterpreter.getSnapshot().context as Context + + const feedbackEventList: AnyEventObject[] = [] + feedbackInterpreter.subscribe(s => { + feedbackEventList.push(s.event) + console.info('>>> feedback:', [ + `(${s.history?.value || ''})`.padEnd(30, ' '), + ' + ', + `[${s.event.type}]`.padEnd(30, ' '), + ' = ', + `(${s.value})`.padEnd(30, ' '), + ].join('')) + }) + + t.equal(feedbackState(), duckula.State.Idle, 'should be idle state after initial') + t.same(feedbackContext().contacts, CONTACTS, 'should set contexts to CONTACTS') + + skipSelfMessagePayload$(bus$)(wechatyFixtures.wechaty.puppet.id).pipe( + map(messagePayload => duckula.Event.MESSAGE(messagePayload)), + ).subscribe(e => { + console.info('### duckula.Event.MESSAGE', e) + testInterpreter.send(e) + }) + + const listenMessage = awaitMessageWechaty(wechatyFixtures.wechaty) + + /** + * Send MESSAGE event: Mary + */ + feedbackEventList.length = 0 + await listenMessage(() => + mockerFixtures.mary.say(FIXTURES.feedbacks.mary[0]).to(mockerFixtures.groupRoom), + ) + // await new Promise(setImmediate) + await sandbox.clock.nextAsync() + + // console.info('feedbackEventList', feedbackEventList) + t.same( + feedbackEventList + .map(e => e.type) + .filter(e => e !== Mailbox.Type.ACTOR_IDLE), + [ duckula.Type.MESSAGE ], + 'should get MESSAGE event', + ) + t.same(feedbackState(), duckula.State.Textualizing, 'should be back to state Textualizing after received a text message') + await sandbox.clock.runAllAsync() + t.same(feedbackContext().feedbacks, { + [wechatyFixtures.mary.id]: FIXTURES.feedbacks.mary[1], + }, 'should have feedback from mary') + t.equal(Object.keys(feedbackContext().feedbacks).length, 1, 'should have 1 feedback so far') + + /** + * Mike + */ + await listenMessage(() => mockerFixtures.mike.say(FIXTURES.feedbacks.mike[0]).to(mockerFixtures.groupRoom)) + await sandbox.clock.runAllAsync() + // console.info((snapshot.event.payload as any).message) + t.same( + feedbackState(), + duckula.State.Idle, + 'should be back to state active.idle after received a text message', + ) + t.same(feedbackContext().feedbacks, { + [wechatyFixtures.mary.id]: FIXTURES.feedbacks.mary[1], + [wechatyFixtures.mike.id]: FIXTURES.feedbacks.mike[1], + }, 'should have feedback from 2 users') + t.equal(Object.keys(feedbackContext().feedbacks).length, 2, 'should have 2 feedback so far') + + /** + * Player + */ + feedbackEventList.length = 0 + await listenMessage(() => mockerFixtures.player.say(FIXTURES.feedbacks.player[0]).to(mockerFixtures.groupRoom)) + await sandbox.clock.nextAsync() + t.same( + feedbackEventList + .map(e => e.type) + .filter(e => e !== Mailbox.Type.ACTOR_IDLE), + [ duckula.Type.MESSAGE ], + 'should get MESSAGE event', + ) + t.equal(feedbackState(), duckula.State.Textualizing, 'should in state Textualizing after received audio message') + + /** + * Wait for State.Idle of feedback + */ + const future = firstValueFrom( + from(feedbackInterpreter as any).pipe( + // tap((x: any) => console.info('> feedback:', [ + // `(${x.history?.value || ''})`, + // ' + ', + // `[${x.event.type}]`, + // ' = ', + // `(${x.value})`, + // ].join(''))), + filter((s: any) => s.value === duckula.State.Idle), + ), + ) + /** + * Huan(202204): even after `sandbox.restore()`, `runAllAsync()` is still require below, + * or the `await future` will never resolved. + * + * Maybe because this `future` is created before `restore()`? + */ + sandbox.restore() + await sandbox.clock.runAllAsync() + await future + + t.equal(feedbackState(), duckula.State.Idle, 'should in state idle after resolve stt message') + t.same(feedbackContext().feedbacks, { + [wechatyFixtures.mary.id] : FIXTURES.feedbacks.mary[1], + [wechatyFixtures.mike.id] : FIXTURES.feedbacks.mike[1], + [wechatyFixtures.player.id] : FIXTURES.feedbacks.player[1], + }, 'should have feedback from all users in feedback context') + t.equal(Object.keys(feedbackContext().feedbacks).length, 3, 'should have all 3 feedbacks in feedback context') + + /** + * Huan(202201): must use setTimeout instead of setImmediate to make sure the following test pass + * it seems that the setImmediate is microtask (however the internet said that it should be a macrotask), + * and the setTimeout is macrotask? + * + * additional note: if we use `await sandbox.clock.runAllAsync()`, it has to be ran twice. + * (the `setImmediate` need to be ran twice too) + * + * TODO: why? + */ + await new Promise(resolve => setTimeout(resolve, 0)) + // await new Promise(setImmediate) + + // console.info('testEventList', testEventList) + t.same( + testEventList.filter(isActionOf(duckula.Event.FEEDBACKS)), + [ + duckula.Event.FEEDBACKS({ + [wechatyFixtures.mary.id] : FIXTURES.feedbacks.mary[1], + [wechatyFixtures.mike.id] : FIXTURES.feedbacks.mike[1], + [wechatyFixtures.player.id] : FIXTURES.feedbacks.player[1], + }), + ], + 'should get feedback EVENT from consumer machine', + ) + + testInterpreter.stop() + } +}) + +test('feedback actor smoke testing', async t => { + for await (const WECHATY_FIXTURES of bot5Fixtures()) { + const { + mocker, + wechaty: wechatyFixtures, + } = WECHATY_FIXTURES + + const bus$ = CQRS.from(wechatyFixtures.wechaty) + const wechatyActor = WechatyActor.from(bus$, wechatyFixtures.wechaty.puppet.id) + + const [ [ FILE, TEXT ] ] = await FileToText.FIXTURES() + + const MEMBER_LIST = (await wechatyFixtures.groupRoom.memberAll()) + .filter(isDefined) + .filter(p => p.id !== mocker.bot.id) + + const FIXTURES = { + contacts: MEMBER_LIST.reduce((acc, member) => ({ ...acc, [member.id]: member.payload }), {}), + feedbacks: { + mary : [ 'im mary', 'im mary' ], + mike : [ 'im mike', 'im mike' ], + player : [ FILE, TEXT ], + }, + } as const + + const feedbackMailbox = Mailbox.from( + machine.withContext({ + ...duckula.initialContext(), + contacts: FIXTURES.contacts, + actors: { + notice : String(Mailbox.nil.address), + wechaty : String(wechatyActor.address), + }, + }), + ) + feedbackMailbox.open() + + const TEST_ID = 'TestMacihne' + const testMachine = createMachine({ + id: TEST_ID, + on: { + '*': { + actions: Mailbox.actions.proxy(TEST_ID)(feedbackMailbox), + }, + }, + }) + + const eventList: AnyEventObject[] = [] + + const interpreter = interpret(testMachine) + .onEvent(e => eventList.push(e)) + .start() + + skipSelfMessagePayload$(bus$)(wechatyFixtures.wechaty.puppet.id).pipe( + map(messagePayload => duckula.Event.MESSAGE(messagePayload)), + ).subscribe(e => { + console.info('### duckula.Event.MESSAGE', e) + interpreter.send(e) + }) + + const feedbackInterpreter = (feedbackMailbox as Mailbox.impls.Mailbox).internal.actor.interpreter! + feedbackInterpreter.subscribe(s => console.info('>>> feedback:', [ + `(${s.history?.value || ''})`.padEnd(30, ' '), + ' + ', + `[${s.event.type}]`.padEnd(30, ' '), + ' = ', + `(${s.value})`.padEnd(30, ' '), + ].join(''))) + + const listenMessage = awaitMessageWechaty(wechatyFixtures.wechaty) + + // console.info('MEMBER_LIST', MEMBER_LIST) + + for (const [ user, [ sayable ] ] of Object.entries(FIXTURES.feedbacks)) { + eventList.length = 0 + /** + * Send MESSAGE event + */ + await listenMessage(() => mocker[user as keyof typeof FIXTURES.feedbacks] + .say(sayable) + .to(mocker.groupRoom)) + + t.same( + eventList.map(e => e.type), + [ duckula.Type.MESSAGE ], + `should get message events from ${user}`, + ) + } + + await firstValueFrom( + from(interpreter).pipe( + // tap(x => console.info('tap event:', x.event.type)), + filter(s => s.event.type === duckula.Type.FEEDBACKS), + ), + ) + // eventList.forEach(e => console.info(e)) + const EXPECTED_FEEDBACKS = Object.entries(FIXTURES.feedbacks) + .reduce( + (acc, cur) => { + const contact = mocker[cur[0] as keyof typeof mocker] + const text = cur[1][1] + return { + ...acc, + [contact.id]: text, + } + }, + {} as { + [key in keyof typeof FIXTURES.feedbacks]: string + }, + ) + + // console.info('EXPECTED_FEEDBACKS', EXPECTED_FEEDBACKS) + t.same( + eventList.filter(isActionOf(duckula.Event.FEEDBACKS)), + [ + duckula.Event.FEEDBACKS(EXPECTED_FEEDBACKS), + ], + 'should get FEEDBACKS event', + ) + + await listenMessage(() => mocker.mary.say(FIXTURES.feedbacks.player[0]).to(mocker.groupRoom)) + eventList.length = 0 + await firstValueFrom( + from(interpreter).pipe( + // tap(x => console.info('tap event:', x.event.type)), + filter(s => s.event.type === duckula.Type.FEEDBACKS), + ), + ) + t.same( + eventList, + [ + duckula.Event.FEEDBACKS({ + ...EXPECTED_FEEDBACKS, + [mocker.mary.id] : FIXTURES.feedbacks.player[1], + }), + ], + 'should get FEEDBACKS event immediately after mary sent feedback of player once again', + ) + + interpreter.stop() + } +}) diff --git a/src/domain-actors/feedback/machine.ts b/src/domain-actors/feedback/machine.ts new file mode 100644 index 0000000..60acc98 --- /dev/null +++ b/src/domain-actors/feedback/machine.ts @@ -0,0 +1,223 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +/* eslint-disable no-redeclare */ +/* eslint-disable sort-keys */ +import { actions, createMachine } from 'xstate' +import { GError } from 'gerror' +import * as Mailbox from 'mailbox' + +import { MessageToText } from '../../application-actors/mod.js' +import { responseStates } from '../../actor-utils/mod.js' + +import * as NoticeActor from '../notice/mod.js' + +import duckula, { Context, Event, Events } from './duckula.js' +import * as selectors from './selectors.js' + +const machine = createMachine({ + id: duckula.id, + + /** + * Internal events only + */ + on: { + [NoticeActor.Type.NOTICE]: { + actions: actions.send( + (_, e) => NoticeActor.Event.NOTICE('【反馈系统】' + e.payload.text, e.payload.mentions), + { to: ctx => ctx.actors.notice }, + ), + }, + }, + + initial: duckula.State.Initializing, + states: { + [duckula.State.Initializing]: { + entry: [ + actions.log(ctx => `states.Initializing.entry context ${JSON.stringify(ctx)}`, duckula.id), + ], + always: duckula.State.Idle, + }, + [duckula.State.Resetting]: { + entry: [ + actions.log('states.Resetting', duckula.id), + actions.assign(ctx => ({ + ...ctx, + ...duckula.initialContext(), + })), + ], + always: duckula.State.Initializing, + }, + + /** + * 0. received MESSAGE -> transition to Textualizing + * 1. received REPORT -> transition to Reporting + * 2. received RESET -> transition to Initializing + * + */ + [duckula.State.Idle]: { + entry: [ + Mailbox.actions.idle(duckula.id), + actions.assign({ + message: undefined, + }), + ], + on: { + /** + * Huan(202112): + * Every EVENTs received in state.idle must have a `target` to make sure it is a `external` event. + * so that the Mailbox.actions.idle() will be triggered and let the Mailbox knowns it's ready to process next message. + */ + '*': duckula.State.Idle, + [duckula.Type.MESSAGE]: { + actions: [ + actions.log('states.Idle.on.MESSAGE', duckula.id), + actions.assign({ message: (_, e) => e.payload.message }), + ], + target: duckula.State.Textualizing, + }, + [duckula.Type.REPORT]: { + actions: [ + actions.log('states.Idle.on.REPORT', duckula.id), + ], + target: duckula.State.Reporting, + }, + [duckula.Type.RESET]: duckula.State.Resetting, + }, + }, + + [duckula.State.Reporting]: { + entry: [ + actions.log(ctx => `states.Reporting.entry feedbacks/contacts(${selectors.feedbackNum(ctx)}/${selectors.contactNum(ctx)})`, duckula.id), + actions.choose([ + { + cond: ctx => selectors.feedbackNum(ctx) >= selectors.contactNum(ctx), + actions: [ + actions.log('states.Reporting.entry replying [FEEDBACKS]', duckula.id), + actions.send(ctx => duckula.Event.FEEDBACKS(ctx.feedbacks)), + ], + }, + { + actions: [ + actions.log('states.Reporting.entry feedbacks is not enough', duckula.id), + actions.send(duckula.Event.NEXT()), + ], + }, + ]), + ], + on: { + [duckula.Type.FEEDBACKS] : duckula.State.Responding, + [duckula.Type.NEXT] : duckula.State.Idle, + }, + }, + + /** + * 1. entry MESSAGE -> TEXT / GERROR + * + * 2. received TEXT -> transition to Feedbacking + * 4. received GERROR -> transition to Errored + */ + [duckula.State.Textualizing]: { + invoke: { + id: MessageToText.id, + src: ctx => Mailbox.wrap( + MessageToText.machine.withContext({ + ...MessageToText.initialContext(), + actors: { + wechaty: ctx.actors.wechaty, + }, + }), + ), + onDone: { actions: actions.send((_, e) => duckula.Event.GERROR(GError.stringify(e.data))) }, + onError: { actions: actions.send((_, e) => duckula.Event.GERROR(GError.stringify(e.data))) }, + }, + entry: [ + actions.log('states.Textualizing.entry', duckula.id), + actions.send((_, e) => e, { to: MessageToText.id }), + ], + on: { + [duckula.Type.TEXT] : duckula.State.Feedbacking, + [duckula.Type.NO_TEXT] : duckula.State.Idle, + [duckula.Type.GERROR] : duckula.State.Erroring, + }, + }, + + [duckula.State.Feedbacking]: { + entry: [ + actions.log((_, e) => `states.Feedbacking.entry ${e.payload.message?.talkerId}: "${e.payload.text}"`, duckula.id), + actions.assign({ + feedbacks: (ctx, e) => ({ + ...ctx.feedbacks, + ...e.payload.message && { [e.payload.message.talkerId]: e.payload.text }, + }), + }), + actions.send( + (ctx, e) => NoticeActor.Event.NOTICE( + [ + `收到${(e.payload.message && ctx.contacts[e.payload.message.talkerId])?.name}的反馈:`, + `“${e.payload.text}”`, + ].join('\n'), + ), + ), + actions.send(duckula.Event.NEXT()), + ], + on: { + [duckula.Type.NEXT]: duckula.State.Nexting, + }, + }, + + [duckula.State.Nexting]: { + entry: [ + actions.choose([ + { + cond: ctx => !!selectors.nextContact(ctx), + actions: [ + actions.send(ctx => NoticeActor.Event.NOTICE( + [ + `下一位:@${selectors.nextContact(ctx)?.name}`, + selectors.contactAfterNext(ctx)?.name ? `。(请@${selectors.contactAfterNext(ctx)?.name}做准备)` : '', + ].join(''), + selectors.contactAfterNext(ctx) + ? [ selectors.nextContact(ctx)!.id, selectors.contactAfterNext(ctx)!.id ] + : [ selectors.nextContact(ctx)!.id ], + )), + ], + }, + { + actions: [ + actions.send(ctx => NoticeActor.Event.NOTICE([ + '已完成收集所有人反馈:', + Object.values(ctx.contacts).map(contact => contact.name).join(','), + `共 ${Object.keys(ctx.contacts).length} 人。`, + ].join(''))), + ], + }, + ]), + actions.send(duckula.Event.NEXT()), + ], + on: { + [duckula.Type.NEXT]: duckula.State.Reporting, + }, + }, + + ...responseStates(duckula.id), + }, +}) + +export default machine diff --git a/src/domain-actors/feedback/mod.spec.ts b/src/domain-actors/feedback/mod.spec.ts new file mode 100755 index 0000000..9dee096 --- /dev/null +++ b/src/domain-actors/feedback/mod.spec.ts @@ -0,0 +1,35 @@ +#!/usr/bin/env -S node --no-warnings --loader ts-node/esm +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import { test } from 'tstest' + +import type { Duckula } from 'mailbox' + +import * as mod from './mod.js' + +test('mod is a Duckula', async t => { + const duckula: Duckula = mod + t.ok(duckula, 'should satisfy Duckula Interface for mod export') +}) + +test('mod has Context', async t => { + const context: mod.Context = {} as any + t.ok(context, 'should has Context interface') +}) diff --git a/src/domain-actors/feedback/mod.ts b/src/domain-actors/feedback/mod.ts new file mode 100644 index 0000000..698e041 --- /dev/null +++ b/src/domain-actors/feedback/mod.ts @@ -0,0 +1,34 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import duckula, { type Context } from './duckula.js' +import machine from './machine.js' + +export const { + id, + Event, + State, + Type, + initialContext, +} = duckula + +export { + machine, + type Context, +} diff --git a/src/domain-actors/feedback/selectors.spec.ts b/src/domain-actors/feedback/selectors.spec.ts new file mode 100755 index 0000000..dbfd662 --- /dev/null +++ b/src/domain-actors/feedback/selectors.spec.ts @@ -0,0 +1,66 @@ +#!/usr/bin/env -S node --no-warnings --loader ts-node/esm +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +/* eslint-disable sort-keys */ + +import { test } from 'tstest' + +import { bot5Fixtures } from '../../fixtures/bot5-fixture.js' + +import duckula from './duckula.js' +import * as selectors from './selectors.js' + +test('nextContact()', async t => { + for await (const { + wechaty: wechatyFixtures, + } of bot5Fixtures()) { + const context = duckula.initialContext() + t.equal(selectors.nextContact(context), undefined, 'should return undefined when context is empty') + + context.contacts = [ + wechatyFixtures.mary.payload!, + wechatyFixtures.mike.payload!, + wechatyFixtures.player.payload!, + wechatyFixtures.bot.payload!, + ].reduce((acc, cur) => ({ ...acc, [cur.id]: cur }), {}) + + t.equal(selectors.nextContact(context), wechatyFixtures.mary.payload!, 'should return first contact in the list when context.feedbacks is empty') + + context.feedbacks = { + [wechatyFixtures.mary.id]: 'im mary', + } + t.equal(selectors.nextContact(context), wechatyFixtures.mike.payload!, 'should return second contact in the list when context.feedbacks is set to mary feedback') + + context.feedbacks = { + [wechatyFixtures.mary.id]: 'im mary', + [wechatyFixtures.mike.id]: 'im mike', + } + t.equal(selectors.nextContact(context), wechatyFixtures.player.payload!, 'should return third contact in the list when context.feedbacks is set to mary&mike feedbacks') + + context.feedbacks = { + [wechatyFixtures.mary.id]: 'im mary', + [wechatyFixtures.mike.id]: 'im mike', + [wechatyFixtures.player.id]: 'im player', + [wechatyFixtures.bot.id]: 'im bot', + } + t.equal(selectors.nextContact(context), undefined, 'should return undefined if everyone has feedbacked') + + } +}) diff --git a/src/domain-actors/feedback/selectors.ts b/src/domain-actors/feedback/selectors.ts new file mode 100644 index 0000000..824ebf9 --- /dev/null +++ b/src/domain-actors/feedback/selectors.ts @@ -0,0 +1,29 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import type { Context } from './duckula.js' + +export const contactNum = (ctx: Context) => Object.keys(ctx.contacts).length +export const feedbackNum = (ctx: Context) => Object.values(ctx.feedbacks).filter(Boolean).length +export const nextContact = (ctx: Context) => Object.values(ctx.contacts).filter(c => + !Object.keys(ctx.feedbacks).includes(c.id), +)[0] +export const contactAfterNext = (ctx: Context) => Object.values(ctx.contacts).filter(c => + !Object.keys(ctx.feedbacks).includes(c.id), +)[1] diff --git a/src/domain-actors/intent/duckula.ts b/src/domain-actors/intent/duckula.ts new file mode 100644 index 0000000..a87f60a --- /dev/null +++ b/src/domain-actors/intent/duckula.ts @@ -0,0 +1,67 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +/* eslint-disable sort-keys */ +import * as Mailbox from 'mailbox' +import type * as PUPPET from 'wechaty-puppet' + +import * as duck from '../../duck/mod.js' + +export interface Context { + message?: PUPPET.payloads.Message + actors: { + messageToText: string + textToIntents: string + }, +} + +const duckula = Mailbox.duckularize({ + id: 'Intent', + events: [ { ...duck.Event }, [ + /** + * Request + */ + 'MESSAGE', + /** + * Response + */ + 'INTENTS', + 'GERROR', + /** + * Internal + */ + 'TEXT', + ] ], + states: [ duck.State, [ + 'Initializing', + 'Idle', + 'Loading', + 'Loaded', + 'Responding', + 'Erroring', + ] ], + initialContext: {} as Context, +}) + +export type Event = ReturnType +export type Events = { + [key in keyof typeof duckula.Event]: ReturnType +} + +export default duckula diff --git a/src/domain-actors/intent/machine.spec.ts b/src/domain-actors/intent/machine.spec.ts new file mode 100755 index 0000000..7f0d1af --- /dev/null +++ b/src/domain-actors/intent/machine.spec.ts @@ -0,0 +1,145 @@ +#!/usr/bin/env -S node --no-warnings --loader ts-node/esm +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +/* eslint-disable sort-keys */ +import { + AnyEventObject, + interpret, + createMachine, + AnyInterpreter, +} from 'xstate' +import { firstValueFrom, from } from 'rxjs' +import { map, mergeMap, filter, tap, share } from 'rxjs/operators' +import { test } from 'tstest' +import type * as WECHATY from 'wechaty' +import type { mock } from 'wechaty-puppet-mock' +import * as Mailbox from 'mailbox' +import * as CQRS from 'wechaty-cqrs' + +import * as WechatyActor from '../../wechaty-actor/mod.js' +import { MessageToText } from '../../application-actors/mod.js' +import { TextToIntents } from '../../infrastructure-actors/mod.js' + +import duckula from './duckula.js' +import machine from './machine.js' +import { bot5Fixtures } from '../../fixtures/bot5-fixture.js' +import { isActionOf } from 'typesafe-actions' + +test('intent actor smoke testing', async t => { + let interpreter: AnyInterpreter + + for await (const { + mocker: mockerFixtures, + wechaty: wechatyFixtures, + } of bot5Fixtures()) { + const bus$ = CQRS.from(wechatyFixtures.wechaty) + const wechatyMailbox = WechatyActor.from(bus$, wechatyFixtures.wechaty.puppet.id) + wechatyMailbox.open() + + const messageToTextMailbox = Mailbox.from(MessageToText.machine.withContext({ + ...MessageToText.initialContext(), + actors: { + wechaty: String(wechatyMailbox.address), + }, + })) + messageToTextMailbox.open() + + const textToIntentsMailbox = Mailbox.from(TextToIntents.machine.withContext(TextToIntents.initialContext())) + textToIntentsMailbox.open() + + const intentMailbox = Mailbox.from(machine.withContext({ + ...duckula.initialContext(), + actors: { + messageToText: String(messageToTextMailbox.address), + textToIntents: String(textToIntentsMailbox.address), + }, + })) + intentMailbox.open() + + const testMachine = createMachine({ + on: { + '*': { + actions: Mailbox.actions.proxy('TestMachine')(intentMailbox), + }, + }, + }) + + const eventList: AnyEventObject[] = [] + interpreter = interpret(testMachine) + .onEvent(e => eventList.push(e)) + .start() + + /** + * Huan(202204): Workaround: make it HOT + * + * Bug: `interpreter.subscribe()` acts like a BehaviorObservable + * @link https://github.com/statelyai/xstate/issues/3259 + */ + const state$ = from(interpreter).pipe( + share(), + ) + // make it keep hot + state$.subscribe(() => {}) + + bus$.pipe( + // tap(e => console.info('### bus$', e)), + filter(CQRS.is(CQRS.events.MessageReceivedEvent)), + map(e => CQRS.queries.GetMessagePayloadQuery(wechatyFixtures.wechaty.puppet.id, e.payload.messageId)), + mergeMap(CQRS.execute$(bus$)), + map(response => response.payload.message), + filter(Boolean), + map(messagePayload => duckula.Event.MESSAGE(messagePayload)), + ).subscribe(e => { + // console.info('### duckula.Event.MESSAGE', e) + interpreter.send(e) + }) + + for (const [ text, intents ] of TextToIntents.FIXTURES) { + eventList.length = 0 + const future = firstValueFrom(state$.pipe( + map(state => state.event), + filter(isActionOf(duckula.Event.INTENTS)), + // tap(e => console.info('### duckula.Event.INTENTS', e)), + )) + mockerFixtures.mary.say(text).to(mockerFixtures.groupRoom) + await future + + // eventList + // .forEach(e => console.info(e)) + // console.info('^^^^^vvvvvv') + // eventList + // .filter(isActionOf(duckula.Event.INTENTS)) + // .forEach(e => console.info(e)) + + t.same(eventList.filter(isActionOf(duckula.Event.INTENTS)), [ + duckula.Event.INTENTS( + intents, + eventList + .filter(isActionOf(duckula.Event.MESSAGE)) + .at(-1)! + .payload + .message, + ), + ], `should get Intents [${intents}] for ${text}`) + } + } + + interpreter!.stop() +}) diff --git a/src/domain-actors/intent/machine.ts b/src/domain-actors/intent/machine.ts new file mode 100644 index 0000000..948ffab --- /dev/null +++ b/src/domain-actors/intent/machine.ts @@ -0,0 +1,106 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +/* eslint-disable sort-keys */ +import { createMachine, actions, AnyEventObject } from 'xstate' +import * as PUPPET from 'wechaty-puppet' +import * as Mailbox from 'mailbox' + +import { MessageToText } from '../../application-actors/mod.js' +import { TextToIntents } from '../../infrastructure-actors/mod.js' + +import duckula, { Context, Event, Events } from './duckula.js' +import { responseStates } from '../../actor-utils/response-states.js' + +const machine = createMachine< + Context, + Event +>({ + id: duckula.id, + initial: duckula.State.Initializing, + states: { + [duckula.State.Initializing]: { + entry: [ + actions.log(ctx => `states.Initializing.entry context ${JSON.stringify(ctx)}`, duckula.id), + ], + always: duckula.State.Idle, + }, + + /** + * + * Idle + * + * 1. received MESSAGE -> transition to Loading + * + */ + [duckula.State.Idle]: { + entry: [ + Mailbox.actions.idle(duckula.id), + actions.assign({ message: undefined }), + ], + on: { + [duckula.Type.MESSAGE]: duckula.State.Loading, + '*' : duckula.State.Idle, + }, + }, + + [duckula.State.Loading]: { + entry: [ + actions.assign({ message: (_, e) => e.payload.message }), + actions.log((_, e) => `states.Loading.entry MESSAGE type: ${PUPPET.types.Message[e.payload.message.type]}`, duckula.id), + actions.send((_, e) => e, { to: ctx => ctx.actors.messageToText }), + ], + on: { + [MessageToText.Type.TEXT]: { + actions: [ + actions.log((_, e) => `states.Loading.on.TEXT ${e.payload.text}`, duckula.id), + actions.send((_, e) => e, { to: ctx => ctx.actors.textToIntents }), + ], + }, + [TextToIntents.Type.INTENTS] : { + actions: [ + actions.log((_, e) => `states.Loading.on.INTENTS ${e.payload.intents}`, duckula.id), + ], + target: duckula.State.Loaded, + }, + [TextToIntents.Type.GERROR] : duckula.State.Erroring, + [MessageToText.Type.GERROR] : duckula.State.Erroring, + }, + }, + + [duckula.State.Loaded]: { + entry: [ + actions.log((_, e) => `states.Loaded.entry [${e.type}]`, duckula.id), + actions.send( + (ctx, e) => duckula.Event.INTENTS( + e.payload.intents, + ctx.message, + ), + ), + ], + on: { + [duckula.Type.INTENTS]: duckula.State.Responding, + }, + }, + + ...responseStates(duckula.id), + }, +}) + +export default machine diff --git a/src/domain-actors/intent/mod.spec.ts b/src/domain-actors/intent/mod.spec.ts new file mode 100755 index 0000000..82b2931 --- /dev/null +++ b/src/domain-actors/intent/mod.spec.ts @@ -0,0 +1,30 @@ +#!/usr/bin/env -S node --no-warnings --loader ts-node/esm +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import { test } from 'tstest' + +import type { Duckula } from 'mailbox' + +import * as mod from './mod.js' + +test('mod is a Duckula', async t => { + const duckula: Duckula = mod + t.ok(duckula, 'should satisfy Duckula Interface for mod export') +}) diff --git a/src/domain-actors/intent/mod.ts b/src/domain-actors/intent/mod.ts new file mode 100644 index 0000000..a6d6c04 --- /dev/null +++ b/src/domain-actors/intent/mod.ts @@ -0,0 +1,33 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import duckula from './duckula.js' +import machine from './machine.js' + +export const { + id, + Event, + State, + Type, + initialContext, +} = duckula + +export { + machine, +} diff --git a/src/domain-actors/meeting-actor.spec.ts b/src/domain-actors/meeting-actor.spec.ts new file mode 100755 index 0000000..fa2fe3b --- /dev/null +++ b/src/domain-actors/meeting-actor.spec.ts @@ -0,0 +1,32 @@ +#!/usr/bin/env -S node --no-warnings --loader ts-node/esm + +import { test } from 'tstest' +import { interpret } from 'xstate' + +import * as duck from '../duck/mod.js' + +import { bot5Fixtures } from '../fixtures/bot5-fixture.js' +import * as MeetingActor from './meeting/machine.js' + +test('MeetingActor smoke testing', async t => { + for await (const { + mocker: mockerFixtures, + wechaty: wechatyFixtures, + } of bot5Fixtures()) { + void mockerFixtures + void wechatyFixtures + // const sandbox = sinon.createSandbox() + const interpreter = interpret(MeetingActor.machineFactory()) + interpreter.start() + + t.ok(interpreter.state.matches(duck.State.Idle), 'should be idle') + + // Huan(202201): remove any + t.ok(interpreter.state.can(duck.Type.START as any), 'should can START') + + interpreter.send(MeetingActor.Events.START()) + t.ok(interpreter.state.matches(duck.State.meeting), 'should be in meeting state') + + t.notOk(interpreter.state.can(duck.Type.START as any), 'should can not START again in meeting') + } +}) diff --git a/src/domain-actors/meeting/duckula.ts b/src/domain-actors/meeting/duckula.ts new file mode 100644 index 0000000..7b1b8b9 --- /dev/null +++ b/src/domain-actors/meeting/duckula.ts @@ -0,0 +1,154 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +/* eslint-disable sort-keys */ +import type * as PUPPET from 'wechaty-puppet' +import * as Mailbox from 'mailbox' + +import * as duck from '../../duck/mod.js' + +import * as NoticeActor from '../notice/mod.js' + +export interface Context { + minutes? : string + room : PUPPET.payloads.Room + admins : string[] + chairs : { [contactId: string]: PUPPET.payloads.Contact } + attendees : { [contactId: string]: PUPPET.payloads.Contact } + brainstorms : { [contactId: string]: string } + talks : { [contactId: string]: string } + actors: { + notice : string, + feedback : string, + brainstorming : string, + intent : string, + wechaty : string, + } +} + +const duckula = Mailbox.duckularize({ + id: 'Meeting', + events: [ { ...NoticeActor.Event, ...duck.Event }, [ + /** + * Config + */ + 'CHAIRS', + 'ATTENDEES', + 'RESET', + /** + * Requests + */ + 'START', + /** + * Responses + */ + 'MINUTES', + 'GERROR', + /** + * Internal + */ + 'BACK', + 'BATCH', + 'CONTACTS', + 'FEEDBACKS', + 'TALKS', + 'HELP', + 'INTENTS', + 'MESSAGE', + 'NEXT', + 'PROCESS', + /** + * NoticingActor + */ + 'NOTICE', + ] ], + states: [ duck.State, [ + /** + * Config & Request + */ + 'Idle', + /** + * Response + */ + 'Responding', + 'Erroring', + /** + * Internal + */ + 'Initializing', + 'Initialized', + + 'Checkining', + 'Mentioning', + /** + * Meeting steps + */ + 'Starting', + 'Started', + 'Meeting', + 'ConfiguringChairs', + 'ConfiguringAttendees', + 'ConfiguringTalks', + 'ConfiguringRoom', + + 'Starting', + 'Upgrading', + 'Brainstorming', + 'Resetting', + 'Resetted', + 'Registering', + 'Electing', + 'Elected', + 'Reporting', + 'Processing', + 'Announcing', + 'Presenting', + 'Introducing', + 'Summarizing', + 'Pledging', + 'ShootingChairs', + 'ShootingAll', + 'ShootingDrinkers', + 'Housekeeping', + 'Chatting', + 'Retrospecting', + 'Joining', + 'Roasting', + 'Summarized', + 'Drinking', + 'Paying', + 'Finishing', + 'Finished', + ] ], + initialContext: ({ + minutes : undefined, + room : undefined, + admins : [ 'lizhuohuan' ], + attendees : {}, + chairs : {}, + brainstorms : {}, + }), +}) + +export type Event = ReturnType +export type Events = { + [key in keyof typeof duckula.Event]: ReturnType +} + +export default duckula diff --git a/src/domain-actors/meeting/machine.spec.ts b/src/domain-actors/meeting/machine.spec.ts new file mode 100755 index 0000000..fe71cd9 --- /dev/null +++ b/src/domain-actors/meeting/machine.spec.ts @@ -0,0 +1,463 @@ +#!/usr/bin/env -S node --no-warnings --loader ts-node/esm +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +/* eslint-disable sort-keys */ + +import { + AnyEventObject, + interpret, + createMachine, + Interpreter, + // spawn, +} from 'xstate' +import { test, sinon } from 'tstest' +import type * as WECHATY from 'wechaty' +import { firstValueFrom, from } from 'rxjs' +import { filter, map, mergeMap } from 'rxjs/operators' +import * as Mailbox from 'mailbox' +import * as CQRS from 'wechaty-cqrs' +import { isActionOf } from 'typesafe-actions' + +import * as WechatyActor from '../../wechaty-actor/mod.js' +import { getSilkFixtures } from '../../fixtures/get-silk-fixtures.js' +import { isDefined } from '../../pure-functions/is-defined.js' + +import { bot5Fixtures } from '../../fixtures/bot5-fixture.js' + +import duckula, { Context } from './duckula.js' +import machine from './machine.js' + +const awaitMessageWechaty = (wechaty: WECHATY.Wechaty) => (sayFn: () => void) => { + const future = new Promise(resolve => wechaty.once('message', resolve)) + sayFn() + return future +} + +test('meeting machine smoke testing', async t => { + for await (const { + mocker: mockerFixtures, + wechaty: wechatyFixtures, + } of bot5Fixtures()) { + + const sandbox = sinon.createSandbox({ + useFakeTimers: { now: Date.now() }, // for make TencentCloud API timestamp happy + }) + + const bus$ = CQRS.from(wechatyFixtures.wechaty) + const wechatyActor = WechatyActor.from(bus$, wechatyFixtures.wechaty.puppet.id) + + const TEST_ID = 'test-id' + const testMachine = createMachine({ + id: TEST_ID, + invoke: { + id: duckula.id, + src: machine.withContext({ + ...duckula.initialContext(), + actors: { + wechaty: String(wechatyActor.address), + notice: String(Mailbox.nil.address), + register: String(Mailbox.nil.address), + }, + }), + }, + + on: { + '*': { + actions: Mailbox.actions.proxy(TEST_ID)(duckula.id), + }, + }, + }) + + const testEventList: AnyEventObject[] = [] + const testInterpreter = interpret(testMachine) + .onEvent(e => testEventList.push(e)) + .start() + + const meetingInterpreter = testInterpreter.children.get(duckula.id) as Interpreter + const meetingState = () => meetingInterpreter.getSnapshot().value + const meetingContext = () => meetingInterpreter.getSnapshot().context as Context + + const meetingEventList: AnyEventObject[] = [] + + meetingInterpreter.subscribe(s => { + meetingEventList.push(s.event) + console.info('>>> meeting:', [ + `(${s.history?.value || ''})`.padEnd(30, ' '), + ' + ', + `[${s.event.type}]`.padEnd(30, ' '), + ' = ', + `(${s.value})`.padEnd(30, ' '), + ].join('')) + }) + + t.equal(meetingState(), duckula.State.Idle, 'should be idle state after initial') + t.same(meetingContext().attendees, [], 'should be empty attendee list') + + bus$.pipe( + // tap(e => console.info('### bus$', e)), + filter(CQRS.is(CQRS.events.MessageReceivedEvent)), + map(e => CQRS.queries.GetMessagePayloadQuery(wechatyFixtures.wechaty.puppet.id, e.payload.messageId)), + mergeMap(CQRS.execute$(bus$)), + map(response => response.payload.message), + filter(isDefined), + map(messagePayload => duckula.Event.MESSAGE(messagePayload)), + ).subscribe(e => { + // console.info('### duckula.Event.MESSAGE', e) + meetingInterpreter.send(e) + }) + + const listenMessage = awaitMessageWechaty(wechatyFixtures.wechaty) + + const SILK = await getSilkFixtures() + + const FIXTURES = { + room: wechatyFixtures.groupRoom, + chairs: [ + wechatyFixtures.player, + wechatyFixtures.mike, + ], + members: [ + wechatyFixtures.mary, + wechatyFixtures.mike, + wechatyFixtures.player, + wechatyFixtures.bot, + ], + brainstorming: { + mary : [ "mary's brainstorming", "mary's brainstorming" ], + mike : [ 'im mike', 'im mike' ], + player : [ SILK.fileBox, SILK.text ], + bot : [ 'im bot', 'im bot' ], + }, + } as const + + /** + * Send CONTACTS event + */ + testEventList.length = 0 + meetingEventList.length = 0 + meetingInterpreter.send( + duckula.Event.CONTACTS( + FIXTURES.members + .map(c => c.payload) + .filter(isDefined), + ), + ) + // console.info(snapshot.history) + t.same(meetingEventList.map(e => e.type), [ + duckula.Type.CONTACTS, + ], 'should get CONTACTS event') + t.equal(meetingState(), duckula.State.Idle, 'should be state idle') + t.same(Object.values(meetingContext().attendees).map(c => c.id), FIXTURES.members.map(c => c.id), 'should get context contacts list') + + /** + * Send MESSAGE event: Mary + */ + meetingEventList.length = 0 + await listenMessage(() => + mockerFixtures.mary.say(FIXTURES.brainstorming.mary[0]).to(mockerFixtures.groupRoom), + ) + t.same( + meetingEventList + .map(e => e.type) + .filter(e => e !== Mailbox.Type.ACTOR_IDLE), + [ duckula.Type.MESSAGE ], + 'should get MESSAGE event', + ) + t.same(meetingState(), duckula.State.Textualizing, 'should be back to state Textualizing after received a text message') + await sandbox.clock.runAllAsync() + t.same(meetingContext().feedbacks, { + [wechatyFixtures.mary.id]: FIXTURES.brainstorming.mary[1], + }, 'should have feedback from mary') + t.equal(Object.keys(meetingContext().feedbacks).length, 1, 'should have 1 feedback so far') + + /** + * Mike + */ + await listenMessage(() => mockerFixtures.mike.say(FIXTURES.brainstorming.mike[0]).to(mockerFixtures.groupRoom)) + await sandbox.clock.runAllAsync() + // console.info((snapshot.event.payload as any).message) + t.same( + meetingState(), + duckula.State.Idle, + 'should be back to state active.idle after received a text message', + ) + t.same(meetingContext().feedbacks, { + [wechatyFixtures.mary.id]: FIXTURES.brainstorming.mary[1], + [wechatyFixtures.mike.id]: FIXTURES.brainstorming.mike[1], + }, 'should have feedback from 2 users') + t.equal(Object.keys(meetingContext().feedbacks).length, 2, 'should have 2 feedback so far') + + /** + * Bot + */ + await listenMessage(() => mockerFixtures.bot.say(FIXTURES.brainstorming.bot[0]).to(mockerFixtures.groupRoom)) + await sandbox.clock.runAllAsync() + t.same(meetingContext().feedbacks, { + [wechatyFixtures.mary.id]: FIXTURES.brainstorming.mary[1], + [wechatyFixtures.mike.id]: FIXTURES.brainstorming.mike[1], + [wechatyFixtures.bot.id]: FIXTURES.brainstorming.bot[1], + }, 'should have feedback from 3 users including bot') + t.equal(Object.keys(meetingContext().feedbacks).length, 3, 'should have 3 feedback so far') + + /** + * Player + */ + meetingEventList.length = 0 + await listenMessage(() => mockerFixtures.player.say(FIXTURES.brainstorming.player[0]).to(mockerFixtures.groupRoom)) + t.same( + meetingEventList + .map(e => e.type) + .filter(e => e !== Mailbox.Type.ACTOR_IDLE), + [ duckula.Type.MESSAGE ], + 'should get MESSAGE event', + ) + t.equal(meetingState(), duckula.State.Textualizing, 'should in state Textualizing after received audio message') + + /** + * Wait for State.Idle of feedback + */ + const future = firstValueFrom( + from(meetingInterpreter as any).pipe( + // tap((x: any) => console.info('> feedback:', [ + // `(${x.history?.value || ''})`, + // ' + ', + // `[${x.event.type}]`, + // ' = ', + // `(${x.value})`, + // ].join(''))), + filter((s: any) => s.value === duckula.State.Idle), + ), + ) + /** + * Huan(202204): even after `sandbox.restore()`, `runAllAsync()` is still require below, + * or the `await future` will never resolved. + * + * Maybe because this `future` is created before `restore()`? + */ + sandbox.restore() + await sandbox.clock.runAllAsync() + await future + + t.equal(meetingState(), duckula.State.Idle, 'should in state idle after resolve stt message') + t.same(meetingContext().feedbacks, { + [wechatyFixtures.mary.id] : FIXTURES.brainstorming.mary[1], + [wechatyFixtures.bot.id] : FIXTURES.brainstorming.bot[1], + [wechatyFixtures.mike.id] : FIXTURES.brainstorming.mike[1], + [wechatyFixtures.player.id] : FIXTURES.brainstorming.player[1], + }, 'should have feedback from all users in feedback context') + t.equal(Object.keys(meetingContext().feedbacks).length, 4, 'should have all 4 feedbacks in feedback context') + + /** + * Huan(202201): must use setTimeout instead of setImmediate to make sure the following test pass + * it seems that the setImmediate is microtask (however the internet said that it should be a macrotask), + * and the setTimeout is macrotask? + * + * additional note: if we use `await sandbox.clock.runAllAsync()`, it has to be ran twice. + * (the `setImmediate` need to be ran twice too) + * + * TODO: why? + */ + await new Promise(resolve => setTimeout(resolve, 0)) + // await new Promise(setImmediate) + + t.same( + testEventList + .filter(isActionOf(Mailbox.Event.ACTOR_REPLY)) + .map(e => e.payload.message), + [ + duckula.Event.FEEDBACKS({ + [wechatyFixtures.mary.id] : FIXTURES.brainstorming.mary[1], + [wechatyFixtures.bot.id] : FIXTURES.brainstorming.bot[1], + [wechatyFixtures.mike.id] : FIXTURES.brainstorming.mike[1], + [wechatyFixtures.player.id] : FIXTURES.brainstorming.player[1], + }), + ], + 'should get feedback EVENT from consumer machine', + ) + + testInterpreter.stop() + } +}) + +test('feedback actor smoke testing', async t => { + for await (const WECHATY_FIXTURES of bot5Fixtures()) { + const { + mocker, + wechaty: wechatyFixtures, + } = WECHATY_FIXTURES + + const bus$ = CQRS.from(wechatyFixtures.wechaty) + const wechatyActor = WechatyActor.from(bus$, wechatyFixtures.wechaty.puppet.id) + + const feedbackMachine = machine.withContext({ + ...duckula.initialContext(), + actors: { + notice : String(Mailbox.nil.address), + register : String(Mailbox.nil.address), + wechaty : String(wechatyActor.address), + }, + }) + + const feedbackActor = Mailbox.from(feedbackMachine) + feedbackActor.open() + + const testMachine = createMachine({ + id: 'TestMachine', + on: { + '*': { + actions: Mailbox.actions.proxy('TestMachine')(feedbackActor), + }, + }, + }) + + const eventList: AnyEventObject[] = [] + + const interpreter = interpret(testMachine) + .onEvent(e => eventList.push(e)) + .start() + + bus$.pipe( + // tap(e => console.info('### bus$', e)), + filter(CQRS.is(CQRS.events.MessageReceivedEvent)), + map(e => CQRS.queries.GetMessagePayloadQuery(wechatyFixtures.wechaty.puppet.id, e.payload.messageId)), + mergeMap(CQRS.execute$(bus$)), + map(response => response.payload.message), + filter(isDefined), + map(messagePayload => duckula.Event.MESSAGE(messagePayload)), + ).subscribe(e => { + // console.info('### duckula.Event.MESSAGE', e) + interpreter.send(e) + }) + + ;(feedbackActor as Mailbox.impls.Mailbox).internal.actor.interpreter?.subscribe(s => console.info('>>> feedback:', [ + `(${s.history?.value || ''})`.padEnd(30, ' '), + ' + ', + `[${s.event.type}]`.padEnd(30, ' '), + ' = ', + `(${s.value})`.padEnd(30, ' '), + ].join(''))) + + const listenMessage = awaitMessageWechaty(wechatyFixtures.wechaty) + + const MEMBER_LIST = (await wechatyFixtures.groupRoom.memberAll()) + .map(m => m.payload) + .filter(isDefined) + + // console.info('MEMBER_LIST', MEMBER_LIST) + + const SILK = await getSilkFixtures() + + const FIXTURES = { + members: MEMBER_LIST, + feedbacks: { + mary : [ 'im mary', 'im mary' ], + mike : [ 'im mike', 'im mike' ], + player : [ SILK.fileBox, SILK.text ], + bot : [ 'im bot', 'im bot' ], + }, + } as const + + /** + * Send initial message to start the feedback + */ + eventList.length = 0 + ;[ + duckula.Event.CONTACTS(MEMBER_LIST), + ].forEach(e => interpreter.send(e)) + + t.same( + eventList + .filter(e => !Mailbox.helpers.isMailboxType(e.type)) + .map(e => e.type), + [ + duckula.Type.CONTACTS, + ], + 'should get CONTACTS event', + ) + + for (const [ user, [ sayable ] ] of Object.entries(FIXTURES.feedbacks)) { + eventList.length = 0 + /** + * Send MESSAGE event + */ + await listenMessage(() => mocker[user as keyof typeof FIXTURES.feedbacks] + .say(sayable) + .to(mocker.groupRoom)) + + t.same( + eventList.map(e => e.type), + [ duckula.Type.MESSAGE ], + `should get message events from ${user}`, + ) + } + + await firstValueFrom( + from(interpreter).pipe( + // tap(x => console.info('tap event:', x.event.type)), + filter(s => s.event.type === duckula.Type.FEEDBACKS), + ), + ) + // eventList.forEach(e => console.info(e)) + const EXPECTED_FEEDBACKS = Object.entries(FIXTURES.feedbacks) + .reduce( + (acc, cur) => { + const contact = mocker[cur[0] as keyof typeof mocker] + const text = cur[1][1] + return { + ...acc, + [contact.id]: text, + } + }, + {} as { + [key in keyof typeof FIXTURES.feedbacks]: string + }, + ) + + // console.info('EXPECTED_FEEDBACKS', EXPECTED_FEEDBACKS) + t.same( + eventList.filter(isActionOf(duckula.Event.FEEDBACKS)), + [ + duckula.Event.FEEDBACKS(EXPECTED_FEEDBACKS), + ], + 'should get FEEDBACKS event', + ) + + await listenMessage(() => mocker.mary.say(FIXTURES.feedbacks.player[0]).to(mocker.groupRoom)) + eventList.length = 0 + await firstValueFrom( + from(interpreter).pipe( + // tap(x => console.info('tap event:', x.event.type)), + filter(s => s.event.type === duckula.Type.FEEDBACKS), + ), + ) + t.same( + eventList, + [ + duckula.Event.FEEDBACKS({ + ...EXPECTED_FEEDBACKS, + [mocker.mary.id] : FIXTURES.feedbacks.player[1], + }), + ], + 'should get FEEDBACKS event immediately after mary sent feedback of player once again', + ) + + interpreter.stop() + } +}) diff --git a/src/domain-actors/meeting/machine.ts b/src/domain-actors/meeting/machine.ts new file mode 100644 index 0000000..dede70a --- /dev/null +++ b/src/domain-actors/meeting/machine.ts @@ -0,0 +1,592 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +/* eslint-disable sort-keys */ +/** + * Finite State Machine for BOT Friday Club Meeting + * @link https://github.com/wechaty/bot5-assistant + */ +import { createMachine, actions, EventObject } from 'xstate' +import * as Mailbox from 'mailbox' + +import * as duck from '../../duck/mod.js' + +import * as Notice from '../notice/mod.js' +import * as Register from '../register/mod.js' +import * as Brainstorming from '../brainstorming/mod.js' + +import duckula, { Context, Event, Events } from './duckula.js' +import * as reactions from './reactions.js' +import { Registering } from '../../duck/states/other-states.js' + +const machine = createMachine({ + id: duckula.id, + + /** + * Huan(202204): global events must be private / internal + * or it will block the Mailbox actor queue + * + * IMPORTANT: Put all Actor Request events inside the State.Idle. + */ + on: { + [Notice.Type.NOTICE]: { + actions: actions.forwardTo(ctx => ctx.actors.notice), + }, + }, + + initial: duckula.State.Initializing, + states: { + [duckula.State.Initializing]: { + entry: [ + actions.log(ctx => `states.Initializing.entry context ${JSON.stringify(ctx)}`, duckula.id), + actions.send(Notice.Event.NOTICE('初始化中……')), + ], + after: { 500: duckula.State.Initialized }, + }, + [duckula.State.Initialized]: { + entry: actions.send(Notice.Event.NOTICE('初始化完成。')), + after: { 500: duckula.State.Idle }, + }, + [duckula.State.Resetting]: { + entry: [ + actions.log('states.Resetting.entry', duckula.id), + actions.send(Notice.Event.NOTICE('重置中……')), + actions.assign(_ => duckula.initialContext()), + actions.send(duckula.Event.RESET(duckula.id), { to: ctx => ctx.actors.feedback }), + actions.send(duckula.Event.RESET(duckula.id), { to: ctx => ctx.actors.brainstorming }), + ], + after: { 500: duckula.State.Resetted }, + }, + [duckula.State.Resetted]: { + entry: actions.send(Notice.Event.NOTICE('重置完成。')), + after: { 500: duckula.State.Initializing }, + }, + + /** + * + * Idle + * + * Config: + * 1. received ROOM -> send CONVERSATION to NoticingActor + * 2. received CHAIRS -> assign context.chairs + * 3. received ATTENDEES -> assign context.attendees + * 4. received RESET -> transition to Resetting + * + * Requests: + * 1. received START -> transition to Meeting + * + */ + [duckula.State.Idle]: { + entry: [ + actions.log('states.Idle.entry', duckula.id), + Mailbox.actions.idle(duckula.id), + ], + on: { + '*': duckula.State.Idle, // enforce external transision + [duckula.Type.RESET]: { + target: duckula.State.Resetting, + }, + [duckula.Type.START]: duckula.State.Starting, + }, + }, + + [duckula.State.Starting]: { + entry: [ + actions.send(Notice.Event.NOTICE('开始会议……')), + ], + always: duckula.State.Started, + }, + + [duckula.State.Started]: { + entry: [ + actions.send(Notice.Event.NOTICE('会议开始。')), + ], + always: duckula.State.Meeting, + }, + + /** + * Main meeting loop + */ + [duckula.State.Meeting]: { + + }, + + [duckula.State.Registering]: { + entry: [ + actions.log('states.Registering.entry', duckula.id), + ], + invoke: { + id: Register.id, + src: ctx => Mailbox.wrap(Register.machine.withContext({ + ...Register.initialContext(), + // admins: ctx.admins, + chairs: ctx.chairs, + attendees: ctx.attendees, + actors: { + notice: ctx.actors.notice, + wechaty: ctx.actors.wechaty, + }, + })), + }, + on: { + /** + * Forward MESSAGE to Register Actor + */ + [duckula.Type.MESSAGE]: { + actions: [ + actions.send((_, e) => e, { to: Register.id }), + ], + }, + /** + * Unwrap BATCH from Register Actor + */ + [Register.Type.BATCH]: { + actions: [ + actions.pure( + (_, batchEvent) => (batchEvent.payload.events as EventObject[]) + .map(singleEvent => actions.send(singleEvent)), + ), + actions.send(duckula.Event.NEXT()), + ], + }, + /** + * Saving response to context + */ + [Register.Type.ATTENDEES]: { + actions: [ + actions.assign({ + attendees: (_, e) => e.payload.contacts.reduce((acc, contact) => ({ ...acc, [contact.id]: contact }), {}), + }), + ], + }, + [Register.Type.CHAIRS]: { + actions: [ + actions.assign({ + chairs: (_, e) => e.payload.contacts.reduce((acc, contact) => ({ ...acc, [contact.id]: contact }), {}), + }), + ], + }, + [Register.Type.TALKS]: { + actions: [ + actions.assign({ + talks: (_, e) => e.payload.talks, + }), + ], + }, + [Register.Type.GERROR]: duckula.State.Erroring, + [duckula.Type.NEXT]: duckula.State.Processing, + }, + }, + + /** + * + * BOT Friday Club - Chair Manual + * @link http://bot5.ml/manuals/chair/ + * + * Main loop of the meeting bot + */ + [duckula.State.Processing]: { + }, + + /** + * 0. + */ + [duckula.State.Announcing]: { + entry: [ + actions.send(Notice.Event.NOTICE([ + ` +Bot Friday (as known as BOT5) is a CLUB for chatbot builders and entrepreneurs with all the topics about the chatbot. +BOT Friday Club 是一个技术极客讨论聊天机器人行业落地和商业应用的创业论坛。 + +Our members are coming from: + +- Developers +- Entrepreneurs +- Giant company product managers + +The topic is all about: + +- Technology +- Ecosystem +- Business + +We have meetups every week, on Friday night. + +Learn more about BOT Friday Club: https://bot5.ml/ + `, + '【会议系统】本周 BOT Friday Club 活动通知:', + '公布时间地点分享人和主题', + 'tbw', + ].join(''))), + ], + always: duckula.State.Retrospecting, + }, + + [duckula.State.Checkining]: { + + }, + + [duckula.State.Starting]: { + + }, + + [duckula.State.Retrospecting]: { + entry: [ + actions.send(Notice.Event.NOTICE([ + '进入新环节:由轮值主席做最后一次活动回顾', + '下一个环节:新人自我介绍', + ].join(''))), + ], + on: { + [duckula.Type.MESSAGE]: { + actions: reactions.messageToIntents, + }, + [duckula.Type.INTENTS]: { + actions: [ + reactions.nextIntentToNext, + ], + }, + [duckula.Type.NEXT]: duckula.State.Joining, + }, + }, + + [duckula.State.Joining]: { + entry: [ + actions.send(Notice.Event.NOTICE([ + '【会议系统】', + '正在进行:新人入群', + '即将进行:新人自我介绍', + '-------', + '环节说明:将新人邀请进入 “Bot Friday Open Form - BFOF” 微信群。(邀请人负责邀请。如果邀请人不在现场则由主席一人负责)', + '环节结束标志:所有新人完成加入微信群', + '人工反馈:请主席确认所有新人已经入群完成后,输入“/next”,进入下一个环节。', + ].join(''))), + ], + exit: [ + actions.send(Notice.Event.NOTICE([ + '【会议系统】', + '已经完成:新人入群', + '即将开始:新人自我介绍', + ].join(''))), + ], + on: { + [duckula.Type.MESSAGE]: { + actions: [ + reactions.chairMessageToIntents, + ], + }, + [duckula.Type.INTENTS]: { + actions: [ + reactions.nextIntentToNext, + ], + }, + [duckula.Type.NEXT]: duckula.State.Introducing, + }, + }, + + [duckula.State.Introducing]: { + entry: [ + actions.send(Notice.Event.NOTICE([ + '【会议系统】', + '当前环节:新人自我介绍', + '即将进行:活动成员注册', + '-------', + '环节说明:通过微信语音发布在微信群中,1 MIN', + '环节结束标志:所有新人完成自我介绍语音发送', + '人工反馈:请主席确认所有新人已经介绍完成后,输入“/next”,进入下一个环节。', + ].join('\n'))), + ], + exit: [ + actions.send(Notice.Event.NOTICE([ + '【会议系统】', + '已经完成:新人自我介绍', + '即将开始:活动成员注册', + ].join(''))), + ], + on: { + [duckula.Type.MESSAGE]: { + actions: [ + reactions.chairMessageToIntents, + ], + }, + [duckula.Type.INTENTS]: { + actions: [ + reactions.nextIntentToNext, + ], + }, + [duckula.Type.NEXT]: duckula.State.Registering, + }, + }, + + [duckula.State.Registering]: { + entry: [ + actions.send(Register.Event.REPORT(), { to: ctx => ctx.actors.register }), + actions.send(Notice.Event.NOTICE([ + '【会议系统】', + '当前模块:活动成员注册', + '下一模块:主题分享', + '--------', + '未来的注册将结合GitHub评论回复报名', + ].join(''))), + ], + exit: [ + actions.send(Notice.Event.NOTICE([ + '【会议系统】', + '已完成当前模块:活动成员注册', + '准备进入下一模块:主题分享', + ].join(''))), + ], + on: { + [duckula.Type.MESSAGE]: { + actions: actions.send((_, e) => e, { to: ctx => ctx.actors.register }), + }, + [duckula.Type.CONTACTS]: { + actions: [ + actions.log((_, e) => `duckula.State.registering.on.CONTACTS ${e.payload.contacts.join(',')}`, duckula.id), + actions.assign({ + attendees: (_, e) => e.payload.contacts.reduce((acc, cur) => ({ ...acc, [cur.id]: cur }), {}), + }), + actions.send(duckula.Event.NEXT()), + ], + }, + [duckula.Type.BACK]: duckula.State.Introducing, + [duckula.Type.NEXT]: duckula.State.Presenting, + }, + }, + + [duckula.State.Presenting]: { + entry: [ + actions.send(Notice.Event.NOTICE([ + '【会议系统】', + '当前模块:主题分享', + '后续模块:会员升级', + '-------', + '模块说明:展开本次活动内容(主席可根据情况酌情修改):分享者 (<30min, 不可以超过 45 mins,超时后每一分钟需要发¥10红包到会员群)', + '环节结束标志:所有分享者完成分享后,主席输入“/next”可进入下一个环节。', + ].join(''))), + ], + on: { + [duckula.Type.MESSAGE]: { + actions: [ + reactions.chairMessageToIntents, + ], + }, + [duckula.Type.INTENTS]: { + actions: [ + reactions.nextIntentToNext, + ], + }, + [duckula.Type.BACK]: duckula.State.Registering, + [duckula.Type.NEXT]: duckula.State.Registering, + }, + }, + + [duckula.State.Upgrading]: { + entry: [ + actions.send(Notice.Event.NOTICE([ + '【会议系统】', + '新人 -> 实习会员:第一次完成分享的新人,将升级为实习会员。由其邀请人负责将其加入 “Bot Friday Club - BOT5” 会员群。(如果邀请人不在,则由当期主席负责);', + '实习会员 -> 正式会员:参加了三次活动的实习会员(含三次),将有资格转为正式会员。转正要求:发送个人 Profile 页面的 Pull Request 至 https://bot5.ml/people/GITHUB_USERNAME/ 下。PR Merge 后正式成为 BOT5 会员;', + '正式会员 -> 实习主席:正式会员可以被提名成为主席候选人。主席候选人被选举成功之后,成为实习主席;', + '实习主席 -> 主席:将完成了第一次轮值主席工作的实习主席,加入 Github Team: chairs,并在 team 中授予 maintainer 权限,便于未来升级其他主席。', + ].join(''))), + ], + always: duckula.State.Brainstorming, + }, + + [duckula.State.Brainstorming]: { + entry: [ + actions.send(Notice.Event.NOTICE([ + '【会议系统】脑洞拓展:', + '正在进行:头脑风暴', + '准备进行:主席任命', + '分享自己在本次活动上想到的新的好点子(1 MIN per person)', + '不讨论(讨论留到After Party)', + ].join(''))), + actions.send(Brainstorming.Event.REPORT(), { to: ctx => ctx.actors.brainstorming }), + ], + exit: [ + actions.send(Notice.Event.NOTICE([ + '【会议系统】', + '已经完成:头脑风暴', + '即将开始:主席任命', + ].join(''))), + ], + on: { + [duckula.Type.MESSAGE]: { + actions: actions.send((_, e) => e, { to: ctx => ctx.actors.brainstorming }), + }, + [duckula.Type.FEEDBACKS]: { + actions: [ + actions.log((_, e) => `duckula.State.brainstorming.on.FEEDBACKS total/${Object.values(e.payload.feedbacks).length}`, duckula.id), + actions.assign({ + brainstorms: (_, e) => e.payload.feedbacks, + }), + actions.send(duckula.Event.NEXT()), + ], + }, + [duckula.Type.BACK]: duckula.State.Upgrading, + [duckula.Type.NEXT]: duckula.State.Electing, + }, + }, + + [duckula.State.Electing]: { + entry: [ + actions.send(Notice.Event.NOTICE([ + '【会议系统】选举主席:', + '选出下下任轮值主席、副主席人选,并举行“受蛋仪式”(主席和副主席不允许挂靠,副主席需要参加主席场次的活动)', + '将金色计时器移交给下任主席,并由下任主席负责妥善保管', + '将银色计时器移交给下任副主席,并由下任副主席负责妥善保管', + ].join(''))), + ], + always: duckula.State.Upgrading, + }, + + [duckula.State.Elected]: { + entry: [ + actions.send(Notice.Event.NOTICE([ + '【会议系统】本次活动轮值主席、下次轮值主席、下次轮值副主席合影', + ].join(''))), + ], + always: duckula.State.Roasting, + }, + + [duckula.State.ShootingChairs]: { + entry: [ + actions.send(Notice.Event.NOTICE([ + '【会议系统】合影', + '轮值主席,轮值副主席,和下期轮值副主席合影(原图经过脸盲助手发到会员群,并将带名字的照片,发布在活动纪要中)', + ].join(''))), + ], + always: duckula.State.Housekeeping, + }, + + [duckula.State.Roasting]: { + entry: [ + actions.send(Notice.Event.NOTICE([ + '【会议系统】吐槽环节尚未支持,请下次活动再试。(自动跳转到下一步)', + '参会人员每人至少指出一条如何在未来可以将活动办的更好的意见建议(1 MIN per person)', + '不讨论(讨论留到After Party)', + '主席负责记录', + ].join(''))), + ], + always: duckula.State.Summarizing, + }, + + [duckula.State.Summarizing]: { + entry: [ + actions.send(Notice.Event.NOTICE( + '【会议系统】summarizing 轮值主席发言,做活动总结', + )), + ], + always: duckula.State.Summarized, + }, + + [duckula.State.Pledging]: { + entry: [ + actions.send(Notice.Event.NOTICE( + '【会议系统】轮值副主席述职报告:陈述自己下周作为主席的主要工作内容', + )), + ], + always: duckula.State.ShootingChairs, + }, + + [duckula.State.ShootingAll]: { + entry: [ + actions.send(Notice.Event.NOTICE([ + '【会议系统】合影', + 'photoing 所有参会人员合影(原图经过脸盲助手发到会员群,并将带名字的照片,发布在活动纪要中)', + ].join(''))), + ], + always: duckula.State.Housekeeping, + }, + + [duckula.State.Housekeeping]: { + entry: [ + actions.send(Notice.Event.NOTICE([ + '【会议系统】场地复原', + '轮值主席组织大家将场地复原(桌椅、白板、设备等)', + ].join(''))), + ], + always: duckula.State.Chatting, + }, + + [duckula.State.Chatting]: { + entry: [ + actions.send(Notice.Event.NOTICE([ + '【会议系统】活动结束,自由交流', + '下一环节:After Party', + '(Drinking, AA)', + ].join(''))), + ], + always: duckula.State.Drinking, + }, + + [duckula.State.Drinking]: { + entry: [ + actions.send(Notice.Event.NOTICE( + '【会议系统】活动结束,自由交流', + )), + ], + always: duckula.State.Finishing, + }, + + [duckula.State.ShootingDrinkers]: { + entry: [ + actions.send(Notice.Event.NOTICE([ + 'After Party 合影', + '酒菜上齐之后第一时间合影啦!', + ].join(''))), + ], + always: duckula.State.Housekeeping, + }, + + [duckula.State.Paying]: { + entry: [ + actions.send(Notice.Event.NOTICE([ + 'After Party AA 付款', + ].join(''))), + ], + always: duckula.State.Housekeeping, + }, + + [duckula.State.Finishing]: { + entry: [ + actions.send(Notice.Event.NOTICE([ + '【会议系统】After Party结束,请美食主席把账单发到群里大家AA', + '感谢各位参与BOT Friday Club沙龙活动,大家下次再见!', + ].join(''))), + ], + always: duckula.State.Finished, + }, + + [duckula.State.Finished]: { + type: 'final', + entry: actions.send(ctx => Notice.Event.NOTICE( + '【会议系统】Huiyi Jieshu', + [ + ...Object.keys(ctx.attendees), + ...ctx.chairs.map(chair => chair.id), + ], + )), + }, + }, +}) + +export default machine diff --git a/src/domain-actors/meeting/mod.spec.ts b/src/domain-actors/meeting/mod.spec.ts new file mode 100755 index 0000000..82b2931 --- /dev/null +++ b/src/domain-actors/meeting/mod.spec.ts @@ -0,0 +1,30 @@ +#!/usr/bin/env -S node --no-warnings --loader ts-node/esm +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import { test } from 'tstest' + +import type { Duckula } from 'mailbox' + +import * as mod from './mod.js' + +test('mod is a Duckula', async t => { + const duckula: Duckula = mod + t.ok(duckula, 'should satisfy Duckula Interface for mod export') +}) diff --git a/src/domain-actors/meeting/mod.ts b/src/domain-actors/meeting/mod.ts new file mode 100644 index 0000000..a6d6c04 --- /dev/null +++ b/src/domain-actors/meeting/mod.ts @@ -0,0 +1,33 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import duckula from './duckula.js' +import machine from './machine.js' + +export const { + id, + Event, + State, + Type, + initialContext, +} = duckula + +export { + machine, +} diff --git a/src/domain-actors/meeting/reactions.ts b/src/domain-actors/meeting/reactions.ts new file mode 100644 index 0000000..99f376a --- /dev/null +++ b/src/domain-actors/meeting/reactions.ts @@ -0,0 +1,41 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +/* eslint-disable sort-keys */ +import { actions } from 'xstate' + +import { Intent } from '../../intents/mod.js' + +import duckula, { Context, Events } from './duckula.js' + +export const messageToIntents = actions.send((_, e) => e, { to: ctx => ctx.actors.intent }) + +export const chairMessageToIntents = actions.choose([ + { + cond: (ctx, e) => Object.keys(ctx.chairs).includes(e.payload.message.talkerId), + actions: messageToIntents, + }, +]) + +export const nextIntentToNext = actions.choose([ + { + cond: (_, e) => e.payload.intents.includes(Intent.Next), + actions: actions.send(duckula.Event.NEXT()), + }, +]) diff --git a/src/domain-actors/meeting/selectors.spec.ts b/src/domain-actors/meeting/selectors.spec.ts new file mode 100755 index 0000000..873bc5b --- /dev/null +++ b/src/domain-actors/meeting/selectors.spec.ts @@ -0,0 +1,49 @@ +#!/usr/bin/env -S node --no-warnings --loader ts-node/esm +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +/* eslint-disable sort-keys */ + +import { test } from 'tstest' + +import { bot5Fixtures } from '../../fixtures/bot5-fixture.js' + +import duckula from './duckula.js' +import * as selectors from './selectors.js' + +test('chair() & viceChairs', async t => { + for await (const { + wechaty: wechatyFixtures, + } of bot5Fixtures()) { + const context = duckula.initialContext() + t.equal(selectors.chair(context), undefined, 'should return undefined when context is empty') + + context.chairs = [ + wechatyFixtures.player.payload!, + wechatyFixtures.mary.payload!, + wechatyFixtures.mike.payload!, + ] + + t.same(selectors.chair(context), wechatyFixtures.player.payload!, 'should return first contact in the list for chair') + t.same(selectors.viceChairs(context), [ + wechatyFixtures.mary.payload!, + wechatyFixtures.mike.payload!, + ], 'should return the second and following contacts in the list for vice chairs') + } +}) diff --git a/src/domain-actors/meeting/selectors.ts b/src/domain-actors/meeting/selectors.ts new file mode 100644 index 0000000..3289b72 --- /dev/null +++ b/src/domain-actors/meeting/selectors.ts @@ -0,0 +1,23 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import type { Context } from './duckula.js' + +export const chair = (ctx: Context) => ctx.chairs[0] +export const viceChairs = (ctx: Context) => ctx.chairs.slice(1) diff --git a/src/domain-actors/mod.ts b/src/domain-actors/mod.ts new file mode 100644 index 0000000..cca1da5 --- /dev/null +++ b/src/domain-actors/mod.ts @@ -0,0 +1,7 @@ +export * as Feedback from './feedback/mod.js' +export * as Notice from './notice/mod.js' +export * as Register from './register/mod.js' +export * as Brainstorming from './brainstorming/mod.js' +export * as Meeting from './meeting/machine.js' + +export * as Assistant from './assistant-actor.js' diff --git a/src/domain-actors/notice/duckula.ts b/src/domain-actors/notice/duckula.ts new file mode 100644 index 0000000..6d9c64d --- /dev/null +++ b/src/domain-actors/notice/duckula.ts @@ -0,0 +1,54 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +/* eslint-disable sort-keys */ +import * as Mailbox from 'mailbox' + +import * as duck from '../../duck/mod.js' + +export interface Context { + conversation: string, + actors: { + wechaty: string, + }, +} + +const duckula = Mailbox.duckularize({ + id: 'Notice', + events: [ duck.Event, [ + /** + * Requests + */ + 'NOTICE', + 'CONVERSATION', + ] ], + states: [ duck.State, [ + 'Initializing', + 'Idle', + 'Busy', + ] ], + initialContext: {}, +}) + +export type Event = ReturnType +export type Events = { + [key in keyof typeof duckula.Event]: ReturnType +} + +export default duckula diff --git a/src/domain-actors/notice/machine.spec.ts b/src/domain-actors/notice/machine.spec.ts new file mode 100755 index 0000000..1e21fe7 --- /dev/null +++ b/src/domain-actors/notice/machine.spec.ts @@ -0,0 +1,129 @@ +#!/usr/bin/env -S node --no-warnings --loader ts-node/esm +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +/* eslint-disable sort-keys */ + +import { + AnyEventObject, + interpret, + createMachine, + Interpreter, +} from 'xstate' +import { test, sinon } from 'tstest' +import * as CQRS from 'wechaty-cqrs' +import * as Mailbox from 'mailbox' + +import * as WechatyActor from '../../wechaty-actor/mod.js' +import { bot5Fixtures } from '../../fixtures/bot5-fixture.js' + +import machine from './machine.js' +import duckula from './duckula.js' + +test('noticeActor smoke testing', async t => { + for await (const { + mocker: mockerFixtures, + wechaty: wechatyFixtures, + moList, + } of bot5Fixtures()) { + const sandbox = sinon.createSandbox({ + useFakeTimers: { now: Date.now() }, // for make TencentCloud API timestamp happy + }) + + const bus$ = CQRS.from(wechatyFixtures.wechaty) + + const wechatyMailbox = Mailbox.from( + WechatyActor.machine.withContext({ + ...WechatyActor.initialContext(), + bus$, + puppetId: wechatyFixtures.wechaty.puppet.id, + }), + ) + wechatyMailbox.open() + + const noticeMachine = machine.withContext({ + ...duckula.initialContext(), + actors: { + wechaty: String(wechatyMailbox.address), + }, + }) + + const CHILD_ID = 'testing-child-id' + const proxyMachine = createMachine({ + invoke: { + id: CHILD_ID, + src: noticeMachine, + }, + on: { + '*': { + actions: [ + Mailbox.actions.proxy('ProxyMachine')(CHILD_ID), + ], + }, + }, + }) + + const proxyEventList: AnyEventObject[] = [] + const proxyInterpreter = interpret(proxyMachine) + .onEvent(e => proxyEventList.push(e)) + .start() + + const noticeRef = proxyInterpreter.children.get(CHILD_ID) as Interpreter + const noticeContext = () => noticeRef.getSnapshot().context as ReturnType + + const noticeEventList: AnyEventObject[] = [] + noticeRef.subscribe(s => noticeEventList.push(s.event)) + + proxyInterpreter.send(duckula.Event.NOTICE('test')) + await sandbox.clock.runAllAsync() + t.equal(moList.length, 0, 'should no message send out before set conversationId') + + proxyInterpreter.send(duckula.Event.CONVERSATION(mockerFixtures.groupRoom.id)) + await sandbox.clock.runAllAsync() + t.same(noticeContext(), { + conversationId: mockerFixtures.groupRoom.id, + actors: { + wechaty: String(wechatyMailbox.address), + }, + }, 'should set conversation id after send event') + + const EXPECTED_TEXT = 'test' + proxyInterpreter.send(duckula.Event.NOTICE(EXPECTED_TEXT)) + await sandbox.clock.runAllAsync() + t.equal(moList.length, 1, 'should sent message after set conversationId') + t.equal(moList[0]!.room()!.id, mockerFixtures.groupRoom.id, 'should get room') + t.ok(moList[0]!.text().endsWith(EXPECTED_TEXT), 'should say EXPECTED_TEXT out') + + // moList.length = 0 + // proxyInterpreter.send( + // CQRS.commands.SendMessageCommand( + // CQRS.uuid.NIL, + // mockerFixtures.groupRoom.id, + // CQRS.sayables.text(EXPECTED_TEXT), + // ), + // ) + // await sandbox.clock.runAllAsync() + // t.equal(moList.length, 1, 'should compatible with WechatyAction events by forwarding them') + // t.equal(moList[0]!.room()!.id, mockerFixtures.groupRoom.id, 'should get room with wechaty actor event') + // t.ok(moList[0]!.text().endsWith(EXPECTED_TEXT), 'should say EXPECTED_TEXT out with wechaty actor event') + + proxyInterpreter.stop() + sandbox.restore() + } +}) diff --git a/src/domain-actors/notice/machine.ts b/src/domain-actors/notice/machine.ts new file mode 100644 index 0000000..1ffdc5d --- /dev/null +++ b/src/domain-actors/notice/machine.ts @@ -0,0 +1,97 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +/* eslint-disable sort-keys */ +/** + * Finite State Machine for BOT Friday Club Meeting + * @link https://github.com/wechaty/bot5-assistant + */ +import { createMachine, actions } from 'xstate' +import * as CQRS from 'wechaty-cqrs' +import * as Mailbox from 'mailbox' + +import duckula, { Context, Event, Events } from './duckula.js' + +const machine = createMachine< + Context, + Event +>({ + id: duckula.id, + initial: duckula.State.Initializing, + states: { + [duckula.State.Initializing]: { + entry: [ + actions.log(ctx => `states.Initializing.entry context ${JSON.stringify(ctx)}`, duckula.id), + ], + always: duckula.State.Idle, + }, + + [duckula.State.Idle]: { + entry: [ + Mailbox.actions.idle(duckula.id), + ], + on: { + '*': { + target: duckula.State.Idle, // enforce external transition + }, + [duckula.Type.NOTICE]: duckula.State.Busy, + [duckula.Type.CONVERSATION]: { + actions: [ + actions.log((_, e) => `states.Idle.on.CONVERSATION ${e.payload.id}`, duckula.id), + actions.assign({ + conversation: (_, e) => e.payload.id, + }), + ], + target: duckula.State.Idle, // enforce external transition + }, + }, + }, + + [duckula.State.Busy]: { + entry: [ + actions.log((_, e) => `states.Busy.entry NOTICE ${e.payload.text}`, duckula.id), + actions.choose([ + { + cond: ctx => !!ctx.conversation, + actions: [ + actions.send( + (ctx, e) => CQRS.commands.SendMessageCommand( + CQRS.uuid.NIL, + ctx.conversation!, + CQRS.sayables.text( + `【系统通知】${e.payload.text}`, + e.payload.mentions, + ), + ), + { to: ctx => ctx.actors.wechaty }, + ), + ], + }, + { + actions: actions.log('states.Busy.entry no conversationId', duckula.id), + }, + ]), + ], + always: duckula.State.Idle, + }, + + }, +}) + +export default machine diff --git a/src/domain-actors/notice/mod.spec.ts b/src/domain-actors/notice/mod.spec.ts new file mode 100755 index 0000000..82b2931 --- /dev/null +++ b/src/domain-actors/notice/mod.spec.ts @@ -0,0 +1,30 @@ +#!/usr/bin/env -S node --no-warnings --loader ts-node/esm +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import { test } from 'tstest' + +import type { Duckula } from 'mailbox' + +import * as mod from './mod.js' + +test('mod is a Duckula', async t => { + const duckula: Duckula = mod + t.ok(duckula, 'should satisfy Duckula Interface for mod export') +}) diff --git a/src/domain-actors/notice/mod.ts b/src/domain-actors/notice/mod.ts new file mode 100644 index 0000000..a6d6c04 --- /dev/null +++ b/src/domain-actors/notice/mod.ts @@ -0,0 +1,33 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import duckula from './duckula.js' +import machine from './machine.js' + +export const { + id, + Event, + State, + Type, + initialContext, +} = duckula + +export { + machine, +} diff --git a/src/domain-actors/register/duckula.ts b/src/domain-actors/register/duckula.ts new file mode 100644 index 0000000..f86c2fb --- /dev/null +++ b/src/domain-actors/register/duckula.ts @@ -0,0 +1,113 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +/* eslint-disable sort-keys */ +import type * as PUPPET from 'wechaty-puppet' +import * as Mailbox from 'mailbox' + +import * as duck from '../../duck/mod.js' + +export interface Context { + /** + * Required + */ + actors: { + wechaty : string + notice : string + } + /** + * To-be-filled + */ + attendees : { [contactId: string]: PUPPET.payloads.Contact } + chairs : { [contactId: string]: PUPPET.payloads.Contact } + talks : { [contactId: string]: string } + message? : PUPPET.payloads.Message +} + +const duckula = Mailbox.duckularize({ + id: 'Register', + events: [ duck.Event, [ + /** + * Request + */ + 'REPORT', + 'MESSAGE', + /** + * Response + */ + 'CHAIRS', + 'ATTENDEES', + 'TALKS', + 'GERROR', + /** + * Config + */ + 'RESET', + /** + * Internal + */ + 'BATCH', + 'HELP', + 'ROOM', + 'NO_ROOM', + 'MENTIONS', + 'NO_MENTION', + 'NEXT', + 'VALIDATE', + 'NOTICE', + 'INTENTS', + 'FINISH', + ] ], + states: [ duck.State, [ + 'Idle', + 'Busy', + 'Responding', + 'Erroring', + 'RegisteringRoom', + 'RegisteredRoom', + 'RegisteringChairs', + 'RegisteredChairs', + 'RegisteringAttendees', + 'RegisteredAttendees', + 'RegisteringTalks', + 'RegisteredTalks', + 'Confirming', + 'Initializing', + 'Initialized', + 'Loading', + 'Mentioning', + 'Reporting', + 'Resetting', + 'Resetted', + 'Summarizing', + ] ], + initialContext: { + attendees : {}, + chairs : {}, + talks : {}, + message : undefined, + }, +}) + +export type Event = ReturnType +export type Events = { + [key in keyof typeof duckula.Event]: ReturnType +} + +export default duckula diff --git a/src/domain-actors/register/machine.spec.ts b/src/domain-actors/register/machine.spec.ts new file mode 100755 index 0000000..c94a4d1 --- /dev/null +++ b/src/domain-actors/register/machine.spec.ts @@ -0,0 +1,536 @@ +#!/usr/bin/env -S node --no-warnings --loader ts-node/esm +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +/* eslint-disable sort-keys */ +import { + AnyEventObject, + interpret, + createMachine, + Interpreter, +} from 'xstate' +import { of } from 'rxjs' +import { map, mergeMap, filter } from 'rxjs/operators' +import { test, sinon } from 'tstest' +import * as Mailbox from 'mailbox' +import * as CQRS from 'wechaty-cqrs' +import { isActionOf } from 'typesafe-actions' + +import * as WechatyActor from '../../wechaty-actor/mod.js' +import { isDefined } from '../../pure-functions/is-defined.js' +import { bot5Fixtures } from '../../fixtures/bot5-fixture.js' +import { invokeId } from '../../actor-utils/invoke-id.js' + +import * as Notice from '../notice/mod.js' + +import duckula, { Context } from './duckula.js' +import machine from './machine.js' + +test('register machine smoke testing', async t => { + for await (const { + mocker: mockerFixture, + wechaty: wechatyFixture, + } of bot5Fixtures()) { + + const sandbox = sinon.createSandbox({ + useFakeTimers: true, + }) + + wechatyFixture.wechaty.on('message', msg => console.info('[Wechaty]', String(msg))) + + const bus$ = CQRS.from(wechatyFixture.wechaty) + const wechatyMailbox = WechatyActor.from(bus$, wechatyFixture.wechaty.puppet.id) + wechatyMailbox.open() + + const noticeMailbox = Mailbox.from(Notice.machine.withContext({ + ...Notice.initialContext(), + conversation: wechatyFixture.groupRoom.id, + actors: { + wechaty: String(wechatyMailbox.address), + }, + })) + noticeMailbox.open() + + const TEST_ID = 'Test' + + const testMachine = createMachine({ + id: TEST_ID, + invoke: { + id: invokeId(duckula.id, TEST_ID), + src: machine.withContext({ + ...duckula.initialContext(), + actors: { + wechaty: String(wechatyMailbox.address), + notice: String(noticeMailbox.address), + }, + }), + }, + on: { + '*': { + actions: Mailbox.actions.proxy(TEST_ID)(invokeId(duckula.id, TEST_ID)), + }, + }, + }) + + const testEventList: AnyEventObject[] = [] + const testInterpreter = interpret(testMachine) + testInterpreter + .onEvent(e => testEventList.push(e)) + .start() + + /** + * Skip self message + */ + of(CQRS.queries.GetCurrentUserIdQuery(wechatyFixture.wechaty.puppet.id)).pipe( + mergeMap(CQRS.execute$(bus$)), + map(response => response.payload.contactId), + mergeMap(currentUserId => bus$.pipe( + filter(CQRS.is(CQRS.events.MessageReceivedEvent)), + map(e => CQRS.queries.GetMessagePayloadQuery(wechatyFixture.wechaty.puppet.id, e.payload.messageId)), + mergeMap(CQRS.execute$(bus$)), + map(response => response.payload.message), + filter(isDefined), + filter(message => message.talkerId !== currentUserId), + map(messagePayload => duckula.Event.MESSAGE(messagePayload)), + )), + ).subscribe(e => { + console.info('### duckula.Event.MESSAGE', e) + testInterpreter.send(e) + }) + + const registerInterpreter = testInterpreter.children.get(invokeId(duckula.id, TEST_ID)) as Interpreter + const registerSnapshot = () => registerInterpreter.getSnapshot() + const registerContext = () => registerSnapshot().context as Context + const registerState = () => registerSnapshot().value as typeof duckula.State + + const registerEventList: AnyEventObject[] = [] + registerInterpreter.onTransition(state => { + console.info('### registerInterpreter.onTransition', state.value, state.event.type) + registerEventList.push(state.event) + }) + + t.equal(registerState(), duckula.State.Idle, 'should be idle state') + t.same(registerContext().attendees, [], 'should be empty attendees list') + + testInterpreter.send(duckula.Event.REPORT()) + await sandbox.clock.runAllAsync() + + t.equal(registerState(), duckula.State.RegisteringChairs, 'should be Stae.RegisteringChairs') + t.same(registerContext().chairs, {}, 'should have no chairs') + t.same(registerContext().attendees, {}, 'should have no attendees') + t.same(registerContext().talks, {}, 'should have no talks') + + t.same(registerEventList.map(e => e.type), [ + duckula.Type.NOTICE, + duckula.Type.REPORT, + duckula.Type.VALIDATE, + duckula.Type.HELP, + duckula.Type.NOTICE, + ], 'should receive bunch of events after send REPORT') + + /** + * Process a message without mention + */ + testEventList.length = 0 + registerEventList.length = 0 + mockerFixture.mary.say('register without mentions').to(mockerFixture.groupRoom) + await sandbox.clock.runAllAsync() + + t.equal(registerState(), duckula.State.RegisteringChairs, 'should be in State.RegisteringChairs') + t.same(registerEventList.map(e => e.type), [ + duckula.Type.MESSAGE, + duckula.Type.NO_MENTION, + duckula.Type.INTENTS, + ], 'should have bunch of events after received a message in the room without mention') + t.same(registerContext().chairs, {}, 'should have empty chairs') + + t.same(registerEventList.map(e => e.type), [ + duckula.Type.MESSAGE, + duckula.Type.NO_MENTION, + duckula.Type.INTENTS, + ], 'should has bunch of events after process the non-mention message in room') + t.same(registerContext().attendees, [], 'should have empty mentioned id list before onDone') + t.equal(registerState(), duckula.State.RegisteringChairs, 'should be in State.RegisteringChairs') + + /** + * Chair: process a message with mention to register + */ + testEventList.length = 0 + registerEventList.length = 0 + const CHAIR_MENTION_LIST = [ mockerFixture.player ] + + mockerFixture.mary.say('register chair with mentions', CHAIR_MENTION_LIST).to(mockerFixture.groupRoom) + await sandbox.clock.runAllAsync() + // console.info('mentionMessage:', mentionMessage.text()) + + t.equal(registerState(), duckula.State.RegisteringChairs, 'should be in State.RegisteringChairs') + t.same(registerEventList.map(e => e.type), [ + duckula.Type.MESSAGE, + duckula.Type.MENTIONS, + duckula.Type.NOTICE, + duckula.Type.INTENTS, + ], 'should got bunch of events after process the mention message in room for registering chair') + t.same(registerContext().chairs, { [mockerFixture.player.id]: mockerFixture.player.payload }, 'should have one chair') + + /** + * Vice Chair: process a message with mention to register + */ + testEventList.length = 0 + registerEventList.length = 0 + const VICE_CHAIR_MENTION_LIST = [ mockerFixture.mike ] + + mockerFixture.mary.say('register vice chair with mentions', VICE_CHAIR_MENTION_LIST).to(mockerFixture.groupRoom) + await sandbox.clock.runAllAsync() + // console.info('mentionMessage:', mentionMessage.text()) + + t.equal(registerState(), duckula.State.RegisteringChairs, 'should still be in State.RegisteringChairs') + t.same(registerEventList.map(e => e.type), [ + duckula.Type.MESSAGE, + duckula.Type.MENTIONS, + duckula.Type.NOTICE, + duckula.Type.INTENTS, + ], 'should got bunch of events after process the mention message in room for registering vice chair') + t.same(registerContext().chairs, { + [mockerFixture.player.id]: mockerFixture.player.payload, + [mockerFixture.mike.id]: mockerFixture.mike.payload, + }, 'should have two chair') + + /** + * Registered Chair: process a message with NEXT + */ + testEventList.length = 0 + registerEventList.length = 0 + mockerFixture.mary.say('/Next').to(mockerFixture.groupRoom) + await sandbox.clock.runAllAsync() + t.same(registerEventList.map(e => e.type), [ + duckula.Type.MESSAGE, + duckula.Type.NO_MENTION, + duckula.Type.INTENTS, + duckula.Type.VALIDATE, + duckula.Type.NOTICE, + duckula.Type.NEXT, + duckula.Type.VALIDATE, + duckula.Type.HELP, + duckula.Type.NOTICE, + ], 'should got bunch of events after process from registering chair to talks') + t.equal(registerState(), duckula.State.RegisteringTalks, 'should next to State.RegisteringTalks') + + /** + * Talks + */ + t.same(registerContext().talks, {}, 'should have no talks') + + /** + * Talk 1: process a message with mention to register talk + */ + testEventList.length = 0 + registerEventList.length = 0 + const TALKER1 = mockerFixture.player + const TALK1_TEXT = 'register talk1: topic... outlines... ' + mockerFixture.mary.say(TALK1_TEXT, [ TALKER1 ]).to(mockerFixture.groupRoom) + await sandbox.clock.runAllAsync() + + t.equal(registerState(), duckula.State.RegisteringTalks, 'should be in State.RegisteringTalks') + t.same(registerEventList.map(e => e.type), [ + duckula.Type.MESSAGE, + duckula.Type.MENTIONS, + duckula.Type.NOTICE, + duckula.Type.INTENTS, + ], 'should got bunch of events after process the mention message in room for registering talk1') + t.same(registerContext().talks, { [mockerFixture.player.id]: TALK1_TEXT }, 'should have one talk') + + /** + * Talk 2: process a message with mention to register + */ + testEventList.length = 0 + registerEventList.length = 0 + const TALKER2 = mockerFixture.mike + const TALK2_TEXT = 'register talk2: topic ... outlines ...' + mockerFixture.mary.say(TALK2_TEXT, [ TALKER2 ]).to(mockerFixture.groupRoom) + await sandbox.clock.runAllAsync() + + t.equal(registerState(), duckula.State.RegisteringTalks, 'should still be in State.RegisteringTalks') + t.same(registerEventList.map(e => e.type), [ + duckula.Type.MESSAGE, + duckula.Type.MENTIONS, + duckula.Type.NOTICE, + duckula.Type.INTENTS, + ], 'should got bunch of events after process the mention message in room for registering talk2') + t.same(registerContext().talks, { + [mockerFixture.player.id]: TALK1_TEXT, + [mockerFixture.mike.id]: TALK2_TEXT, + }, 'should have two talks') + + /** + * Registered Talks: process a message with NEXT + */ + testEventList.length = 0 + registerEventList.length = 0 + mockerFixture.mary.say('/Next').to(mockerFixture.groupRoom) + await sandbox.clock.runAllAsync() + t.same(registerEventList.map(e => e.type), [ + duckula.Type.MESSAGE, + duckula.Type.NO_MENTION, + duckula.Type.INTENTS, + duckula.Type.VALIDATE, + duckula.Type.NOTICE, + duckula.Type.NEXT, + duckula.Type.VALIDATE, + duckula.Type.HELP, + duckula.Type.NOTICE, + ], 'should got bunch of events after process from registering talks to attendees') + t.equal(registerState(), duckula.State.RegisteringAttendees, 'should next to State.RegisteringAttendees') + + /** + * Attendees: register attendees by mention them + */ + testEventList.length = 0 + registerEventList.length = 0 + mockerFixture.mary.say('register without mentions').to(mockerFixture.groupRoom) + await sandbox.clock.runAllAsync() + + t.equal(registerState(), duckula.State.RegisteringAttendees, 'should be in State.RegisteringAttendees') + t.same(registerEventList.map(e => e.type), [ + duckula.Type.MESSAGE, + duckula.Type.NO_MENTION, + duckula.Type.HELP, + duckula.Type.NOTICE, + duckula.Type.INTENTS, + ], 'should have bunch of events after received a register attendee message in the room without mention') + t.same(registerContext().attendees, {}, 'should have empty attendees') + + /** + * Attendee 1: process a message with mention to register + */ + testEventList.length = 0 + registerEventList.length = 0 + const ATTENDEE1_MENTION_LIST = [ mockerFixture.mike ] + + mockerFixture.mary.say('register mike with mentions', ATTENDEE1_MENTION_LIST).to(mockerFixture.groupRoom) + await sandbox.clock.runAllAsync() + + t.same(registerEventList.map(e => e.type), [ + duckula.Type.MESSAGE, + duckula.Type.MENTIONS, + duckula.Type.NOTICE, + duckula.Type.INTENTS, + ], 'should got bunch of events after process the mention message in room for registering attendee 1') + t.same(registerContext().attendees, { [mockerFixture.mike.id]: mockerFixture.mike.payload }, 'should have one attendee mike') + + /** + * Attendee 2: process a message with mention to register + */ + testEventList.length = 0 + registerEventList.length = 0 + const ATTENDEE2_MENTION_LIST = [ mockerFixture.mary ] + + mockerFixture.player.say('register mary with mentions', ATTENDEE2_MENTION_LIST).to(mockerFixture.groupRoom) + await sandbox.clock.runAllAsync() + + t.same(registerEventList.map(e => e.type), [ + duckula.Type.MESSAGE, + duckula.Type.MENTIONS, + duckula.Type.NOTICE, + duckula.Type.INTENTS, + ], 'should got bunch of events after process the mention message in room for registering attendee 2') + t.same(registerContext().attendees, { + [mockerFixture.mary.id]: mockerFixture.mary.payload, + [mockerFixture.mike.id]: mockerFixture.mike.payload, + }, 'should have two attendees') + + /** + * Registered Attendees: process a message with NEXT + */ + testEventList.length = 0 + registerEventList.length = 0 + mockerFixture.mary.say('/Next').to(mockerFixture.groupRoom) + await sandbox.clock.runAllAsync() + t.same(registerEventList.map(e => e.type), [ + duckula.Type.MESSAGE, + duckula.Type.NO_MENTION, + duckula.Type.HELP, + duckula.Type.NOTICE, + duckula.Type.INTENTS, + duckula.Type.VALIDATE, + duckula.Type.NOTICE, + duckula.Type.NEXT, + duckula.Type.NOTICE, + duckula.Type.NEXT, + duckula.Type.BATCH, + ], 'should got bunch of events after /Next') + t.equal(registerState(), duckula.State.Idle, 'should next to State.Idle') + + /** + * Response CHAIRS, ATTENDEES, TALKS + */ + testEventList.forEach(e => console.info(e.type, JSON.stringify(e.payload))) + + t.same( + testEventList + .filter(isActionOf(Mailbox.Event.ACTOR_REPLY)) + .map(e => (e as any).payload.message) + .filter(isActionOf(duckula.Event.BATCH)), + [ + duckula.Event.BATCH([ + duckula.Event.CHAIRS([ + mockerFixture.player.payload, + mockerFixture.mike.payload, + ]), + duckula.Event.TALKS({ + [TALKER1.id]: TALK1_TEXT, + [TALKER2.id]: TALK2_TEXT, + }), + duckula.Event.ATTENDEES([ + mockerFixture.mike.payload, + mockerFixture.mary.payload, + ]), + ]), + ], + 'should get response BATCH([CHAIRS, TALKS, ATTENDEES])', + ) + + testInterpreter.stop() + sandbox.restore() + } +}) + +test.only('register actor smoke testing', async t => { + for await (const fixture of bot5Fixtures()) { + + const sandbox = sinon.createSandbox({ + useFakeTimers: true, + }) + + const bus$ = CQRS.from(fixture.wechaty.wechaty) + const wechatyMailbox = WechatyActor.from(bus$, fixture.wechaty.wechaty.puppet.id) + wechatyMailbox.open() + + const noticeMailbox = Mailbox.from(Notice.machine.withContext({ + ...Notice.initialContext(), + conversation: fixture.mocker.groupRoom.id, + actors: { + wechaty: String(wechatyMailbox.address), + }, + })) + noticeMailbox.open() + + const mailbox = Mailbox.from(machine.withContext({ + ...duckula.initialContext(), + actors: { + notice: String(noticeMailbox.address), + wechaty: String(wechatyMailbox.address), + }, + })) + mailbox.open() + + const TEST_ID = 'Test' + const testMachine = createMachine({ + id: TEST_ID, + on: { + '*': { + actions: Mailbox.actions.proxy(TEST_ID)(mailbox), + }, + }, + }) + + const eventList: AnyEventObject[] = [] + const testInterpreter = interpret(testMachine) + .onEvent(e => eventList.push(e)) + .start() + + /** + * Skip self message + */ + of(CQRS.queries.GetCurrentUserIdQuery(fixture.wechaty.wechaty.puppet.id)).pipe( + mergeMap(CQRS.execute$(bus$)), + map(response => response.payload.contactId), + mergeMap(currentUserId => bus$.pipe( + filter(CQRS.is(CQRS.events.MessageReceivedEvent)), + map(e => CQRS.queries.GetMessagePayloadQuery(fixture.wechaty.wechaty.puppet.id, e.payload.messageId)), + mergeMap(CQRS.execute$(bus$)), + map(response => response.payload.message), + filter(isDefined), + filter(message => message.talkerId !== currentUserId), + map(messagePayload => duckula.Event.MESSAGE(messagePayload)), + )), + ).subscribe(e => { + // console.info('### duckula.Event.MESSAGE', e) + testInterpreter.send(e) + }) + + fixture.wechaty.wechaty.on('message', msg => console.info(String(msg))) + ;(mailbox as Mailbox.impls.Mailbox).internal.actor.interpreter!.onTransition(s => { + console.info('______________________________') + console.info(`Actor: (${s.history?.value}) + [${s.event.type}] = (${s.value})`) + console.info('-------------------------') + }) + // ;(mailbox as Mailbox.impls.Mailbox).internal.interpreter!.onTransition(s => { + // console.info('______________________________') + // console.info(`Mailbox: (${s.history?.value}) + [${s.event.type}] = (${s.value})`) + // console.info('-------------------------') + // }) + // ;(wechatyMailbox as Mailbox.impls.Mailbox).internal.actor.interpreter!.onTransition(s => { + // console.info('______________________________') + // // console.info(`Wechaty: (${(s.history?.value as any).child}) + [${s.event.type}] = (${(s.value as any).child}})`) + // console.info(`Wechaty: (${s.history?.value}) + [${s.event.type}] = (${s.value})`) + // console.info('-------------------------') + // }) + + testInterpreter.send(duckula.Event.REPORT()) + + fixture.mocker.player.say('register chair', [ fixture.mocker.player ]).to(fixture.mocker.groupRoom) + await sandbox.clock.runAllAsync() + fixture.mocker.player.say('/Next').to(fixture.mocker.groupRoom) + await sandbox.clock.runAllAsync() + + fixture.mocker.player.say('register talk', [ fixture.mocker.mary ]).to(fixture.mocker.groupRoom) + await sandbox.clock.runAllAsync() + fixture.mocker.player.say('/Next').to(fixture.mocker.groupRoom) + await sandbox.clock.runAllAsync() + + fixture.mocker.player.say('register attendees', [ fixture.mocker.mary, fixture.mocker.mike ]).to(fixture.mocker.groupRoom) + await sandbox.clock.runAllAsync() + fixture.mocker.player.say('/Next').to(fixture.mocker.groupRoom) + await sandbox.clock.runAllAsync() + + // eventList.forEach(e => console.info(e.type, e.payload)) + + t.same( + eventList.filter(isActionOf(duckula.Event.BATCH)), + [ + duckula.Event.BATCH([ + duckula.Event.CHAIRS([ + fixture.mocker.player.payload, + ]), + duckula.Event.TALKS({ + [fixture.mocker.mary.id]: 'register talk', + }), + duckula.Event.ATTENDEES([ + fixture.mocker.mary.payload, + fixture.mocker.mike.payload, + ]), + ]), + ], + 'should get response BATCH([CHAIRS, TALKS, ATTENDEES])', + ) + + sandbox.restore() + } + +}) diff --git a/src/domain-actors/register/machine.ts b/src/domain-actors/register/machine.ts new file mode 100644 index 0000000..3857038 --- /dev/null +++ b/src/domain-actors/register/machine.ts @@ -0,0 +1,441 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +/* eslint-disable sort-keys */ +import { createMachine, actions } from 'xstate' +import * as Mailbox from 'mailbox' + +import type * as WechatyActor from '../../wechaty-actor/mod.js' +import { responseStates } from '../../actor-utils/response-states.js' +import { MessageToMentions, MessageToIntents } from '../../application-actors/mod.js' +import { invokeId } from '../../actor-utils/invoke-id.js' +import { Intent } from '../../intents/mod.js' + +import * as Notice from '../notice/mod.js' + +import duckula, { Context, Event } from './duckula.js' + +const machine = createMachine< + Context, + Event + | WechatyActor.Events[keyof WechatyActor.Events] +>({ + id: duckula.id, + + /** + * Spawn Notice, MessageToIntent, MessageToMention, MessageToRoom Actors + */ + invoke: [ + { + id: invokeId(MessageToIntents.id, duckula.id), + src: ctx => Mailbox.wrap( + MessageToIntents.machine.withContext({ + ...MessageToIntents.initialContext(), + actors: { + wechaty: ctx.actors.wechaty, + }, + }), + ), + }, + { + id: invokeId(MessageToMentions.id, duckula.id), + src: ctx => Mailbox.wrap( + MessageToMentions.machine.withContext({ + ...MessageToMentions.initialContext(), + actors: { + wechaty: ctx.actors.wechaty, + }, + }), + ), + }, + ], + + /** + * Huan(202204): Global events must be internal / private + * or the Mailbox actor will be blocked. + */ + on: { + [duckula.Type.NOTICE]: { + actions: actions.send( + (_, e) => Notice.Event.NOTICE( + '【注册系统】' + e.payload.text, + e.payload.mentions, + ), + { to: ctx => ctx.actors.notice }, + ), + }, + [duckula.Type.HELP]: { + actions: [ + actions.send( + ctx => duckula.Event.NOTICE( + [ + '【帮助】', + '【注册参会人员】', + '请主席发送一条消息,同时一次性 @ 所有参会人员,即可完成参会活动人员注册。', + `当前注册${Object.keys(ctx.attendees).length}人:`, + Object.values(ctx.attendees).map(c => c.name).join(', '), + '【注册分享主题】', + '主席发送一条消息,一次性将分享主体、分享人、分享人简介、分享大纲,发送出来即可完成分享主题注册。', + '...TBW', + ].join(''), + Object.keys(ctx.chairs), + ), + ), + ], + }, + }, + + initial: duckula.State.Initializing, + states: { + [duckula.State.Initializing]: { + entry: [ + actions.log(ctx => `states.Initializing.entry context ${JSON.stringify(ctx)}`, duckula.id), + actions.send(duckula.Event.NOTICE('正在初始化...')), + ], + always: duckula.State.Initialized, + }, + [duckula.State.Initialized]: { + entry: actions.send(duckula.Event.NOTICE('初始化完成。')), + always: duckula.State.Idle, + }, + [duckula.State.Resetting]: { + entry: [ + actions.log('states.Resetting.entry', duckula.id), + actions.send(duckula.Event.NOTICE('重置中...')), + actions.assign(ctx => ({ + ...ctx, + ...duckula.initialContext(), + })), + ], + always: duckula.State.Resetted, + }, + [duckula.State.Resetted]: { + entry: actions.send(duckula.Event.NOTICE('重置完成。')), + always: duckula.State.Initializing, + }, + + /** + * Idle + * + * 1. received REPORT -> transition to Busy + * 2. received RESET -> transition to Resetting + */ + [duckula.State.Idle]: { + entry: [ + Mailbox.actions.idle(duckula.id), + ], + on: { + '*' : duckula.State.Idle, + [duckula.Type.REPORT] : duckula.State.Busy, + [duckula.Type.RESET] : duckula.State.Resetting, + }, + }, + + [duckula.State.Busy]: { + always: duckula.State.RegisteringChairs, + }, + + [duckula.State.RegisteringChairs]: { + entry: [ + actions.send(duckula.Event.VALIDATE()), + ], + on: { + [duckula.Type.HELP]: { + actions: actions.send( + ctx => duckula.Event.NOTICE( + [ + '登记活动主席中... 在群内用一条消息 @ 主席,进行主席登记。', + '锁定活动主席中... 在群内发送消息 @ 主席,完成主席锁定。', + '每条消息登记一个主席。可以登记多个主席。', + '完成所有主席登记完成后,发送 NEXT 指令(完成、下一步、/next),进入会议下一个议程。', + ].join('\n'), + Object.keys(ctx.chairs), + ), + ), + }, + [duckula.Type.MESSAGE]: { + actions: [ + actions.send((_, e) => e, { to: invokeId(MessageToMentions.id, duckula.id) }), + actions.send((_, e) => e, { to: invokeId(MessageToIntents.id, duckula.id) }), + ], + }, + [duckula.Type.MENTIONS]: { + actions: [ + actions.assign({ + chairs: (ctx, e) => ({ + ...ctx.chairs, + ...e.payload.contacts.reduce((acc, cur) => ({ ...acc, [cur.id]: cur }), {}), + }), + }), + actions.send((ctx, e) => duckula.Event.NOTICE( + [ + `${e.payload.contacts[0].name} 成功登记为主席。`, + `当前主席名单:${Object.values(ctx.chairs).map(c => c.name).join('、')}`, + '如果有其他主席,清重复登记流程,发送消息进行登记。', + '输入 NEXT 指令(完成、下一步、/next),进入下一个议程。', + ].join('\n'), + e.payload.message ? [ e.payload.message.talkerId ] : [], + )), + ], + }, + [duckula.Type.INTENTS]: { + actions: actions.choose([ + { + cond: (_, e) => e.payload.intents.includes(Intent.Next), + actions: actions.send(duckula.Event.VALIDATE()), + }, + { + cond: (_, e) => e.payload.intents.length > 0 && !e.payload.intents.includes(Intent.Unknown), + actions: actions.send(duckula.Event.HELP()), + }, + ]), + }, + [duckula.Type.VALIDATE]: { + actions: actions.choose([ + { + cond: ctx => Object.keys(ctx.chairs).length > 0, + actions: [ + actions.send(ctx => duckula.Event.NOTICE( + '主席锁定完成。主席名单:', + Object.keys(ctx.chairs), + )), + actions.send(duckula.Event.NEXT()), + ], + }, + { actions: actions.send(duckula.Event.HELP()) }, + ]), + }, + [duckula.Type.NO_MENTION] : {}, + [duckula.Type.GERROR] : duckula.State.Erroring, + [duckula.Type.NEXT] : duckula.State.RegisteringTalks, + }, + }, + + [duckula.State.RegisteringTalks]: { + entry: [ + actions.send(duckula.Event.VALIDATE()), + ], + on: { + [duckula.Type.HELP]: { + actions: actions.send( + ctx => duckula.Event.NOTICE( + [ + '登记活动分享主题中... 在群内用一条消息发送演讲主题大纲,同时 @ 分享人,进行主题登记。', + '每条消息登记一个主题。可以登记多个主题。', + '完成所有主题登记完成后,发送 NEXT 指令(完成、下一步、/next),进入会议下一个议程。', + ].join('\n'), + Object.keys(ctx.chairs), + ), + ), + }, + [duckula.Type.MESSAGE]: { + actions: [ + actions.send((_, e) => e, { to: invokeId(MessageToMentions.id, duckula.id) }), + actions.send((_, e) => e, { to: invokeId(MessageToIntents.id, duckula.id) }), + ], + }, + [duckula.Type.NO_MENTION]: { + // actions: actions.send(duckula.Event.HELP()), + }, + [duckula.Type.MENTIONS]: { + actions: [ + actions.assign({ + talks: (ctx, e) => ({ + ...ctx.talks, + ...e.payload.message?.text + ? { + [e.payload.contacts[0].id]: e.payload.message.text, + } + : {}, + }), + }), + actions.send((_, e) => duckula.Event.NOTICE( + [ + `${e.payload.contacts[0].name} 登记了演讲主题:${e.payload.message?.text}`, + '如果有其他分享主题,清重复登记流程,发送消息进行登记。', + '输入 NEXT 指令(完成、下一步、/next),进入下一个议程。', + ].join('\n'), + e.payload.message + ? [ e.payload.message.talkerId ] + : [] + , + )), + ], + }, + [duckula.Type.INTENTS]: { + actions: actions.choose([ + { + cond: (_, e) => e.payload.intents.includes(Intent.Next), + actions: actions.send(duckula.Event.VALIDATE()), + }, + { + cond: (_, e) => e.payload.intents.length > 0 && !e.payload.intents.includes(Intent.Unknown), + actions: actions.send(duckula.Event.HELP()), + }, + ]), + }, + [duckula.Type.VALIDATE]: { + actions: actions.choose([ + { + cond: ctx => Object.keys(ctx.talks).length > 0, + actions: [ + actions.send(ctx => duckula.Event.NOTICE([ + '分享主题完成登记:', + ...Object.values(ctx.talks), + ].join('\n'))), + actions.send(duckula.Event.NEXT()), + ], + }, + { actions: actions.send(duckula.Event.HELP()) }, + ]), + }, + [duckula.Type.NEXT] : duckula.State.RegisteringAttendees, + [duckula.Type.GERROR] : duckula.State.Erroring, + }, + }, + + [duckula.State.RegisteringAttendees]: { + entry: [ + actions.send(duckula.Event.VALIDATE()), + ], + on: { + [duckula.Type.HELP]: { + actions: actions.send( + ctx => duckula.Event.NOTICE( + [ + '注册沙龙活动成员中... 在群内用一条消息 @ 所有参与者,进行活动成员登记。', + '请将所有参与沙龙的成员,在微信群中全部进行 @ 进行登记。(主席不必重复登记)', + '完成所有沙龙活动成员登记后,发送 NEXT 指令(完成、下一步、/next),进入会议下一个议程。', + ].join('\n'), + Object.keys(ctx.chairs), + ), + ), + }, + [duckula.Type.MESSAGE]: { + actions: [ + actions.send((_, e) => e, { to: invokeId(MessageToMentions.id, duckula.id) }), + actions.send((_, e) => e, { to: invokeId(MessageToIntents.id, duckula.id) }), + ], + }, + [duckula.Type.MENTIONS]: { + actions: [ + actions.assign({ + attendees: (ctx, e) => ({ + ...ctx.attendees, + ...e.payload.contacts.reduce((acc, cur) => ({ ...acc, [cur.id]: cur }), {}), + }), + }), + actions.send((ctx, e) => duckula.Event.NOTICE( + [ + `${e.payload.contacts.map(c => c.name).join('、')} 成功登记为沙龙活动成员。`, + `当前沙龙活动成员名单:${Object.values(ctx.attendees).map(c => c.name).join('、')}`, + '如果有其他沙龙活动成员需要登记,清重复登记流程,发送消息进行登记。', + '输入 NEXT 指令(完成、下一步、/next),进入下一个议程。', + ].join('\n'), + Object.keys(ctx.chairs), + )), + ], + }, + [duckula.Type.INTENTS]: { + actions: actions.choose([ + { + cond: (_, e) => e.payload.intents.includes(Intent.Next), + actions: actions.send(duckula.Event.VALIDATE()), + }, + // { + // cond: (_, e) => e.payload.intents.length > 0 && !e.payload.intents.includes(Intent.Unknown), + // actions: actions.send(duckula.Event.HELP()), + // }, + ]), + }, + [duckula.Type.VALIDATE]: { + actions: actions.choose([ + { + cond: ctx => Object.keys(ctx.attendees).length > 0, + actions: [ + actions.send(ctx => duckula.Event.NOTICE( + '沙龙活动成员登记完成。名单:', + Object.keys(ctx.attendees), + )), + actions.send(duckula.Event.NEXT()), + ], + }, + { actions: actions.send(duckula.Event.HELP()) }, + ]), + }, + [duckula.Type.NO_MENTION] : { + actions: actions.send(duckula.Event.HELP()), + }, + [duckula.Type.GERROR] : duckula.State.Erroring, + [duckula.Type.NEXT] : duckula.State.Summarizing, + }, + }, + + [duckula.State.Summarizing]: { + entry: [ + actions.log( + ctx => [ + 'states.Summarizing.entry ', + `chairs/${Object.keys(ctx.chairs).length} `, + `talks/${Object.keys(ctx.talks).length} `, + `attendees/${Object.keys(ctx.attendees).length} `, + ].join(''), + duckula.id, + ), + actions.send(ctx => duckula.Event.NOTICE( + [ + '注册完成:', + `主席:${Object.values(ctx.chairs).map(c => c.name).join('、')},`, + `成员:${Object.values(ctx.attendees).map(c => c.name).join('、')}(共${Object.keys({ ...ctx.chairs, ...ctx.attendees }).length}名成员参加活动)`, + `议程:${Object.keys(ctx.talks).map(id => ctx.talks[id]).join('\n')}(共${Object.keys(ctx.talks).length}个议程)`, + ].join('\n'), + )), + actions.send(duckula.Event.NEXT()), + ], + on: { + [duckula.Type.NEXT]: duckula.State.Reporting, + }, + }, + + /** + * Reporting + * + * 1. context.contacts.length > 0 -> emit CONTACTS + * 2. otherwise -> emit NEXT + * + */ + [duckula.State.Reporting]: { + entry: [ + actions.log('states.Reporting.entry', duckula.id), + actions.send(ctx => duckula.Event.BATCH([ + duckula.Event.CHAIRS(Object.values(ctx.chairs)), + duckula.Event.TALKS(ctx.talks), + duckula.Event.ATTENDEES(Object.values(ctx.attendees)), + ])), + ], + on: { + [duckula.Type.BATCH]: duckula.State.Responding, + }, + }, + + ...responseStates(duckula.id), + }, +}) + +export default machine diff --git a/src/domain-actors/register/mod.spec.ts b/src/domain-actors/register/mod.spec.ts new file mode 100755 index 0000000..9ae5ead --- /dev/null +++ b/src/domain-actors/register/mod.spec.ts @@ -0,0 +1,35 @@ +#!/usr/bin/env -S node --no-warnings --loader ts-node/esm +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import { test } from 'tstest' + +import type { Duckula } from 'mailbox' + +import * as mod from './mod.js' + +test('mod is a Duckula', async t => { + const duckula: Duckula = mod + t.ok(duckula, 'should satisfy Duckula Interface for mod export') +}) + +test('mod.Context', async t => { + const context: mod.Context = {} as any + t.ok(context, 'should has Context interface') +}) diff --git a/src/domain-actors/register/mod.ts b/src/domain-actors/register/mod.ts new file mode 100644 index 0000000..698e041 --- /dev/null +++ b/src/domain-actors/register/mod.ts @@ -0,0 +1,34 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import duckula, { type Context } from './duckula.js' +import machine from './machine.js' + +export const { + id, + Event, + State, + Type, + initialContext, +} = duckula + +export { + machine, + type Context, +} diff --git a/src/duck/event-fancy-enum.ts b/src/duck/event-fancy-enum.ts new file mode 100644 index 0000000..50eea98 --- /dev/null +++ b/src/duck/event-fancy-enum.ts @@ -0,0 +1,15 @@ +/* eslint-disable no-redeclare */ +import * as events from './events.js' + +/** + * Huan(202204): We are using a "Fancy Enum" instead of a TypeScript native `enum` at here, + * because the below tweet from @BenLesh said: + * + * @link https://twitter.com/huan_us/status/1511260462544998404 + */ + +export type Event = { + [K in keyof typeof events]: ReturnType +} +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const Event = events diff --git a/src/duck/events.ts b/src/duck/events.ts new file mode 100644 index 0000000..7435964 --- /dev/null +++ b/src/duck/events.ts @@ -0,0 +1,140 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import { createAction } from 'typesafe-actions' +import type * as PUPPET from 'wechaty-puppet' +import type { AnyEventObject } from 'xstate' + +import type { Intent } from '../intents/mod.js' + +import { Type } from './type-fancy-enum.js' + +const payloadOptionalMessage = (message?: PUPPET.payloads.Message) => ({ message }) + +const payloadAbort = (reason: string) => ({ reason }) +const payloadCancel = (reason: string) => ({ reason }) +const payloadData = (data?: string) => ({ data }) + +const payloadMentions = (contacts: [PUPPET.payloads.Contact, ...PUPPET.payloads.Contact[]], message?: PUPPET.payloads.Message) => ({ contacts, message }) +export const MENTIONS = createAction(Type.MENTIONS, payloadMentions)() +export const NO_MENTION = createAction(Type.NO_MENTION, payloadOptionalMessage)() + +const payloadContacts = (contacts: PUPPET.payloads.Contact[]) => ({ contacts }) +export const CONTACTS = createAction(Type.CONTACTS, payloadContacts)() +export const NO_CONTACT = createAction(Type.NO_CONTACT)() +export const ADD_CONTACT = createAction(Type.ADD_CONTACT, payloadContacts)() + +export const ATTENDEES = createAction(Type.ATTENDEES, payloadContacts)() +export const ADMINS = createAction(Type.ADMINS, payloadContacts)() +export const CHAIRS = createAction(Type.CHAIRS, payloadContacts)() + +const payloadMessage = (message: PUPPET.payloads.Message) => ({ message }) +export const MESSAGE = createAction(Type.MESSAGE, payloadMessage)() + +export const BACK = createAction(Type.BACK)() +export const NEXT = createAction(Type.NEXT)() + +export const NO_AUDIO = createAction(Type.NO_AUDIO)() + +const payloadRoom = (room: PUPPET.payloads.Room, message?: PUPPET.payloads.Message) => ({ message, room }) +export const ROOM = createAction(Type.ROOM, payloadRoom)() + +const payloadNoRoom = (message?: PUPPET.payloads.Message) => ({ message }) +export const NO_ROOM = createAction(Type.NO_ROOM, payloadNoRoom)() + +export const START = createAction(Type.START)() +export const STOP = createAction(Type.STOP)() + +const payloadText = (text: string, message?: PUPPET.payloads.Message) => ({ message, text }) +export const TEXT = createAction(Type.TEXT, payloadText)() +export const NO_TEXT = createAction(Type.NO_TEXT, payloadOptionalMessage)() + +const payloadFeedbacks = (feedbacks: { [contactId: string]: string }) => ({ feedbacks }) +export const FEEDBACKS = createAction(Type.FEEDBACKS, payloadFeedbacks)() + +// const payloadFeedback = (feedback: string, message: PUPPET.payloads.Message) => ({ feedback, message }) +// export const FEEDBACK = createAction(Type.FEEDBACK, payloadFeedback)() + +export const CANCEL = createAction(Type.CANCEL, payloadCancel)() +export const ABORT = createAction(Type.ABORT, payloadAbort)() + +const payloadGerror = (gerror: string) => ({ gerror }) +export const GERROR = createAction(Type.GERROR, payloadGerror)() + +const payloadReset = (data?: string) => ({ data }) +export const RESET = createAction(Type.RESET, payloadReset)() + +const payloadIntents = (intents: readonly Intent[], message?: PUPPET.payloads.Message) => ({ intents, message }) +export const INTENTS = createAction(Type.INTENTS, payloadIntents)() + +/** + * Complete v.s. Finish + * @see https://ejoy-english.com/blog/complete-vs-finish-similar-but-different/ + */ +export const FINISH = createAction(Type.FINISH, payloadData)() +export const COMPLETE = createAction(Type.COMPLETE, payloadData)() + +export const HELP = createAction(Type.HELP)() +export const REPORT = createAction(Type.REPORT)() + +const payloadIdle = (data?: string) => ({ reason: data }) +export const IDLE = createAction(Type.IDLE, payloadIdle)() + +export const CHECK = createAction(Type.CHECK)() + +export const PROCESS = createAction(Type.PROCESS)() +export const PARSE = createAction(Type.PARSE)() + +const payloadNotice = (text: string, mentions: string[] = []) => ({ mentions, text }) +export const NOTICE = createAction(Type.NOTICE, payloadNotice)() + +/** + * Minutes of Meeting (MoM) + * @link https://en.wikipedia.org/wiki/Minutes + */ +const payloadMinute = (minutes: string) => ({ minutes }) +export const MINUTES = createAction(Type.MINUTES, payloadMinute)() + +const payloadConversation = (id: string) => ({ id }) +export const CONVERSATION = createAction(Type.CONVERSATION, payloadConversation)() + +export const NOP = createAction(Type.NOP)() + +const payloadFile = (box: string, message?: PUPPET.payloads.Message) => ({ box, message }) +export const FILE = createAction(Type.FILE, payloadFile)() + +const payloadNoFile = (message?: PUPPET.payloads.Message) => ({ message }) +export const NO_FILE = createAction(Type.NO_FILE, payloadNoFile)() + +const payloadLoad = (id: string) => ({ id }) +export const LOAD = createAction(Type.LOAD, payloadLoad)() + +export const REGISTER = createAction(Type.REGISTER)() + +export const payloadTalk = (contact: PUPPET.payloads.Contact, topic: string, outlines: string) => ({ contact, outlines, topic }) +export const TALK = createAction(Type.TALK, payloadTalk)() + +export const payloadTalks = (talks: { [contactId: string]: string }) => ({ talks }) +export const TALKS = createAction(Type.TALKS, payloadTalks)() + +export const TEST = createAction(Type.TEST)() +export const VALIDATE = createAction(Type.VALIDATE)() + +export const payloadBatch = (events: AnyEventObject[]) => ({ events }) +export const BATCH = createAction(Type.BATCH, payloadBatch)() diff --git a/src/duck/mod.ts b/src/duck/mod.ts new file mode 100644 index 0000000..0e1f759 --- /dev/null +++ b/src/duck/mod.ts @@ -0,0 +1,28 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +/** + * Huan(202204): We are using a "Fancy Enum" instead of a TypeScript native `enum` at here, + * because the below tweet from @BenLesh said: + * + * @link https://twitter.com/huan_us/status/1511260462544998404 + */ +export { Event } from './event-fancy-enum.js' +export { State } from './states/mod.js' +export { Type } from './type-fancy-enum.js' diff --git a/src/duck/states/actor-states.ts b/src/duck/states/actor-states.ts new file mode 100644 index 0000000..105cb5c --- /dev/null +++ b/src/duck/states/actor-states.ts @@ -0,0 +1,81 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +/** + * Idle Time – Definition, Causes, And How To Reduce It + * @see https://limblecmms.com/blog/idle-time/ + */ +export const Idle = 'bot5-assistant/Idle' +export const Busy = 'bot5-assistant/Busy' + +/** + * Huan(202112): Recommended states transition for actors with Mailbox + * 1. initializing / onDone: idle + * 2. idle + * - RESET: resetting -> initializing + * - *: idle (make sure it's an external transition) + */ +export const Initializing = 'bot5-assistant/Initializing' +export const Initialized = 'bot5-assistant/Initialized' + +export const Responding = 'bot5-assistant/Responding' +export const Responded = 'bot5-assistant/Responded' + +/** + * Which one is better: errored v.s. failed? + * @see https://stackoverflow.com/questions/6323049/understanding-what-fault-error-and-failure-mean + */ +export const Erroring = 'bot5-assistant/Erroring' +export const Errored = 'bot5-assistant/Errored' + +export const Failing = 'bot5-assistant/Failing' +export const Failed = 'bot5-assistant/Failed' + +/** + * Start / Stop + */ +export const Starting = 'bot5-assistant/Starting' +export const Started = 'bot5-assistant/Started' + +export const Stopping = 'bot5-assistant/Stopping' +export const Stopped = 'bot5-assistant/Stopped' + +export const Resetting = 'bot5-assistant/Resetting' +export const Resetted = 'bot5-assistant/Resetted' + +/** + * Complete v.s. Finish + * @see https://ejoy-english.com/blog/complete-vs-finish-similar-but-different/ + */ +export const Completing = 'bot5-assistant/Completing' +export const Completed = 'bot5-assistant/Completed' + +export const Finishing = 'bot5-assistant/Finishing' +export const Finished = 'bot5-assistant/Finished' + +/** + * Abort v.s. Cancel + * @see https://stackoverflow.com/a/9838022/1123955 + */ +export const Aborting = 'bot5-assistant/Aborting' +export const Aborted = 'bot5-assistant/Aborted' + +export const Canceling = 'bot5-assistant/Canceling' +export const Canceled = 'bot5-assistant/Canceled' diff --git a/src/duck/states/bot5-meeting-steps.ts b/src/duck/states/bot5-meeting-steps.ts new file mode 100644 index 0000000..8ab4053 --- /dev/null +++ b/src/duck/states/bot5-meeting-steps.ts @@ -0,0 +1,74 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +/** + * + * BOT5 Club main meeting steps + * + * @link https://bot5.ml/manuals/chair/ + * + */ +export const Meeting = 'bot5-assistant/Meeting' + +export const RegisteringRoom = 'bot5-assistant/RegisteringRoom' +export const RegisteredRoom = 'bot5-assistant/RegisteredRoom' + +export const RegisteringChairs = 'bot5-assistant/RegisteringChairs' +export const RegisteredChairs = 'bot5-assistant/Registerinedairs' + +export const RegisteringAttendees = 'bot5-assistant/RegisteringAttendees' +export const RegisteredAttendees = 'bot5-assistant/RegisteringAteddees' + +export const RegisteringTalks = 'bot5-assistant/RegisteringTalks' +export const RegisteredTalks = 'bot5-assistant/Registeriedalks' + +// Checkining - https://www.online-translator.com/conjugation%20and%20declination/english/checkin +export const Checkining = 'bot5-assistant/Checkining' + +// Retrospect: a review of the past - https://wikidiff.com/reminisce/retrospect +export const Retrospecting = 'bot5-assistant/Retrospecting' + +export const Joining = 'bot5-assistant/Joining' + +export const Presenting = 'bot5-assistant/Presenting' +export const Upgrading = 'bot5-assistant/Upgrading' + +export const Electing = 'bot5-assistant/Electing' +export const Elected = 'bot5-assistant/Elected' + +// For the elected vice-chair +export const Pledging = 'bot5-assistant/Pledging' + +export const Brainstorming = 'bot5-assistant/Brainstorming' + +// Rosting: 吐槽 - https://www.sohu.com/a/222322905_509197 +export const Roasting = 'bot5-assistant/Roasting' + +export const ShootingChairs = 'bot5-assistant/ShootingChairs' +export const ShootingAll = 'bot5-assistant/ShootingAll' +export const ShootingDrinkers = 'bot5-assistant/ShootingDrinkers' + +export const Housekeeping = 'bot5-assistant/Housekeeping' +export const Summarizing = 'bot5-assistant/Summarizing' +export const Summarized = 'bot5-assistant/Summarized' +export const Chatting = 'bot5-assistant/Chatting' +export const Drinking = 'bot5-assistant/Drinking' +export const Paying = 'bot5-assistant/Paying' +export const Announcing = 'bot5-assistant/Announcing' diff --git a/src/duck/states/mod.ts b/src/duck/states/mod.ts new file mode 100644 index 0000000..7e5977e --- /dev/null +++ b/src/duck/states/mod.ts @@ -0,0 +1,12 @@ +/* eslint-disable no-redeclare */ +import * as states from './states.js' + +/** + * Huan(202204): We are using a "Fancy Enum" instead of a TypeScript native `enum` at here, + * because the below tweet from @BenLesh said: + * + * @link https://twitter.com/huan_us/status/1511260462544998404 + */ + +export type State = typeof states[keyof typeof states] +export const State = states diff --git a/src/duck/states/other-states.ts b/src/duck/states/other-states.ts new file mode 100644 index 0000000..e739f02 --- /dev/null +++ b/src/duck/states/other-states.ts @@ -0,0 +1,68 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +export const Unknown = 'bot5-assistant/Unknown' + +export const Listening = 'bot5-assistant/Listening' +export const Thinking = 'bot5-assistant/Thinking' +export const Feedbacking = 'bot5-assistant/Feedbacking' +export const Feedbacked = 'bot5-assistant/Feedbacked' +export const Checking = 'bot5-assistant/Checking' +export const Validating = 'bot5-assistant/Validating' + +export const Active = 'bot5-assistant/Active' +export const Inactive = 'bot5-assistant/Inactive' + +export const Recognizing = 'bot5-assistant/Recognizing' +export const Recognized = 'bot5-assistant/Recognized' + +export const Classifying = 'bot5-assisstant/Classifying' +export const Classified = 'bot5-assistant/Classified' + +export const Processing = 'bot5-assistant/Processing' +export const Delivering = 'bot5-assistant/Delivering' + +export const Mentioning = 'bot5-assistant/Mentioning' +export const Registering = 'bot5-assistant/Registering' +export const Registered = 'bot5-assistant/Registered' + +export const Saying = 'bot5-assistant/Saying' +export const Updating = 'bot5-assistant/Updating' +export const Confirming = 'bot5-assistant/Confirming' + +export const Understanding = 'bot5-assistant/Understanding' +export const Understood = 'bot5-assistant/Understood' + +export const Introducing = 'bot5-assistant/Introducing' +export const Selecting = 'bot5-assistant/Selecting' +export const Scheduling = 'bot5-assistant/Scheduling' +export const Noticing = 'bot5-assistant/Noticing' +export const Reporting = 'bot5-assistant/Reporting' +export const Parsing = 'bot5-assistant/Parsing' + +export const Loading = 'bot5-assistant/Loading' +export const Loaded = 'bot5-assistant/Loaded' + +export const Messaging = 'bot5-assistant/Messaging' +export const Filing = 'bot5-assistant/Filing' + +export const Textualizing = 'bot5-assistant/Textualizing' +export const Textualized = 'bot5-assistant/Textualized' + +export const Nexting = 'bot5-assistant/Nexting' diff --git a/src/duck/states/states.ts b/src/duck/states/states.ts new file mode 100644 index 0000000..92ca313 --- /dev/null +++ b/src/duck/states/states.ts @@ -0,0 +1,22 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +export * from './actor-states.js' +export * from './bot5-meeting-steps.js' +export * from './other-states.js' diff --git a/src/duck/type-fancy-enum.ts b/src/duck/type-fancy-enum.ts new file mode 100644 index 0000000..13beef4 --- /dev/null +++ b/src/duck/type-fancy-enum.ts @@ -0,0 +1,12 @@ +/* eslint-disable no-redeclare */ +import * as types from './types.js' + +/** + * Huan(202204): We are using a "Fancy Enum" instead of a TypeScript native `enum` at here, + * because the below tweet from @BenLesh said: + * + * @link https://twitter.com/huan_us/status/1511260462544998404 + */ + +export type Type = typeof types[keyof typeof types] +export const Type = types diff --git a/src/duck/types.ts b/src/duck/types.ts new file mode 100644 index 0000000..63911d9 --- /dev/null +++ b/src/duck/types.ts @@ -0,0 +1,108 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +export const MESSAGE = 'bot5-assistant/MESSAGE' + +export const ROOM = 'bot5-assistant/ROOM' +export const NO_ROOM = 'bot5-assistant/NO_ROOM' + +export const ADMINS = 'bot5-assistant/ADMINS' +export const ATTENDEES = 'bot5-assistant/ATTENDEES' +export const CHAIRS = 'bot5-assistant/CHAIRS' + +export const NO_AUDIO = 'bot5-assistant/NO_AUDIO' + +export const CONTACTS = 'bot5-assistant/CONTACTS' +export const NO_CONTACT = 'bot5-assistant/NO_CONTACT' + +export const NO_TEXT = 'bot5-assistant/NO_TEXT' +export const TEXT = 'bot5-assistant/TEXT' + +export const MENTIONS = 'bot5-assistant/MENTIONS' +export const NO_MENTION = 'bot5-assistant/NO_MENTION' + +export const BACK = 'bot5-assistant/BACK' +export const NEXT = 'bot5-assistant/NEXT' + +export const START = 'bot5-assistant/START' +export const STOP = 'bot5-assistant/STOP' + +export const SAY = 'bot5-assistant/SAY' +export const RESET = 'bot5-assistant/RESET' + +export const GERROR = 'bot5-assistant/GERROR' + +export const FEEDBACKS = 'bot5-assistant/FEEDBACKS' +export const FEEDBACK = 'bot5-assistant/FEEDBACK' + +/** + * Complete v.s. Finish + * @see https://ejoy-english.com/blog/complete-vs-finish-similar-but-different/ + */ +export const FINISH = 'bot5-assistant/FINISH' +export const COMPLETE = 'bot5-assistant/COMPLETE' + +/** + * Abort v.s. Cancel + * @see https://stackoverflow.com/a/9838022/1123955 + */ +export const ABORT = 'bot5-assistant/ABORT' +export const CANCEL = 'bot5-assistant/CANCEL' + +export const WECHATY = 'bot5-assistant/WECHATY' + +export const DELIVER = 'bot5-assistant/DELIVER' +export const INTENTS = 'bot5-assistant/INTENTS' + +export const HELP = 'bot5-assistant/HELP' +export const REPORT = 'bot5-assistant/REPORT' + +export const IDLE = 'bot5-assistant/IDLE' +export const CHECK = 'bot5-assistant/CHECK' + +export const PROCESS = 'bot5-assistant/PROCESS' +export const PARSE = 'bot5-assistant/PARSE' +export const NOTICE = 'bot5-assistant/NOTICE' + +/** + * Minutes of Meeting (MoM) + * @link https://en.wikipedia.org/wiki/Minutes + */ +export const MINUTES = 'bot5-assistant/MINUTES' + +export const CONVERSATION = 'bot5-assistant/CONVERSATION' +export const NOP = 'bot5-assistant/NOP' + +export const FILE = 'bot5-assistant/FILE' +export const NO_FILE = 'bot5-assistant/NO_FILE' + +export const LOAD = 'bot5-assisstant/LOAD' + +export const REGISTER = 'bot5-assistant/REGISTER' + +export const CHECKIN = 'bot5-assistant/CHECKIN' +export const TALK = 'bot5-assistant/TALK' +export const TALKS = 'bot5-assistant/TALKS' + +export const ADD_CONTACT = 'bot5-assistant/ADD_CONTACT' + +export const TEST = 'bot5-assistant/TEST' +export const VALIDATE = 'bot5-assistant/VALIDATE' + +export const BATCH = 'bot5-assistant/BATCH' diff --git a/src/fixtures/bot5-fixture.ts b/src/fixtures/bot5-fixture.ts new file mode 100644 index 0000000..b391f45 --- /dev/null +++ b/src/fixtures/bot5-fixture.ts @@ -0,0 +1,91 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +/* eslint-disable sort-keys */ + +import * as WECHATY from 'wechaty' +import { createFixture } from 'wechaty-mocker' + +async function * bot5Fixtures () { + for await (const WECHATY_FIXTURES of createFixture()) { + const { + mocker, + wechaty, + } = WECHATY_FIXTURES + + const mockMary = mocker.mocker.createContact({ name: 'Mary' }) + const mockMike = mocker.mocker.createContact({ name: 'Mike' }) + + const mary = (await wechaty.wechaty.Contact.find({ id: mockMary.id }))! + const mike = (await wechaty.wechaty.Contact.find({ id: mockMike.id }))! + + // const mockContactList = [ + // mockMary, + // mockMike, + // mocker.bot, + // mocker.player, + // ] + const contactList = [ + mary, + mike, + wechaty.bot, + wechaty.player, + ] + + const mockGroupRoom = mocker.mocker.createRoom({ + topic: contactList.map(c => c.name()).join(','), + memberIdList: contactList.map(c => c.id), + }) + const groupRoom = await wechaty.wechaty.Room.find({ id: mockGroupRoom.id }) + if (!groupRoom) { + throw new Error('no meeting room') + } + + const logger = (arg0: any, ...args: any[]) => { + const arg0List = arg0.split(/\s+/) + WECHATY.log.info( + arg0List[0], + [ + ...arg0List.slice(1), + ...args, + ].join(' '), + ) + } + + yield { + ...WECHATY_FIXTURES, + mocker: { + ...WECHATY_FIXTURES.mocker, + mary: mockMary, + mike: mockMike, + groupRoom: mockGroupRoom, + }, + + wechaty: { + ...WECHATY_FIXTURES.wechaty, + mary, + mike, + groupRoom, + }, + logger, + } as const + } +} + +export { bot5Fixtures } diff --git a/src/fsm/guard-machine-event.ts b/src/fsm/guard-machine-event.ts deleted file mode 100644 index 3af962a..0000000 --- a/src/fsm/guard-machine-event.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { - EventObject, - Interpreter, - StateSchema, -} from 'xstate' - -const guardMachineEvent = ( - interpreter: Interpreter< - any, - TServiceCtlState, - TServiceCtlEvent - >, - event: TServiceCtlEvent['type'], -): void => { - if (!interpreter.state.can(event)) { - throw new Error([ - `StateMachine "${interpreter.id}" can not accept event "${event}"`, - ` with current state "${interpreter.state.value}"`, - ].join('')) - } -} - -export { guardMachineEvent } diff --git a/src/fsm/machine-config.ts b/src/fsm/machine-config.ts deleted file mode 100644 index cca1ed9..0000000 --- a/src/fsm/machine-config.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * Finite State Machine for BOT Friday Club Meeting - * @link https://github.com/wechaty/bot5-assistant - */ -import type { MachineConfig, StateSchema } from 'xstate' -import type { Wechaty } from 'wechaty' - -type MeetingEvent = - | 'START' - | 'FINISH' - | 'CANCEL' - -type MeetingState = - | 'meeting' - | 'idle' - -interface MeetingEventSchema { - type: MeetingEvent -} - -interface MeetingActionSchema { - type: never -} - -interface MeetingContext { - wechaty: Wechaty, -} - -interface MeetingStateSchema { - states: { - [key in MeetingState]: StateSchema - } - value: MeetingState // types for `state.matches()` - context: MeetingContext -} - -const idle = { - on: { - START: 'meeting', - }, -} as const - -const meeting = { - on: { - CANCEL : 'idle', - FINISH : 'idle', - }, -} as const - -const states = { - idle, - meeting, -} as const - -const config: (wechaty: Wechaty) => MachineConfig< - MeetingContext, - MeetingStateSchema, - MeetingEventSchema -> = wechaty => ({ - context: { - wechaty, - }, - id: 'meeting-machine', - initial: 'idle', - states, -} as const) - -export type { - MeetingActionSchema, - MeetingContext, - MeetingEventSchema, - MeetingStateSchema, - MeetingState, -} -export { config } diff --git a/src/fsm/machine-options.ts b/src/fsm/machine-options.ts deleted file mode 100644 index 6cafd96..0000000 --- a/src/fsm/machine-options.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { MachineOptions } from 'xstate' - -import type { - MeetingContext, - MeetingEventSchema, -} from './machine-config.js' - -interface MeetingServiceOptions { - // actions: { - // onEntry: Function, - // onExit: Function, - // }, - // services: { - start: () => Promise, - stop: () => Promise, - // } -} - -const buildMachineOptions = ( - options: MeetingServiceOptions, -): MachineOptions< - MeetingContext, - MeetingEventSchema -> => { - const reset = async () => { - await options.stop() - await options.start() - } - - return { - actions: { - // onEntry, - // onExit, - }, - activities: {}, - delays: {}, - guards: {}, - services: { - reset, - start : options.start, - stop : options.stop, - }, - } -} - -export type { - MeetingServiceOptions, -} -export { - buildMachineOptions, -} diff --git a/src/fsm/meeting-interpreter.spec.ts b/src/fsm/meeting-interpreter.spec.ts deleted file mode 100755 index bf990a5..0000000 --- a/src/fsm/meeting-interpreter.spec.ts +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env -S node --no-warnings --loader ts-node/esm - -import { - test, - // sinon, -} from 'tstest' - -import { createFixture } from 'wechaty-mocker' - -import { - getInterpreter, -} from './meeting-interpreter.js' - -test('Bot5MeetingFsm smoke testing', async t => { - for await (const fixture of createFixture()) { - // const sandbox = sinon.createSandbox() - const interpreter = getInterpreter(fixture.wechaty.wechaty) - t.ok(interpreter.state.matches('idle'), 'should be idle') - - t.ok(interpreter.state.can('START'), 'should can START') - - interpreter.send('START') - t.ok(interpreter.state.matches('meeting'), 'should be in meeting state') - - t.notOk(interpreter.state.can('START'), 'should can not START again in meeting') - } -}) diff --git a/src/fsm/meeting-interpreter.ts b/src/fsm/meeting-interpreter.ts deleted file mode 100644 index c5f2177..0000000 --- a/src/fsm/meeting-interpreter.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { - createMachine, - interpret, -} from 'xstate' -import type { Wechaty } from 'wechaty' - -import { - MeetingContext, - MeetingEventSchema, - MeetingStateSchema, - config, -} from './machine-config.js' - -const getInterpreter = (wechaty: Wechaty) => { - const machine = createMachine< - MeetingContext, - MeetingEventSchema, - MeetingStateSchema - >(config(wechaty)) - - const interpreter = interpret(machine) - interpreter.start() - - return interpreter -} - -export { getInterpreter } diff --git a/src/fsm/mod.ts b/src/fsm/mod.ts deleted file mode 100644 index 0747c3f..0000000 --- a/src/fsm/mod.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { - getInterpreter, -} from './meeting-interpreter.js' - -export { - getInterpreter, -} diff --git a/src/infrastructure-actors/file-to-text/duckula.ts b/src/infrastructure-actors/file-to-text/duckula.ts new file mode 100644 index 0000000..7d9b48e --- /dev/null +++ b/src/infrastructure-actors/file-to-text/duckula.ts @@ -0,0 +1,61 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +/* eslint-disable sort-keys */ +import * as Mailbox from 'mailbox' + +import * as duck from '../../duck/mod.js' + +export interface Context {} + +const duckula = Mailbox.duckularize({ + id: 'FileToText', + events: [ duck.Event, [ + /** + * Request + */ + 'FILE', + /** + * Response + */ + 'TEXT', + 'NO_TEXT', + 'GERROR', + /** + * Internal + */ + ] ], + states: [ duck.State, [ + 'Idle', + 'Initializing', + 'Recognizing', + 'Erroring', + 'Errored', + 'Responding', + 'Responded', + ] ], + initialContext: ({}), +}) + +export type Event = ReturnType +export type Events = { + [key in keyof typeof duckula.Event]: ReturnType +} + +export default duckula diff --git a/src/infrastructure-actors/file-to-text/fixtures.spec.ts b/src/infrastructure-actors/file-to-text/fixtures.spec.ts new file mode 100755 index 0000000..bea3128 --- /dev/null +++ b/src/infrastructure-actors/file-to-text/fixtures.spec.ts @@ -0,0 +1,31 @@ +#!/usr/bin/env -S node --no-warnings --loader ts-node/esm +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import { test } from 'tstest' + +import { FIXTURES } from './fixtures.js' + +test('silk fixture existance testing', async t => { + const [ [ fileBox ] ] = await FIXTURES() + await t.resolves( + () => fileBox.toBase64(), + 'should be able to read file to base64', + ) +}) diff --git a/src/infrastructure-actors/file-to-text/fixtures.ts b/src/infrastructure-actors/file-to-text/fixtures.ts new file mode 100644 index 0000000..6da1d08 --- /dev/null +++ b/src/infrastructure-actors/file-to-text/fixtures.ts @@ -0,0 +1,40 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import { FileBox, FileBoxInterface } from 'file-box' +import path from 'path' +import { fileURLToPath } from 'url' + +export const FIXTURES = async () => { + const __dirname = path.dirname(fileURLToPath(import.meta.url)) + + const EXPECTED_TEXT = '大可乐两个统一,冰红茶三箱。' + const base64 = await FileBox.fromFile(path.join( + __dirname, + '../../../tests/fixtures/sample.sil', + )).toBase64() + + const FILE_BOX = FileBox.fromBase64(base64, 'sample.sil') as FileBoxInterface + + const fixture = [ + [ FILE_BOX, EXPECTED_TEXT ], + ] as const + + return fixture +} diff --git a/src/infrastructure-actors/file-to-text/machine.spec.ts b/src/infrastructure-actors/file-to-text/machine.spec.ts new file mode 100755 index 0000000..58f3a75 --- /dev/null +++ b/src/infrastructure-actors/file-to-text/machine.spec.ts @@ -0,0 +1,257 @@ +#!/usr/bin/env -S node --no-warnings --loader ts-node/esm +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +/* eslint-disable sort-keys */ +import { interpret, createMachine, actions } from 'xstate' +import { FileBox, FileBoxInterface } from 'file-box' +import { test } from 'tstest' +import path from 'path' +import { fileURLToPath } from 'url' +import * as Mailbox from 'mailbox' +import { isActionOf } from 'typesafe-actions' + +import machine from './machine.js' +import duckula from './duckula.js' + +test('machine initialState', async t => { + t.equal(machine.initialState.value, duckula.State.Idle, 'should be initial state idle') + t.equal(machine.initialState.event.type, 'xstate.init', 'should be initial event from xstate') + t.same(machine.initialState.context, duckula.initialContext(), 'should be initial context') +}) + +test('process audio message', async t => { + const __dirname = path.dirname(fileURLToPath(import.meta.url)) + + const FILE_BOX_FIXTURE_LOCAL = FileBox.fromFile(path.join( + __dirname, + '../../../tests/fixtures/sample.sil', + )) as FileBoxInterface + const FILE_BOX_FIXTURE_BASE64 = FileBox.fromBase64(await FILE_BOX_FIXTURE_LOCAL.toBase64(), FILE_BOX_FIXTURE_LOCAL.name) + + const FILE_BOX_FIXTURE_EVENT = duckula.Event.FILE( + JSON.stringify(FILE_BOX_FIXTURE_BASE64), + ) + const EXPECTED_TEXT = '大可乐两个统一,冰红茶三箱。' + + const mailbox = Mailbox.from( + machine.withContext({ + ...duckula.initialContext(), + }), + ) + mailbox.open() + + const consumerMachineTest = createMachine({ + id: 'consumer', + initial: 'idle', + states: { + idle: { + on: { + '*': { + actions: Mailbox.actions.proxy('ConsumerMachineTest')(mailbox), + target: 'idle', + }, + }, + }, + }, + }) + + const eventList: any[] = [] + const interpreter = interpret(consumerMachineTest) + .onEvent(e => { + // console.info('Event:', e.type) + eventList.push(e) + }) + .start() + + const future = new Promise(resolve => { + interpreter.onEvent(e => + isActionOf( + [ + duckula.Event.TEXT, + duckula.Event.GERROR, + ], + e, + ) && resolve(e)) + }) + + interpreter.send(FILE_BOX_FIXTURE_EVENT) + await future + // await new Promise(resolve => setTimeout(resolve, 10000)) + + // eventList.forEach(e => console.info(e)) + t.same( + eventList.at(-1), + duckula.Event.TEXT(EXPECTED_TEXT), + `should get expected TEXT: ${EXPECTED_TEXT}`, + ) + interpreter.stop() +}) + +test('process non-audio(image) message ', async t => { + const __dirname = path.dirname(fileURLToPath(import.meta.url)) + + const FILE_BOX_FIXTURE_LOCAL = FileBox.fromFile(path.join( + __dirname, + '../../../docs/images/caq-bot5-qingyu.webp', + )) as FileBoxInterface + const FILE_BOX_FIXTURE_BASE64 = FileBox.fromBase64(await FILE_BOX_FIXTURE_LOCAL.toBase64(), FILE_BOX_FIXTURE_LOCAL.name) + + const FILE_BOX_FIXTURE_EVENT = duckula.Event.FILE( + JSON.stringify(FILE_BOX_FIXTURE_BASE64), + ) + + const mailbox = Mailbox.from( + machine.withContext({ + ...duckula.initialContext(), + }), + ) + mailbox.open() + + const consumerMachineTest = createMachine({ + id: 'consumer', + initial: 'idle', + states: { + idle: { + on: { + '*': { + actions: Mailbox.actions.proxy('ConsumerMachineTest')(mailbox), + target: 'idle', + }, + }, + }, + }, + }) + + const eventList: any[] = [] + const interpreter = interpret(consumerMachineTest) + .onEvent(e => { + // console.info('Event:', e.type) + eventList.push(e) + }) + .start() + + const future = new Promise(resolve => { + interpreter.onEvent(e => + isActionOf( + [ + duckula.Event.TEXT, + duckula.Event.GERROR, + ], + e, + ) && resolve(e)) + }) + + interpreter.send(FILE_BOX_FIXTURE_EVENT) + await future + // await new Promise(resolve => setTimeout(resolve, 10000)) + + // eventList.forEach(e => console.info(e)) + t.same( + eventList.at(-1).type, + duckula.Type.GERROR, + 'should get GERROR for image', + ) + interpreter.stop() +}) + +test('state invoke smoke testing', async t => { + const __dirname = path.dirname(fileURLToPath(import.meta.url)) + + const FILE_BOX_FIXTURE_LOCAL = FileBox.fromFile(path.join( + __dirname, + '../../../tests/fixtures/sample.sil', + )) as FileBoxInterface + const FILE_BOX_FIXTURE_BASE64 = FileBox.fromBase64(await FILE_BOX_FIXTURE_LOCAL.toBase64(), FILE_BOX_FIXTURE_LOCAL.name) + + const FILE_BOX_FIXTURE_EVENT = duckula.Event.FILE( + JSON.stringify(FILE_BOX_FIXTURE_BASE64), + ) + const EXPECTED_TEXT = '大可乐两个统一,冰红茶三箱。' + + const parentMachineTest = createMachine({ + id: 'parent', + initial: 'idle', + states: { + idle: { + on: { + '*': 'busy', + }, + }, + busy: { + invoke: { + id: duckula.id, + src: machine, + }, + entry: [ + actions.send((_, e) => e, { to: duckula.id }), + ], + on: { + [Mailbox.Type.ACTOR_REPLY]: { + actions: [ + actions.log((_, e) => 'received ACTOR_REPLY ' + JSON.stringify(e)), + actions.send((_, e) => (e as Mailbox.Event['ACTOR_REPLY']).payload.message), + ], + }, + [duckula.Type.TEXT]: { + actions: [ + actions.log('received TEXT'), + ], + }, + [duckula.Type.GERROR]: { + actions: [ + actions.log('received GERROR'), + ], + }, + }, + }, + }, + }) + + const eventList: any[] = [] + const interpreter = interpret(parentMachineTest) + .onEvent(e => { + console.info('Event:', e.type) + eventList.push(e) + }) + .start() + + const future = new Promise(resolve => { + interpreter.onEvent(e => + isActionOf( + [ + duckula.Event.TEXT, + duckula.Event.GERROR, + ], + e, + ) && resolve(e)) + }) + + interpreter.send(FILE_BOX_FIXTURE_EVENT) + await future + // await new Promise(resolve => setTimeout(resolve, 10000)) + + // eventList.forEach(e => console.info(e)) + t.same( + eventList.at(-1), + duckula.Event.TEXT(EXPECTED_TEXT), + `should get expected TEXT: ${EXPECTED_TEXT}`, + ) + interpreter.stop() +}) diff --git a/src/infrastructure-actors/file-to-text/machine.ts b/src/infrastructure-actors/file-to-text/machine.ts new file mode 100644 index 0000000..d3b0163 --- /dev/null +++ b/src/infrastructure-actors/file-to-text/machine.ts @@ -0,0 +1,91 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +/* eslint-disable sort-keys */ +import { createMachine, actions } from 'xstate' +import * as Mailbox from 'mailbox' +import { GError } from 'gerror' +import { FileBox } from 'file-box' + +import { responseStates } from '../../actor-utils/response-states.js' + +import { speechToText } from './speech-to-text.js' +import duckula, { Context, Event, Events } from './duckula.js' + +const machine = createMachine< + Context, + Event +>({ + id: duckula.id, + context: duckula.initialContext, + + initial: duckula.State.Initializing, + states: { + [duckula.State.Initializing]: { + entry: [ + actions.log(ctx => `states.Initializing.entry context ${JSON.stringify(ctx)}`, duckula.id), + ], + always: duckula.State.Idle, + }, + + [duckula.State.Idle]: { + entry: [ + Mailbox.actions.idle(duckula.id), + ], + on: { + [duckula.Type.FILE]: duckula.State.Recognizing, + }, + }, + [duckula.State.Recognizing]: { + entry: [ + actions.log((_, e) => `states.Recognizing.entry fileBox: "${JSON.parse((e as Events['FILE']).payload.box).name}"`, duckula.id), + ], + invoke: { + src: (_, e) => speechToText( + FileBox.fromJSON((e as Events['FILE']).payload.box), + ), + onDone: { + actions: [ + actions.log((_, e) => `states.recognizing.invoke.onDone "${e.data}"`, duckula.id), + actions.send((_, e) => e.data + ? duckula.Event.TEXT(e.data) + : duckula.Event.NO_TEXT() + , + ), + ], + }, + onError: { + actions: [ + actions.log((_, e) => `states.recognizing.invoke.onError "${e.data}"`, duckula.id), + actions.send((_, e) => duckula.Event.GERROR(GError.stringify(e.data))), + ], + }, + }, + on: { + [duckula.Type.TEXT] : duckula.State.Responding, + [duckula.Type.NO_TEXT] : duckula.State.Responding, + [duckula.Type.GERROR] : duckula.State.Erroring, + }, + }, + + ...responseStates(duckula.id), + }, +}) + +export default machine diff --git a/src/infrastructure-actors/file-to-text/mod.spec.ts b/src/infrastructure-actors/file-to-text/mod.spec.ts new file mode 100755 index 0000000..82b2931 --- /dev/null +++ b/src/infrastructure-actors/file-to-text/mod.spec.ts @@ -0,0 +1,30 @@ +#!/usr/bin/env -S node --no-warnings --loader ts-node/esm +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import { test } from 'tstest' + +import type { Duckula } from 'mailbox' + +import * as mod from './mod.js' + +test('mod is a Duckula', async t => { + const duckula: Duckula = mod + t.ok(duckula, 'should satisfy Duckula Interface for mod export') +}) diff --git a/src/infrastructure-actors/file-to-text/mod.ts b/src/infrastructure-actors/file-to-text/mod.ts new file mode 100644 index 0000000..30e0ac6 --- /dev/null +++ b/src/infrastructure-actors/file-to-text/mod.ts @@ -0,0 +1,35 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import duckula from './duckula.js' +import machine from './machine.js' + +export { FIXTURES } from './fixtures.js' + +export const { + id, + Event, + State, + Type, + initialContext, +} = duckula + +export { + machine, +} diff --git a/src/infrastructure-actors/file-to-text/speech-to-text.spec.ts b/src/infrastructure-actors/file-to-text/speech-to-text.spec.ts new file mode 100755 index 0000000..5070a07 --- /dev/null +++ b/src/infrastructure-actors/file-to-text/speech-to-text.spec.ts @@ -0,0 +1,38 @@ +#!/usr/bin/env -S node --no-warnings --loader ts-node/esm +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import { test } from 'tstest' + +import { getSilkFixtures } from '../../fixtures/get-silk-fixtures.js' + +import { speechToText } from './speech-to-text.js' +import { FileBox } from 'file-box' + +test('stt() smoke testing', async t => { + const silkFixtures = await getSilkFixtures() + const result = await speechToText(silkFixtures.fileBox) + t.equal(result, silkFixtures.text, 'should recognize correct text: ' + silkFixtures.text) +}) + +test('stt() throws exception for unknown data', async t => { + const fileBox = FileBox.fromBase64('aGVsbG8=', 'test.unknown') + await t.rejects(() => speechToText(fileBox), 'should reject for unknown data') +}) diff --git a/src/stt.ts b/src/infrastructure-actors/file-to-text/speech-to-text.ts similarity index 73% rename from src/stt.ts rename to src/infrastructure-actors/file-to-text/speech-to-text.ts index 4664895..f8c70e9 100644 --- a/src/stt.ts +++ b/src/infrastructure-actors/file-to-text/speech-to-text.ts @@ -1,3 +1,25 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import * as uuid from 'uuid' +import type { FileBoxInterface } from 'file-box' + /** * 文档中心 > 语音识别 > API 文档 > 录音文件识别极速版相关接口 > 录音文件识别极速版 * @link https://cloud.tencent.com/document/product/1093/52097 @@ -7,13 +29,8 @@ */ /* eslint-disable sort-keys */ -import * as TencentCloud from 'tencentcloud-sdk-nodejs' -import type { SentenceRecognitionRequest } from 'tencentcloud-sdk-nodejs/tencentcloud/services/asr/v20190614/asr_models' - -import * as uuid from 'uuid' -import type { - FileBoxInterface, -} from 'file-box' +import * as TencentCloud from 'tencentcloud-sdk-nodejs' +import type { SentenceRecognitionRequest } from 'tencentcloud-sdk-nodejs/tencentcloud/services/asr/v20190614/asr_models' const AsrClient = TencentCloud.asr.v20190614.Client @@ -29,16 +46,38 @@ const clientConfig = { }, // 可选配置实例 profile: {}, - // 产品地域 + /** + * 产品地域 @link https://intl.cloud.tencent.com/document/product/213/6091 + */ region: 'na-siliconvalley', } as const // 实例化要请求产品(以cvm为例)的client对象 const client = new AsrClient(clientConfig) -async function stt ( - fileBox: FileBoxInterface, +export async function speechToText ( + fileBox?: FileBoxInterface | Promise, ): Promise { + if (!fileBox) { + return '' + } + + if (fileBox instanceof Promise) { + fileBox = await fileBox + } + + let voiceFormat = fileBox.name.split('.').pop() + if (!voiceFormat) { + throw new Error('no ext for fileBox name: ' + fileBox.name) + } + + switch (voiceFormat) { + case 'sil': + voiceFormat = 'silk' + break + default: + throw new Error('ext not supported: ' + voiceFormat) + } const req: SentenceRecognitionRequest = { /** @@ -73,7 +112,7 @@ async function stt ( * Huan(202111): wav、pcm、ogg-opus、speex、silk、mp3、m4a、aac * @see https://cloud.tencent.com/document/product/1093/52097 */ - VoiceFormat: fileBox.name.split('.').pop() as any, // FIXME: check the file extension + VoiceFormat: voiceFormat, // FIXME: check the file extension /** * 用户端对此任务的唯一标识,用户自助生成,用于用户查找识别结果。 */ @@ -120,7 +159,3 @@ async function stt ( const data = await client.SentenceRecognition(req) return data.Result } - -export { - stt, -} diff --git a/src/infrastructure-actors/mod.ts b/src/infrastructure-actors/mod.ts new file mode 100644 index 0000000..4bd37ac --- /dev/null +++ b/src/infrastructure-actors/mod.ts @@ -0,0 +1,21 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +export * as FileToText from './file-to-text/mod.js' +export * as TextToIntents from './text-to-intents/mod.js' diff --git a/src/infrastructure-actors/text-to-intents/duckula.ts b/src/infrastructure-actors/text-to-intents/duckula.ts new file mode 100644 index 0000000..59bb7ac --- /dev/null +++ b/src/infrastructure-actors/text-to-intents/duckula.ts @@ -0,0 +1,60 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +/* eslint-disable sort-keys */ +import * as Mailbox from 'mailbox' + +import * as duck from '../../duck/mod.js' + +export interface Context {} + +const duckula = Mailbox.duckularize({ + id: 'TextToIntents', + events: [ duck.Event, [ + /** + * Request + */ + 'TEXT', + /** + * Response + */ + 'INTENTS', + 'GERROR', + /** + * Internal + */ + ] ], + states: [ duck.State, [ + 'Idle', + 'Initializing', + 'Understanding', + 'Responding', + 'Responded', + 'Erroring', + 'Errored', + ] ], + initialContext: {} as Context, +}) + +export type Event = ReturnType +export type Events = { + [key in keyof typeof duckula.Event]: ReturnType +} + +export default duckula diff --git a/src/infrastructure-actors/text-to-intents/machine.spec.ts b/src/infrastructure-actors/text-to-intents/machine.spec.ts new file mode 100755 index 0000000..5078e33 --- /dev/null +++ b/src/infrastructure-actors/text-to-intents/machine.spec.ts @@ -0,0 +1,90 @@ +#!/usr/bin/env -S node --no-warnings --loader ts-node/esm +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +/* eslint-disable sort-keys */ +import { + AnyEventObject, + createMachine, + EventObject, + interpret, +} from 'xstate' +import { test } from 'tstest' +import * as Mailbox from 'mailbox' +import { Observable, firstValueFrom } from 'rxjs' +import { filter } from 'rxjs/operators' +import { isActionOf } from 'typesafe-actions' + +import * as duck from '../../duck/mod.js' + +import { FIXTURES } from '../../intents/fixtures.js' +import machine from './machine.js' + +test('IntentActor happy path smoke testing', async t => { + const mailbox = Mailbox.from(machine) + mailbox.open() + + const consumerMachine = createMachine({ + on: { + '*': { + actions: [ + Mailbox.actions.proxy('TestMachine')(mailbox), + ], + }, + }, + }) + + const eventList: AnyEventObject[] = [] + const interpreter = interpret(consumerMachine) + .onEvent(e => eventList.push(e)) + .start() + + for (const [ texts, expectedIntents ] of FIXTURES()) { + for (const text of texts) { + const future = firstValueFrom( + new Observable( + subscribe => { + interpreter.onEvent(e => subscribe.next(e)) + }, + ).pipe( + filter(isActionOf(duck.Event.INTENTS)), + ), + ) + + const TEXT = duck.Event.TEXT(text) + + eventList.length = 0 + interpreter.send(TEXT) + + // await new Promise(resolve => setTimeout(resolve, 10)) + // eventList.forEach(e => console.info(e)) + await future + t.same( + eventList, + [ + TEXT, + duck.Event.INTENTS(expectedIntents), + ], + `should get expected intents [${expectedIntents}] for text "${text}"`, + ) + } + } + + interpreter.stop() +}) diff --git a/src/infrastructure-actors/text-to-intents/machine.ts b/src/infrastructure-actors/text-to-intents/machine.ts new file mode 100644 index 0000000..c2646d3 --- /dev/null +++ b/src/infrastructure-actors/text-to-intents/machine.ts @@ -0,0 +1,106 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +/* eslint-disable sort-keys */ +import { createMachine, actions } from 'xstate' +import * as Mailbox from 'mailbox' +import { isActionOf } from 'typesafe-actions' +import { GError } from 'gerror' + +import { responseStates } from '../../actor-utils/response-states.js' +import { textToIntents, Intent } from '../../intents/mod.js' + +import duckula, { Context, Event, Events } from './duckula.js' + +const machine = createMachine< + Context, + Event +>({ + id: duckula.id, + context: duckula.initialContext, + + initial: duckula.State.Initializing, + states: { + [duckula.State.Initializing]: { + entry: [ + actions.log(ctx => `states.Initializing.entry context ${JSON.stringify(ctx)}`, duckula.id), + ], + always: duckula.State.Idle, + }, + + /** + * + * Idle + * + * 1. received TEXT -> transition to Understanding + * + */ + + [duckula.State.Idle]: { + entry: [ + actions.log('states.Idle.entry', duckula.id), + Mailbox.actions.idle(duckula.id), + ], + on: { + [duckula.Type.TEXT]: duckula.State.Understanding, + '*': duckula.State.Idle, + }, + }, + + /** + * + * Understanding + * + * 1. received TEXT -> invoke textToIntents + * 2. received invoke.done -> emit INTENTS + * 3. received invoke.error -> emit GERROR + * + * 4. received INTENTS -> transition to Responded + * 5. received GERROR -> transition to Errored + * + */ + [duckula.State.Understanding]: { + entry: [ + actions.log((_, e) => `states.Understanding.entry TEXT: "${e.payload.text}"`, duckula.id), + ], + invoke: { + src: (_, e) => isActionOf(duckula.Event.TEXT, e) + ? textToIntents(e.payload.text) + : () => { throw new Error(`isActionOf(${e.type}) unexpected.`) }, + onDone: { + actions: [ + actions.log((_, e) => `states.Understanding.invoke.onDone INTENTS: ${JSON.stringify(e.data)}`, duckula.id), + actions.send((_, e) => duckula.Event.INTENTS(e.data || [ Intent.Unknown ])), + ], + }, + onError: { + actions: actions.send((_, e) => duckula.Event.GERROR(GError.stringify(e.data))), + }, + }, + on: { + [duckula.Type.INTENTS] : duckula.State.Responding, + [duckula.Type.GERROR] : duckula.State.Erroring, + }, + }, + + ...responseStates(duckula.id), + }, +}) + +export default machine diff --git a/src/infrastructure-actors/text-to-intents/mod.spec.ts b/src/infrastructure-actors/text-to-intents/mod.spec.ts new file mode 100755 index 0000000..82b2931 --- /dev/null +++ b/src/infrastructure-actors/text-to-intents/mod.spec.ts @@ -0,0 +1,30 @@ +#!/usr/bin/env -S node --no-warnings --loader ts-node/esm +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import { test } from 'tstest' + +import type { Duckula } from 'mailbox' + +import * as mod from './mod.js' + +test('mod is a Duckula', async t => { + const duckula: Duckula = mod + t.ok(duckula, 'should satisfy Duckula Interface for mod export') +}) diff --git a/src/infrastructure-actors/text-to-intents/mod.ts b/src/infrastructure-actors/text-to-intents/mod.ts new file mode 100644 index 0000000..3acc8d0 --- /dev/null +++ b/src/infrastructure-actors/text-to-intents/mod.ts @@ -0,0 +1,35 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import duckula from './duckula.js' +import machine from './machine.js' + +export { Intent, FIXTURES } from '../../intents/mod.js' + +export const { + id, + Event, + State, + Type, + initialContext, +} = duckula + +export { + machine, +} diff --git a/src/intents/fixtures.ts b/src/intents/fixtures.ts new file mode 100644 index 0000000..eee17ea --- /dev/null +++ b/src/intents/fixtures.ts @@ -0,0 +1,100 @@ +import { Intent } from './intent-fancy-enum.js' + +export const FIXTURES = () => [ + /** + * Testing only + */ + [ + [ '测试三个Intents(f861-48b6-b691)' ], + [ Intent.Start, Intent.Stop, Intent.Unknown ], + ], + [ + [ '测试大可乐两个,统一冰红茶三箱。' ], + [ Intent.CocaCola ], + ], + + /** + * Special values + */ + [ + [ '' ], + [], + ], + [ + [ '!@#$%^&*()_+-=' ], + [ Intent.Unknown ], + ], + + /** + * Meeting Intents + */ + [ + [ '开始' ], + [ Intent.Start ], + ], + [ + [ + '停止', + '结束', + ], + [ Intent.Stop ], + ], + [ + [ + 'yes', + '是', + '好', + '对', + ], + [ Intent.Affirm ], + ], + [ + [ + 'no', + '不', + '否', + ], + [ Intent.Deny ], + ], + [ + [ + '下一步', + '/next', + 'next', + 'NEXT', + ], + [ Intent.Next ], + ], + [ + [ + '上一步', + '返回', + '/back', + 'back', + ], + [ Intent.Back ], + ], + [ + [ + '取消', + 'cancel', + ], + [ Intent.Cancel ], + ], + [ + [ '继续' ], + [ Intent.Continue ], + ], + [ + [ '完成' ], + [ Intent.Complete ], + ], + [ + [ '结束' ], + [ Intent.Finish ], + ], + [ + [ '帮助', 'help' ], + [ Intent.Help ], + ], +] as const diff --git a/src/intents/intent-fancy-enum.ts b/src/intents/intent-fancy-enum.ts new file mode 100644 index 0000000..56e496a --- /dev/null +++ b/src/intents/intent-fancy-enum.ts @@ -0,0 +1,12 @@ +/* eslint-disable no-redeclare */ +import * as intents from './intents.js' + +/** + * Huan(202204): We are using a "Fancy Enum" instead of a TypeScript native `enum` at here, + * because the below tweet from @BenLesh said: + * + * @link https://twitter.com/huan_us/status/1511260462544998404 + */ + +export type Intent = typeof intents[keyof typeof intents] +export const Intent = intents diff --git a/src/intents/intents.ts b/src/intents/intents.ts new file mode 100644 index 0000000..65f2e65 --- /dev/null +++ b/src/intents/intents.ts @@ -0,0 +1,50 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +export const Unknown = 'Unknown' + +/** + * For Debuging & Testing + */ +export const CocaCola = 'CocaCola' + +export const Start = 'Start' +export const Stop = 'Stop' + +export const Complete = 'Complete' +export const Finish = 'Finish' +/** + * When to use previous/next vs. back/forward? (and what about back/next?) + * @link https://ux.stackexchange.com/questions/3364/when-to-use-previous-next-vs-back-forward-and-what-about-back-next + */ +export const Next = 'Next' +export const Back = 'Back' + +export const Affirm = 'Affirm' +export const Deny = 'Deny' + +export const Cancel = 'Cancel' + +export const Add = 'Add' + +export const Pause = 'Pause' +export const Continue = 'Continue' + +export const Help = 'Help' diff --git a/src/intents/mod.ts b/src/intents/mod.ts new file mode 100644 index 0000000..c2565a9 --- /dev/null +++ b/src/intents/mod.ts @@ -0,0 +1,3 @@ +export { FIXTURES } from './fixtures.js' +export { Intent } from './intent-fancy-enum.js' +export { textToIntents } from './text-to-intents.js' diff --git a/src/intents/text-to-intents.spec.ts b/src/intents/text-to-intents.spec.ts new file mode 100755 index 0000000..edea536 --- /dev/null +++ b/src/intents/text-to-intents.spec.ts @@ -0,0 +1,38 @@ +#!/usr/bin/env -S node --no-warnings --loader ts-node/esm +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +/* eslint-disable sort-keys */ + +import { test } from 'tstest' + +import { FIXTURES } from './fixtures.js' + +import { textToIntents } from './text-to-intents.js' + +test('textToIntents()', async t => { + for (const [ textList, intentList ] of FIXTURES()) { + for (const text of textList) { + const results = await textToIntents(text) + for (const intent of intentList) { + t.ok(results.includes(intent), `should contain Intent.${intent} in "${text}" intents: [${results}]`) + } + } + } +}) diff --git a/src/intents/text-to-intents.ts b/src/intents/text-to-intents.ts new file mode 100644 index 0000000..bf8ffe0 --- /dev/null +++ b/src/intents/text-to-intents.ts @@ -0,0 +1,132 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import { Intent } from './intent-fancy-enum.js' + +const INTENT_PATTERNS = [ + /** + * Testing only + */ + [ + [ + Intent.Start, + Intent.Stop, + Intent.Unknown, + ], + [ + /^测试三个Intents(f861-48b6-b691)$/i, + ], + ], + [ + [ + Intent.CocaCola, + ], + [ + /** + * Match all keywords in the sentence + * + * SO: Regex to match string containing two names in any order + * @link https://stackoverflow.com/a/4389683/1123955 + */ + /^(?=.*可乐)(?=.*两个)(?=.*统一)(?=.*红茶)(?=.*三箱).*$/, + ], + ], + + /** + * Production values + */ + [ + [ Intent.Start ], + [ + /^\/start$/i, + /开始|开会/i, + ], + ], + [ + [ Intent.Stop ], + [ + /^\/stop$/i, + /开完|结束|结会|停止/i, + ], + ], + [ + [ Intent.Affirm ], + [ + /^\/(confirm|affirm|yes|ok)$/i, + /^yes|ok|对|好|是|是的|对的|好的|没错|可以啊|好啊|可以的|可以的$/i, + ], + ], + [ + [ Intent.Deny ], + [ + /^\/(no|deny|cancel)$/i, + /^no|否|不|不是|不确认|不对|不要|不好|不行|不可以|没有$/i, + ], + ], + [ + [ Intent.Next ], + [ + /^\/(next|forward)$/i, + /^next|下一步$/i, + ], + ], + [ + [ Intent.Back ], + [ + /^\/(back|prev|previous)$/i, + /^back|上一步|回退|退回|后退$/i, + ], + ], + [ + [ Intent.Cancel ], + [ + /^\/cancel$/i, + /取消|cancel/i, + ], + ], + [ + [ Intent.Continue ], + [ + /^\/continue$/i, + /^继续$/i, + ], + ], +] as const + +export const textToIntents = async (text?: string): Promise => { + const intentList: Intent[] = [] + + if (!text) { + return intentList + } + + for (const [ intents, res ] of INTENT_PATTERNS) { + for (const regex of res) { + if (regex.test(text)) { + intentList.push(...intents) + } + } + } + + if (intentList.length <= 0) { + intentList.push(Intent.Unknown) + } + + return intentList +} diff --git a/src/ioc/ioc-dispose.spec.ts b/src/ioc/ioc-dispose.spec.ts new file mode 100755 index 0000000..7a858e0 --- /dev/null +++ b/src/ioc/ioc-dispose.spec.ts @@ -0,0 +1,42 @@ +#!/usr/bin/env -S node --no-warnings --loader ts-node/esm +/* eslint-disable sort-keys */ + +import { test, sinon } from 'tstest' +import { Disposable, createInjector } from 'typed-inject' + +test('dispose()', async t => { + const spy = sinon.spy() + + enum InjectionToken { + Foo = 'Foo' + } + + function fooFactory (): Disposable { + spy('fooFactory()') + return { + dispose: () => { + spy('~fooFactory()') + }, + } + } + + const injector = createInjector() + .provideFactory(InjectionToken.Foo, fooFactory) + + test.inject = [InjectionToken.Foo] as const + function test (foo: Object) { + void foo + spy('test()') + return 42 + } + + const result = injector.injectFunction(test) + t.equal(result, 42, 'should get 42 as return value') + + await injector.dispose() + t.same(spy.args, [ + ['fooFactory()'], + ['test()'], + ['~fooFactory()'], + ], 'should dispose all factories') +}) diff --git a/src/ioc/ioc.ts b/src/ioc/ioc.ts new file mode 100644 index 0000000..0aa2d59 --- /dev/null +++ b/src/ioc/ioc.ts @@ -0,0 +1,66 @@ +#!/usr/bin/env -S node --no-warnings --loader ts-node/esm +import { createInjector } from 'typed-inject' +import type { Wechaty } from 'wechaty' +import type * as Mailbox from 'mailbox' +import type { Observable } from 'rxjs' + +// import type { +// // Ducks, +// Bundle, +// } from 'ducks' +// import type { +// Duck as WechatyDuck, +// } from 'wechaty-redux' + +import * as actors from '../domain-actors/mod.js' + +import { InjectionToken } from './tokens.js' + +resolveAll.inject = [ + InjectionToken.WechatyMailbox, + InjectionToken.IntentMailbox, + InjectionToken.FeedbackMailbox, + InjectionToken.RegisterMailbox, + InjectionToken.Logger, +] as const + +function resolveAll ( + wechatyMailbox : Mailbox.Interface, + intentMailbox : Mailbox.Interface, + feedbackMailbox : Mailbox.Interface, + registerMailbox : Mailbox.Interface, + wechaty : Wechaty, + logger : Mailbox.Options['logger'], +) { + return { + logger, + mailbox: { + feedback: feedbackMailbox, + intent: intentMailbox, + register: registerMailbox, + wechaty: wechatyMailbox, + }, + wechaty, + } +} + +interface IocOptions { + bus$ : Observable, + devTools? : Mailbox.Options['devTools'] + logger? : Mailbox.Options['logger'] +} + +const createBot5Injector = (options: IocOptions) => createInjector() + .provideValue(InjectionToken.Bus$, options.bus$) + .provideValue(InjectionToken.DevTools, options.devTools) + .provideValue(InjectionToken.Logger, options.logger) + // .provideValue(InjectionToken.WechatyDuck, options.wechatyDuck) + // + .provideFactory(InjectionToken.WechatyMailbox, actors.wechaty.mailboxFactory) + .provideFactory(InjectionToken.IntentMailbox, actors.intent.mailboxFactory) + .provideFactory(InjectionToken.RegisterMailbox, actors.Register.mailboxFactory) + .provideFactory(InjectionToken.FeedbackMailbox, actors.Feedback.mailboxFactory) + +export { + createBot5Injector, +} diff --git a/src/ioc/mod.ts b/src/ioc/mod.ts new file mode 100644 index 0000000..5c8444b --- /dev/null +++ b/src/ioc/mod.ts @@ -0,0 +1,5 @@ +import { InjectionToken } from './tokens.js' + +export { + InjectionToken, +} diff --git a/src/ioc/tokens.ts b/src/ioc/tokens.ts new file mode 100644 index 0000000..d4d87f2 --- /dev/null +++ b/src/ioc/tokens.ts @@ -0,0 +1,25 @@ +enum InjectionToken { + Bus$ = 'Bus$', + Logger = 'Logger', + DevTools = 'DevTools', + // + // WechatyDuck = 'WechatyDuck', + // + WechatyMailbox = 'WechatyMailbox', + IntentMailbox = 'IntentMailbox', + FeedbackMailbox = 'FeedbackMailbox', + RegisterMailbox = 'RegisterMailbox', + MeetingMailbox = 'MeetingMailbox', + NoticeMailbox = 'NoticeMailbox', + + // + // PuppetGetter = 'PuppetGetter', + // WechatyGetter = 'WechatyGetter', + + // + WechatyCqrsBus$ = 'WechatyCqrsBus$' +} + +export { + InjectionToken, +} diff --git a/src/mod.ts b/src/mod.ts index 19c4f36..6ca053d 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -1,9 +1,22 @@ -export { - Bot5Assistant, -} from './plugin.js' -export type { - Bot5AssistantConfig, -} from './config.js' -export { - VERSION, -} from './version.js' +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +export { Bot5Assistant } from './plugin.js' +export type { Bot5AssistantConfig } from './config.js' +export { VERSION } from './version.js' diff --git a/src/plugin.spec.ts b/src/plugin.spec.ts index d387a95..0791acc 100755 --- a/src/plugin.spec.ts +++ b/src/plugin.spec.ts @@ -1,5 +1,23 @@ #!/usr/bin/env -S node --no-warnings --loader ts-node/esm - +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2016 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ import { test } from 'tstest' import { @@ -10,6 +28,6 @@ import { Bot5Assistant, } from './plugin.js' -test('VoteOut()', async t => { +test('Bot5Assistant()', async t => { t.doesNotThrow(() => validatePlugin(Bot5Assistant), 'should pass the validation') }) diff --git a/src/plugin.ts b/src/plugin.ts index a17a8b0..31abaca 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1,24 +1,42 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2016 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ import { Wechaty, - type, log, + types, WechatyPlugin, -} from 'wechaty' - +} from 'wechaty' import { matchers, talkers, -} from 'wechaty-plugin-contrib' +} from 'wechaty-plugin-contrib' +import type * as Mailbox from 'mailbox' + +import * as Actors from './domain-actors/mod.js' -import type { - Bot5AssistantConfig, -} from './config.js' +import type { Bot5AssistantConfig } from './config.js' import { processMessage } from './bot5-qingyu.js' -import { getInterpreter } from './fsm/meeting-interpreter.js' export interface Bot5AssistantContext { - fsm: ReturnType + actor: Mailbox.Address wechaty: Wechaty, } @@ -27,13 +45,13 @@ const dongOptions: talkers.MessageTalkerOptions = [ ] export function Bot5Assistant (config: Bot5AssistantConfig): WechatyPlugin { - log.verbose('WechatyPluginContrib', 'Bot5Assistant(%s)', JSON.stringify(config)) + log.verbose('bot5-assistant', 'Bot5Assistant(%s)', JSON.stringify(config)) const isMeetingRoom = matchers.roomMatcher(config.room) const talkDong = talkers.messageTalker<{ inMeeting: string }>(dongOptions) return function Bot5AssistantPlugin (wechaty: Wechaty) { - log.verbose('WechatyPluginContrib', 'Bot5Assistant() Bot5AssistantPlugin(%s)', wechaty) + log.verbose('bot5-assistant', 'Bot5Assistant() Bot5AssistantPlugin(%s)', wechaty) const context: Bot5AssistantContext = { fsm: getInterpreter(wechaty), @@ -44,7 +62,7 @@ export function Bot5Assistant (config: Bot5AssistantConfig): WechatyPlugin { /** * message validation */ - if (message.type() !== type.Message.Text) { return } + if (message.type() !== types.Message.Text) { return } if (message.self()) { return } const room = message.room() diff --git a/src/pure-functions/is-defined.ts b/src/pure-functions/is-defined.ts new file mode 100644 index 0000000..c0b7530 --- /dev/null +++ b/src/pure-functions/is-defined.ts @@ -0,0 +1,26 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +/** + * SO: Filter undefined from RxJS Observable + * @link https://stackoverflow.com/a/65959350/1123955 + */ +export function isDefined (value: T, ..._: any[]): value is Exclude { + return !!value +} diff --git a/src/fsm/wait-for-selector.ts b/src/pure-functions/wait-for-selector.ts similarity index 54% rename from src/fsm/wait-for-selector.ts rename to src/pure-functions/wait-for-selector.ts index 3cd092f..c218f08 100644 --- a/src/fsm/wait-for-selector.ts +++ b/src/pure-functions/wait-for-selector.ts @@ -1,3 +1,22 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ import { Observable, firstValueFrom, diff --git a/src/stt.spec.ts b/src/stt.spec.ts deleted file mode 100755 index cd97790..0000000 --- a/src/stt.spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -#!/usr/bin/env -S node --no-warnings --loader ts-node/esm - -import { test } from 'tstest' - -import { FileBox } from 'file-box' - -import path from 'path' -import { fileURLToPath } from 'url' - -import { stt } from './stt.js' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) - -test('stt() smoke testing', async t => { - const EXPECTED = '大可乐两个,统一冰红茶三箱。' - - const fileBox = FileBox.fromFile(path.join( - __dirname, - '../tests/fixtures/sample.silk', - )) - - const text = await stt(fileBox) - - t.equal(text, EXPECTED, 'should recognize correct') -}) diff --git a/src/wechaty-actor/NOTICE b/src/wechaty-actor/NOTICE new file mode 100644 index 0000000..4b8f8d6 --- /dev/null +++ b/src/wechaty-actor/NOTICE @@ -0,0 +1,8 @@ +Wechaty is a Conversational SDK for Chatbot Makers. +Copyright 2016-now Huan (李卓桓) and Wechaty Community Contributors. + +This product includes software developed at +The Wechaty Organization (https://github.com/wechaty). + +This software contains code derived from the Stackoverflow, +including various modifications by GitHub. diff --git a/src/wechaty-actor/cqrs/skip-self-message-payload$.ts b/src/wechaty-actor/cqrs/skip-self-message-payload$.ts new file mode 100644 index 0000000..8891125 --- /dev/null +++ b/src/wechaty-actor/cqrs/skip-self-message-payload$.ts @@ -0,0 +1,22 @@ +import { of } from 'rxjs' +import { filter, map, mergeMap } from 'rxjs/operators' +import * as CQRS from 'wechaty-cqrs' + +import { isDefined } from '../../pure-functions/is-defined.js' + +/** + * Skip self message payload + */ +export const skipSelfMessagePayload$ = (bus$: CQRS.Bus) => (puppetId: string) => + of(CQRS.queries.GetCurrentUserIdQuery(puppetId)).pipe( + mergeMap(CQRS.execute$(bus$)), + map(response => response.payload.contactId), + mergeMap(currentUserId => bus$.pipe( + filter(CQRS.is(CQRS.events.MessageReceivedEvent)), + map(e => CQRS.queries.GetMessagePayloadQuery(puppetId, e.payload.messageId)), + mergeMap(CQRS.execute$(bus$)), + map(response => response.payload.message), + filter(isDefined), + filter(message => message.talkerId !== currentUserId), + )), + ) diff --git a/src/wechaty-actor/dto.ts b/src/wechaty-actor/dto.ts new file mode 100644 index 0000000..b416305 --- /dev/null +++ b/src/wechaty-actor/dto.ts @@ -0,0 +1,28 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import type * as CQRS from 'wechaty-cqrs' + +export type CommandQuery = + | ReturnType + | ReturnType + +export type ResponseEvent = + | ReturnType + | ReturnType diff --git a/src/wechaty-actor/duck/constants.ts b/src/wechaty-actor/duck/constants.ts new file mode 100644 index 0000000..c61ce4d --- /dev/null +++ b/src/wechaty-actor/duck/constants.ts @@ -0,0 +1,20 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +export const ID = 'WechatyActor' diff --git a/src/wechaty-actor/duck/context.ts b/src/wechaty-actor/duck/context.ts new file mode 100644 index 0000000..81eab4f --- /dev/null +++ b/src/wechaty-actor/duck/context.ts @@ -0,0 +1,34 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +export interface Context { + puppetId : string +} + +/** + * use JSON.parse() to prevent the initial context from being changed + */ +export function initialContext ( + puppetId : string, +): Context { + const context: Context = { + puppetId, + } + return JSON.parse(JSON.stringify(context)) +} diff --git a/src/wechaty-actor/duck/event-fancy-enum.ts b/src/wechaty-actor/duck/event-fancy-enum.ts new file mode 100644 index 0000000..3939c7a --- /dev/null +++ b/src/wechaty-actor/duck/event-fancy-enum.ts @@ -0,0 +1,14 @@ +import * as events from './events.js' + +/** + * Huan(202204): We are using a "Fancy Enum" instead of a TypeScript native `enum` at here, + * because the below tweet from @BenLesh said: + * + * @link https://twitter.com/huan_us/status/1511260462544998404 + */ + +export const Event = events +// eslint-disable-next-line no-redeclare +export type Event = { + [key in keyof typeof Event]: ReturnType +} diff --git a/src/wechaty-actor/duck/events.ts b/src/wechaty-actor/duck/events.ts new file mode 100644 index 0000000..f0ec8e2 --- /dev/null +++ b/src/wechaty-actor/duck/events.ts @@ -0,0 +1,51 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import { createAction } from 'typesafe-actions' + +import type { CommandQuery, ResponseEvent } from '../dto.js' + +import * as types from './types.js' + +export const NOP = createAction(types.NOP)() +export const IDLE = createAction(types.IDLE)() + +/** + * Error + */ +const payloadGError = (gerror: string) => ({ gerror }) +export const GERROR = createAction(types.GERROR, payloadGError)() + +/** + * Execute + */ +const payloadExecute = (commandQuery: CommandQuery) => ({ commandQuery }) +export const EXECUTE = createAction(types.EXECUTE, payloadExecute)() + +const payloadResponse = (response: ResponseEvent) => ({ response }) +export const RESPONSE = createAction(types.RESPONSE, payloadResponse)() + +/** + * Batched + */ +const payloadBatch = (commandQueryList: CommandQuery[]) => ({ commandQueryList }) +export const BATCH = createAction(types.BATCH, payloadBatch)() + +const payloadBatchResponse = (responseList: ResponseEvent[]) => ({ responseList }) +export const BATCH_RESPONSE = createAction(types.BATCH_RESPONSE, payloadBatchResponse)() diff --git a/src/wechaty-actor/duck/mod.ts b/src/wechaty-actor/duck/mod.ts new file mode 100644 index 0000000..b8c2690 --- /dev/null +++ b/src/wechaty-actor/duck/mod.ts @@ -0,0 +1,13 @@ +export { ID } from './constants.js' + +export * from './context.js' + +/** + * Huan(202204): We are using a "Fancy Enum" instead of a TypeScript native `enum` at here, + * because the below tweet from @BenLesh said: + * + * @link https://twitter.com/huan_us/status/1511260462544998404 + */ +export * from './event-fancy-enum.js' +export * from './state-fancy-enum.js' +export * from './type-fancy-enum.js' diff --git a/src/wechaty-actor/duck/state-fancy-enum.ts b/src/wechaty-actor/duck/state-fancy-enum.ts new file mode 100644 index 0000000..9879d5b --- /dev/null +++ b/src/wechaty-actor/duck/state-fancy-enum.ts @@ -0,0 +1,12 @@ +import * as states from './states.js' + +/** + * Huan(202204): We are using a "Fancy Enum" instead of a TypeScript native `enum` at here, + * because the below tweet from @BenLesh said: + * + * @link https://twitter.com/huan_us/status/1511260462544998404 + */ + +export const State = states +// eslint-disable-next-line no-redeclare +export type State = typeof State[keyof typeof State] diff --git a/src/wechaty-actor/duck/states.ts b/src/wechaty-actor/duck/states.ts new file mode 100644 index 0000000..855d439 --- /dev/null +++ b/src/wechaty-actor/duck/states.ts @@ -0,0 +1,29 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +export const Initializing = 'wechaty-actor/Initializing' +export const Idle = 'wechaty-actor/Idle' +export const Classifying = 'wechaty-actor/Classifying' +export const Erroring = 'wechaty-actor/Erroring' + +export const Executing = 'wechaty-actor/Executing' +export const Responding = 'wechaty-actor/Responding' + +export const Batching = 'wechaty-actor/Batching' +export const BatchResponding = 'wechaty-actor/BatchResponding' diff --git a/src/wechaty-actor/duck/type-fancy-enum.ts b/src/wechaty-actor/duck/type-fancy-enum.ts new file mode 100644 index 0000000..dfea17a --- /dev/null +++ b/src/wechaty-actor/duck/type-fancy-enum.ts @@ -0,0 +1,12 @@ +import * as types from './types.js' + +/** + * Huan(202204): We are using a "Fancy Enum" instead of a TypeScript native `enum` at here, + * because the below tweet from @BenLesh said: + * + * @link https://twitter.com/huan_us/status/1511260462544998404 + */ + +export const Type = types +// eslint-disable-next-line no-redeclare +export type Type = typeof Type[keyof typeof Type] diff --git a/src/wechaty-actor/duck/types.ts b/src/wechaty-actor/duck/types.ts new file mode 100644 index 0000000..5a46bd2 --- /dev/null +++ b/src/wechaty-actor/duck/types.ts @@ -0,0 +1,29 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2016 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +export const NOP = 'wechaty-actor/NOP' +export const IDLE = 'wechaty-actor/IDLE' +export const GERROR = 'wechaty-actor/GERROR' + +export const EXECUTE = 'wechaty-actor/EXECUTE' +export const RESPONSE = 'wechaty-actor/RESPONSE' + +export const BATCH = 'wechaty-actor/BATCH' +export const BATCH_RESPONSE = 'wechaty-actor/BATCH_RESPONSE' diff --git a/src/wechaty-actor/duckula.ts b/src/wechaty-actor/duckula.ts new file mode 100644 index 0000000..82bf2b7 --- /dev/null +++ b/src/wechaty-actor/duckula.ts @@ -0,0 +1,71 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +/* eslint-disable sort-keys */ +import type * as CQRS from 'wechaty-cqrs' +import * as Mailbox from 'mailbox' + +import * as duck from './duck/mod.js' + +export interface Context { + bus$ : CQRS.Bus + puppetId? : string +} + +const duckula = Mailbox.duckularize({ + id: 'WechatyActor', + events: [ duck.Event, [ + /** + * Request + */ + // CQRS.commands.*, + // CQRS.queries.*, + 'BATCH', + /** + * Response + */ + // CQRS.responses.* + 'BATCH_RESPONSE', + 'GERROR', + /** + * Internal + */ + 'IDLE', + 'EXECUTE', + 'RESPONSE', + ] ], + states: [ duck.State, [ + 'Initializing', + 'Idle', + 'Classifying', + 'Erroring', + 'Executing', + 'Responding', + 'Batching', + 'BatchResponding', + ] ], + initialContext: {} as Context, +}) + +export type Event = ReturnType +export type Events = { + [key in keyof typeof duckula.Event]: ReturnType +} + +export default duckula diff --git a/src/wechaty-actor/from.ts b/src/wechaty-actor/from.ts new file mode 100644 index 0000000..d3975a2 --- /dev/null +++ b/src/wechaty-actor/from.ts @@ -0,0 +1,39 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import type * as CQRS from 'wechaty-cqrs' +import * as Mailbox from 'mailbox' + +import machine from './machine.js' + +const from = ( + bus$ : CQRS.Bus, + puppetId? : string, +) => { + const mailbox = Mailbox.from( + machine.withContext({ + bus$, + puppetId, + }), + ) + mailbox.open() + return mailbox +} + +export default from diff --git a/src/wechaty-actor/loop-actor.spec.ts b/src/wechaty-actor/loop-actor.spec.ts new file mode 100755 index 0000000..1bbd33e --- /dev/null +++ b/src/wechaty-actor/loop-actor.spec.ts @@ -0,0 +1,202 @@ +#!/usr/bin/env -S node --no-warnings --loader ts-node/esm +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +/* eslint-disable sort-keys */ +import { + AnyEventObject, + interpret, + createMachine, + actions, + EventObject, +} from 'xstate' +import { test } from 'tstest' +import * as Mailbox from 'mailbox' +import { createAction } from 'typesafe-actions' + +test('loop machine', async t => { + + const QUERY = createAction('QUERY', (id?: number) => ({ id }))() + const RESPONSE = createAction('RESPONSE', (id?: number, data?: number) => id && data ? ({ id, data }) : undefined)() + + const serviceMachine = createMachine({ + id: 'service', + initial: 'idle', + states: { + idle: { + on: { + QUERY: 'querying', + }, + }, + querying: { + entry: [ + actions.sendParent<{}, ReturnType>( + (_, e) => RESPONSE(e.payload.id, (e.payload.id ?? 0) * 2), + ), + ], + always: [ + { target: 'idle' }, + ], + }, + }, + }) + + const payloadLoop = (ids: number[] = []) => ({ ids }) + const payloadDone = (datas: ReturnType['payload'][] = []) => ({ datas }) + + const LOOP = createAction('LOOP', payloadLoop)() + const NEXT = createAction('NEXT')() + const DONE = createAction('DONE', payloadDone)() + + type Event = ReturnType< + | typeof LOOP + | typeof NEXT + | typeof DONE + | typeof RESPONSE + > + + interface Context { + ids: number[] + datas: Exclude['payload'], undefined>[] + } + + // [].map(i => i*2) + // map + // actor CommandQuerey[] + // => Response[] + + const mapMachine = createMachine({ + id: 'map', + context: { + ids: [], + datas: [], + }, + initial: 'idle', + invoke: { + id: 'service', + src: serviceMachine, + }, + states: { + idle: { + on: { + LOOP: { + actions: [ + actions.assign({ + datas: _ => [], + ids: (_, e) => e.payload.ids, + }), + ], + target: 'looping', + }, + }, + }, + looping: { + entry: actions.choose([ + { + cond: ctx => ctx.ids.length > ctx.datas.length, + actions: [ + actions.send(NEXT()), + ], + }, + { + actions: [ + actions.send(ctx => DONE(ctx.datas)), + ], + }, + ]), + on: { + NEXT: 'next', + DONE: 'done', + }, + }, + next: { + entry: [ + actions.log(ctx => `next: ${JSON.stringify(ctx.datas)}`), + actions.send( + ctx => QUERY( + ctx.ids.filter( + id => !ctx.datas + .find(data => data.id === id), + )[0], + ), + { to: 'service' }, + ), + ], + on: { + RESPONSE: { + actions: [ + actions.log((_, e) => `response: ${JSON.stringify(e.payload)}`), + actions.choose([ + { + cond: (_, e) => !!e.payload, + actions: [ + actions.assign>({ + datas: (ctx, e) => [ ...ctx.datas, e.payload! ], + }), + ], + }, + ]), + ], + target: 'looping', + }, + }, + }, + done: { + entry: [ + actions.log(ctx => `done: ${JSON.stringify(ctx.datas)}`), + actions.sendParent(ctx => DONE(ctx.datas)), + ], + }, + }, + }) + + const consumerMachine = createMachine({ + id: 'consumer', + invoke: { + id: 'map', + src: mapMachine, + }, + on: { + '*': { + actions: Mailbox.actions.proxy('consumer')('map'), + }, + }, + }) + + const consumerEventList: AnyEventObject[] = [] + const interpreter = interpret(consumerMachine) + interpreter + .onEvent(e => consumerEventList.push(e)) + .start() + + interpreter.send(LOOP([ 1, 2, 3 ])) + t.same(consumerEventList, [ + { + type: 'xstate.init', + }, + LOOP([ 1, 2, 3 ]), + DONE([ + RESPONSE(1, 2), + RESPONSE(2, 4), + RESPONSE(3, 6), + ].map(r => r.payload)), + ], 'should get events') + + interpreter.stop() +}) diff --git a/src/wechaty-actor/machine.spec.ts b/src/wechaty-actor/machine.spec.ts new file mode 100755 index 0000000..b25db4d --- /dev/null +++ b/src/wechaty-actor/machine.spec.ts @@ -0,0 +1,402 @@ +#!/usr/bin/env -S node --no-warnings --loader ts-node/esm +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +/* eslint-disable sort-keys */ +import { + createMachine, + interpret, + AnyEventObject, +} from 'xstate' +import { firstValueFrom } from 'rxjs' +import { filter } from 'rxjs/operators' +import { test, sinon } from 'tstest' +import type * as WECHATY from 'wechaty' +import * as CQRS from 'wechaty-cqrs' +import * as PUPPET from 'wechaty-puppet' +import { createFixture } from 'wechaty-mocker' +import * as Mailbox from 'mailbox' +import { isActionOf } from 'typesafe-actions' + +import * as duck from './duck/mod.js' + +import machine from './machine.js' + +test('wechatyMachine Mailbox actor validation', async t => { + const wechatyMachine = machine.withContext({} as any) + t.doesNotThrow(() => Mailbox.helpers.validate(wechatyMachine), 'should pass validate') +}) + +test('wechatyActor SAY with concurrency', async t => { + const sandbox = sinon.createSandbox({ + useFakeTimers: true, + }) + + for await (const { + wechaty: { + wechaty, + room, + }, + } of createFixture()) { + const bus$ = CQRS.from(wechaty) + + const wechatyMachine = machine.withContext({ + bus$, + puppetId: wechaty.puppet.id, + }) + + const WECHATY_MACHINE_ID = 'wechaty-machine-id' + + const testActor = createMachine({ + invoke: { + id: WECHATY_MACHINE_ID, + src: Mailbox.helpers.wrap(wechatyMachine), + }, + on: { + '*': { + actions: Mailbox.actions.proxy('TestActor')(WECHATY_MACHINE_ID), + }, + }, + }) + + const EXPECTED_TEXT = 'hello world' + + const eventList: AnyEventObject[] = [] + const interpreter = interpret(testActor) + .onTransition(s => eventList.push(s.event)) + .start() + + eventList.length = 0 + + const spy = sinon.spy() + wechaty.on('message', spy) + + for (const i of [ ...Array(3).keys() ]) { + interpreter.send( + CQRS.commands.SendMessageCommand( + wechaty.puppet.id, + room.id, + PUPPET.payloads.sayable.text(EXPECTED_TEXT + i), + ), + ) + } + // eventList.forEach(e => console.info(e.type)) + + /** + * Wait async: `wechaty.puppet.messageSendText()` + */ + await sandbox.clock.runToLastAsync() + t.equal(spy.callCount, 3, 'should emit 3 messages') + t.equal(spy.args[1]![0].type(), wechaty.Message.Type.Text, 'should emit text message') + t.equal(spy.args[1]![0].text(), EXPECTED_TEXT + 1, `should emit "${EXPECTED_TEXT}1"`) + + interpreter.stop() + sandbox.restore() + } +}) + +test('wechatyMachine interpreter smoke testing', async t => { + const WECHATY_MACHINE_ID = 'wechaty-machine-id' + + for await (const { + wechaty: { + wechaty, + player, + room, + }, + } of createFixture()) { + + const bus$ = CQRS.from(wechaty) + + const wechatyMachine = machine.withContext({ + bus$, + puppetId: wechaty.puppet.id, + }) + + const testMachine = createMachine({ + invoke: { + src: wechatyMachine, + id: WECHATY_MACHINE_ID, + }, + on: { + '*': { + actions: Mailbox.actions.proxy('TestMachine')(WECHATY_MACHINE_ID), + }, + }, + }) + + const eventList: AnyEventObject[] = [] + const interpreter = interpret(testMachine) + .onTransition(s => eventList.push(s.event)) + .start() + + const EXPECTED_TEXT = 'hello world' + const future = new Promise(resolve => + wechaty.once('message', resolve), + ) + interpreter.send( + CQRS.commands.SendMessageCommand(wechaty.puppet.id, room.id, PUPPET.payloads.sayable.text(EXPECTED_TEXT, [ player.id ])), + ) + const message = await future + t.equal(message.text(), EXPECTED_TEXT, `should get said message "${EXPECTED_TEXT}"`) + + interpreter.stop() + } + +}) + +test('wechatyMachine isLoggedIn & currentUserId & authQrCode', async t => { + const WECHATY_MACHINE_ID = 'wechaty-machine-id' + + for await (const { + wechaty: { + wechaty, + bot, + }, + } of createFixture()) { + + const bus$ = CQRS.from(wechaty) + + const wechatyMachine = machine.withContext({ + bus$, + puppetId: wechaty.puppet.id, + }) + + const testMachine = createMachine({ + invoke: { + src : wechatyMachine, + id : WECHATY_MACHINE_ID, + }, + on: { + '*': { + actions: Mailbox.actions.proxy('TestMachine')(WECHATY_MACHINE_ID), + }, + }, + }) + + const eventList: AnyEventObject[] = [] + const interpreter = interpret(testMachine) + .onTransition(s => eventList.push(s.event)) + .start() + + // We need to wait the bullet to fly a while because here we are testing the machine (instead of Mailbox actor) + await new Promise(setImmediate) + + /** + * isLoggedIn + */ + const future = firstValueFrom(bus$.pipe( + filter(CQRS.is(CQRS.responses.GetIsLoggedInQueryResponse)), + )) + interpreter.send( + CQRS.queries.GetIsLoggedInQuery(wechaty.puppet.id), + ) + const response = await future + t.equal(response.payload.isLoggedIn, true, 'should get isLoggedIn response from bot') + + // We need to wait the bullet to fly a while because here we are testing the machine (instead of Mailbox actor) + await new Promise(setImmediate) + + /** + * currentUserId + */ + const future2 = firstValueFrom(bus$.pipe( + filter(CQRS.is(CQRS.responses.GetCurrentUserIdQueryResponse)), + )) + interpreter.send( + CQRS.queries.GetCurrentUserIdQuery(wechaty.puppet.id), + ) + const response2 = await future2 + t.equal(response2.payload.contactId, bot.id, 'should get currentUesrId response from bot') + + // We need to wait the bullet to fly a while because here we are testing the machine (instead of Mailbox actor) + await new Promise(setImmediate) + + /** + * authQrCode + */ + const future3 = firstValueFrom(bus$.pipe( + filter(CQRS.is(CQRS.responses.GetAuthQrCodeQueryResponse)), + )) + interpreter.send( + CQRS.queries.GetAuthQrCodeQuery(wechaty.puppet.id), + ) + const response3 = await future3 + t.equal(response3.payload.qrcode, undefined, 'should get auth qrcode response from bot') + + interpreter.stop() + } + +}) + +test('wechatyMachine EXECUTE & RESPONSE events', async t => { + for await (const { + wechaty: { + wechaty, + }, + } of createFixture()) { + + const bus$ = CQRS.from(wechaty) + const puppetId = wechaty.puppet.id + + const wechatyMailbox = Mailbox.from( + machine.withContext({ + bus$, + puppetId: wechaty.puppet.id, + }), + ) + + wechatyMailbox.open() + + const testMachine = createMachine({ + id: 'testMachine', + on: { + '*': { + actions: wechatyMailbox.address.send((_, e) => e), + }, + }, + }) + + const eventList: AnyEventObject[] = [] + const interpreter = interpret(testMachine) + .onEvent(e => eventList.push(e)) + .start() + + const future = new Promise>( + resolve => interpreter.onEvent( + e => { + // console.info('onEvent', e) + if (isActionOf(CQRS.responses.GetIsLoggedInQueryResponse, e)) { + resolve(e) + } + }, + ), + ) + + const query = CQRS.queries.GetIsLoggedInQuery(puppetId) + + interpreter.send(query) + + const EXPECTED = CQRS.responses.GetIsLoggedInQueryResponse({ + ...query.meta, + isLoggedIn: true, + }) + + // await new Promise(resolve => setTimeout(resolve, 100)) + // console.info(eventList) + + const response = await future + + // console.info('###', response) + + t.same( + JSON.parse(JSON.stringify(response)), + JSON.parse(JSON.stringify(EXPECTED)), + 'should get CQRS.responses.GetIsLoggedInQueryResponse response from CQRS.queries.GetIsLoggedInQuery', + ) + + interpreter.stop() + } + +}) + +test('Wechaty machine BATCH & BATCH_RESPONSE events', async t => { + for await (const { + wechaty: { + wechaty, + bot, + }, + } of createFixture()) { + + const bus$ = CQRS.from(wechaty) + const puppetId = wechaty.puppet.id + + const wechatyMailbox = Mailbox.from( + machine.withContext({ + bus$, + puppetId: wechaty.puppet.id, + }), + ) + + wechatyMailbox.open() + + const testMachine = createMachine({ + id: 'testMachine', + on: { + '*': { + actions: wechatyMailbox.address.send((_, e) => e), + }, + }, + }) + + const eventList: AnyEventObject[] = [] + const interpreter = interpret(testMachine) + .onEvent(e => eventList.push(e)) + .start() + + const future = new Promise( + resolve => interpreter.onEvent( + e => { + // console.info('onEvent', e) + if (isActionOf(duck.Event.BATCH_RESPONSE, e)) { + resolve(e) + } + }, + ), + ) + + interpreter.send( + duck.Event.BATCH([ + CQRS.queries.GetIsLoggedInQuery(puppetId), + CQRS.queries.GetCurrentUserIdQuery(puppetId), + CQRS.queries.GetAuthQrCodeQuery(puppetId), + ]), + ) + + const res = { + id: CQRS.uuid.NIL, + puppetId, + } + + const EXPECTED = duck.Event.BATCH_RESPONSE([ + CQRS.responses.GetIsLoggedInQueryResponse({ ...res, isLoggedIn: true }), + CQRS.responses.GetCurrentUserIdQueryResponse({ ...res, contactId: bot.id }), + CQRS.responses.GetAuthQrCodeQueryResponse({ ...res, qrcode: undefined }), + ]) + + // await new Promise(resolve => setTimeout(resolve, 100)) + // eventList.forEach(e => console.info(e)) + + const response = await future + response.payload.responseList.forEach(r => { 'id' in r.meta && (r.meta.id = CQRS.uuid.NIL) }) + + t.same( + JSON.parse(JSON.stringify(response)), + JSON.parse(JSON.stringify(EXPECTED)), + 'should get batch response', + ) + + interpreter.stop() + } + +}) + +test('TODO: add a GERROR test', async t => { + await t.skip('TBW') +}) diff --git a/src/wechaty-actor/machine.ts b/src/wechaty-actor/machine.ts new file mode 100644 index 0000000..01c71ae --- /dev/null +++ b/src/wechaty-actor/machine.ts @@ -0,0 +1,249 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +/* eslint-disable sort-keys */ +import * as CQRS from 'wechaty-cqrs' +import { actions, createMachine } from 'xstate' +import { GError } from 'gerror' +import { isActionOf } from 'typesafe-actions' +import * as Mailbox from 'mailbox' + +import * as services from './services/mod.js' + +import type { CommandQuery } from './dto.js' +import duckula, { Context, Event, Events } from './duckula.js' + +const machine = createMachine< + Context, + Event +>({ + id: duckula.id, + + initial: duckula.State.Initializing, + states: { + [duckula.State.Initializing]: { + entry: [ + actions.log(ctx => `states.Initializing.entry context ${JSON.stringify({ ...ctx, bus$: 'bus$' })}`, duckula.id), + ], + always: duckula.State.Idle, + }, + + [duckula.State.Idle]: { + entry: [ + actions.log('states.Idle.entry', duckula.id), + Mailbox.actions.idle(duckula.id), + ], + on: { + /** + * Wechaty Actor accepts and responses: + * + * 1. CQRS.commands.* & CQRS.queries.* + * 2. BATCH() + */ + '*': duckula.State.Classifying, + }, + }, + + /** + * + * Classifying EVENTs + * + * 1. received CQRS.commands.* & CQRS.queries.* -> emit EXECUTE + * 2. received BATCH -> emit BATCH + * 3. '*' -> emit IDLE + * + * 4. received EXECUTE -> tarnsit to Executing + * 5. received BATCH -> transit to Executing + * 6. received IDLE -> transit to Idle + * + */ + [duckula.State.Classifying]: { + entry: [ + actions.log('states.Classifying.entry', duckula.id), + actions.choose([ + { + cond: (_, e) => CQRS.is( + Object.values({ + ...CQRS.commands, + ...CQRS.queries, + }), + e, + ), + actions: [ + actions.log((_, e) => `states.Classifying.entry found Command/Query [${e.type}]`, duckula.id), + actions.send((_, e) => duckula.Event.EXECUTE(e as CommandQuery)), + ], + }, + { + cond: (_, e) => isActionOf(duckula.Event.BATCH, e), + actions: [ + actions.log('states.Classifying.entry found BATCH', duckula.id), + actions.send((_, e) => e), // <- duckula.Event.batch / types.BATCH + ], + }, + { + cond: (_, e) => isActionOf(duckula.Event.EXECUTE, e), + actions: [ + actions.log((_, e) => `states.Classifying.entry received EXECUTE event, this is unnecessary: we should send raw [${(e as Events['EXECUTE']).payload.commandQuery.type}] instead.`, duckula.id), + actions.send((_, e) => e), + ], + }, + { + actions: [ + actions.log((_, e) => `states.Classifying.entry neither BATCH nor Command/Query, ignore [${e.type}]`, duckula.id), + actions.send(duckula.Event.IDLE()), + ], + }, + ]), + ], + on: { + [duckula.Type.IDLE] : duckula.State.Idle, + [duckula.Type.EXECUTE] : duckula.State.Executing, + [duckula.Type.BATCH] : duckula.State.Batching, + }, + }, + + /** + * + * Execute CQRS.commands.* & CQRS.queries.* + * + * 1. received EXECUTE -> emit RESPONSE + * + */ + [duckula.State.Executing]: { + entry: [ + actions.log((_, e) => [ + 'states.Executing.entry EXECUTE [', + (e as ReturnType) + .payload + .commandQuery + .type, + ']', + ].join(''), duckula.id), + ], + invoke: { + src: 'execute', + onDone: { actions: actions.send((_, e) => duckula.Event.RESPONSE(e.data)) }, + onError: { actions: actions.send((_, e) => duckula.Event.GERROR(GError.stringify(e.data))) }, + }, + on: { + [duckula.Type.RESPONSE] : duckula.State.Responding, + [duckula.Type.GERROR] : duckula.State.Erroring, + }, + }, + + /** + * + * Response CQRS.commands.* & CQRS.queries.* + * + * Unwrap the RESPONSE and emit CQRS.responses.* + * 1. received RESPONSE -> emit [RESPONSE].payload.response + * 2. transit to Idle + * + */ + [duckula.State.Responding]: { + entry: [ + actions.log>( + (_, e) => `states.Responding.entry RESPONSE [${e.payload.response.type}]`, + duckula.id, + ), + Mailbox.actions.reply>( + (_, e) => e.payload.response, + ), + ], + always: duckula.State.Idle, + }, + + /** + * + * Batch Execute CQRS.commands.* & CQRS.queries.* + * + */ + [duckula.State.Batching]: { + entry: [ + actions.log((_, e) => [ + 'states.Batching.entry BATCH [', + [ + ...new Set( + (e as ReturnType) + .payload + .commandQueryList + .map(cq => cq.type), + ), + ].join(','), + ']#', + (e as ReturnType) + .payload + .commandQueryList + .length, + ].join(''), duckula.id), + ], + invoke: { + src: 'batch', + onDone: { actions: actions.send((_, e) => duckula.Event.BATCH_RESPONSE(e.data)) }, + onError: { actions: actions.send((_, e) => duckula.Event.GERROR(GError.stringify(e.data))) }, + }, + on: { + [duckula.Type.BATCH_RESPONSE] : duckula.State.BatchResponding, + [duckula.Type.GERROR] : duckula.State.Erroring, + }, + }, + + /** + * + * Batch Response CQRS.commands.* & CQRS.queries.* + * + */ + [duckula.State.BatchResponding]: { + entry: [ + actions.log((_, e) => [ + `states.BatchResponding.entry ${e.type} [`, + CQRS.is(duckula.Event.BATCH_RESPONSE, e) + ? [ ...new Set( + e.payload.responseList + .map(r => r.type), + ) ].join(',') + : e.type, + ']', + ].join(''), duckula.id), + Mailbox.actions.reply((_, e) => e), + ], + always: duckula.State.Idle, + }, + + [duckula.State.Erroring]: { + entry: [ + actions.log((_, e) => `states.Erroring.entry [${e.type}] ${e.payload.gerror}`, duckula.id), + Mailbox.actions.reply((_, e) => e), + ], + always: duckula.State.Idle, + }, + + }, +}, { + /** + * FIXME: batch is never used in the machine definition + */ + services: { + batch : (ctx, e) => services.batch(ctx, e), + execute : services.execute, + }, +}) + +export default machine diff --git a/src/wechaty-actor/mod.spec.ts b/src/wechaty-actor/mod.spec.ts new file mode 100755 index 0000000..91cd55c --- /dev/null +++ b/src/wechaty-actor/mod.spec.ts @@ -0,0 +1,34 @@ +#!/usr/bin/env -S node --no-warnings --loader ts-node/esm +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import { test } from 'tstest' +import type { Duckula } from 'mailbox' + +import * as mod from './mod.js' + +test('mod is a Duckula', async t => { + const duckula: Duckula = mod + t.ok(duckula, 'should satisfy Duckula Interface for mod export') +}) + +test('mod has `from()` method', async t => { + t.ok(mod.from, 'should have `from()` method') + t.equal(typeof mod.from, 'function', 'from() should be function') +}) diff --git a/src/wechaty-actor/mod.ts b/src/wechaty-actor/mod.ts new file mode 100644 index 0000000..b7e76ed --- /dev/null +++ b/src/wechaty-actor/mod.ts @@ -0,0 +1,36 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import duckula, { type Events } from './duckula.js' +import from from './from.js' +import machine from './machine.js' + +export const Event = duckula.Event as Omit +export const { + id, + State, + initialContext, + Type, +} = duckula + +export { + machine, + from, + type Events, +} diff --git a/src/wechaty-actor/services/batch.ts b/src/wechaty-actor/services/batch.ts new file mode 100644 index 0000000..43614cc --- /dev/null +++ b/src/wechaty-actor/services/batch.ts @@ -0,0 +1,46 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import { isActionOf } from 'typesafe-actions' +import type { AnyEventObject } from 'xstate' + +import duckula from '../duckula.js' + +import { execute } from './execute.js' + +export const batch = async ( + ctx: ReturnType, + e: AnyEventObject, +) => { + + if (!isActionOf(duckula.Event.BATCH, e)) { + throw new Error(`${duckula.id} service.batch: unknown event [${e.type}]`) + } + + return Promise.all( + e.payload.commandQueryList + .map(commandQuery => + execute( + ctx, + duckula.Event.EXECUTE(commandQuery), + ), + ), + ) + +} diff --git a/src/wechaty-actor/services/execute.ts b/src/wechaty-actor/services/execute.ts new file mode 100644 index 0000000..bba66e1 --- /dev/null +++ b/src/wechaty-actor/services/execute.ts @@ -0,0 +1,53 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import { isActionOf } from 'typesafe-actions' +import * as CQRS from 'wechaty-cqrs' +import { firstValueFrom } from 'rxjs' +import type { AnyEventObject } from 'xstate' + +import duckula from '../duckula.js' + +export const execute = async ( + ctx: ReturnType, + e: AnyEventObject, +) => { + if (!isActionOf(duckula.Event.EXECUTE, e)) { + throw new Error(`${duckula.id} service.execut: unknown event [${e.type}]`) + } + + const cq = e.payload.commandQuery + + if (ctx.puppetId) { + if (cq.meta.puppetId !== ctx.puppetId && cq.meta.puppetId !== CQRS.uuid.NIL) { + throw new Error(`${duckula.id} services.execute() puppetId mismatch. (given: "${cq.meta.puppetId}", expected: "${ctx.puppetId}")`) + } + + cq.meta.puppetId = ctx.puppetId + + } else { // no puppetId in context + if (!cq.meta.puppetId || cq.meta.puppetId === CQRS.uuid.NIL) { + throw new Error(`${duckula.id} services.execute() puppetId missing. (no puppetId in context, and given: "${cq.meta.puppetId}")`) + } + } + + return firstValueFrom( + CQRS.execute$(ctx.bus$)(cq), + ) +} diff --git a/src/wechaty-actor/services/mod.ts b/src/wechaty-actor/services/mod.ts new file mode 100644 index 0000000..62cf9f3 --- /dev/null +++ b/src/wechaty-actor/services/mod.ts @@ -0,0 +1,21 @@ +/** + * Wechaty Open Source Software - https://github.com/wechaty + * + * @copyright 2022 Huan LI (李卓桓) , and + * Wechaty Contributors . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +export * from './execute.js' +export * from './batch.js' diff --git a/tests/fixtures/sample.silk b/tests/fixtures/sample.sil similarity index 100% rename from tests/fixtures/sample.silk rename to tests/fixtures/sample.sil