Skip to main content

Service and State

Despite sending messages, a bot requires many other services to provide functional features. In this lesson, you’ll learn how to use the DI (dependencies injection) system to access chat state and other services.

Time to accomplish: 15 minutes

Use Services​

Calling users by their name is a common feature to improve chat experience. Let's implement it by editing handleChat like this:

src/handlers/handleChat.tsx
//...
const handleChat = makeContainer({
deps: [useIntent, useUserProfile],
})(
(getIntent, getUserProfile) =>
async (
ctx: ChatEventContext & { event: { category: 'message'| 'postback' } }
) => {
const { event, reply } = ctx;
const intent = await getIntent(event);
//...

const profile = await getUserProfile(event.user);
return reply(
<WithMenu todoCount={3}>
<p>Hello{profile ? `, ${profile.name}` : ''}! I'm a Todo Bot 🤖</p>
</WithMenu>
);
}
);
//...

Now the bot can say hello with the user's name:

Service Container​

The handleChat handler is a service container. A container declares the services it requires, and the system will inject the required dependencies at runtime.

handleChat is declared by makeContainer({ deps: [useIntent, useUserProfile] })(factoryFn). It requires the useIntent and useUserProfile services, which can be used like:

(getIntent, getUserProfile) =>    // factory fn, receivces service instances
async (context) => {/* ... */} // handler fn, receivces event context

The container function takes the required services and returns the handler function. Then the services can be used in the handler like:

  const profile = await getUserProfile(event.user);

Service Provider​

Let's go deeper to see what happens in the useUserProfile service. Check the src/services/useUserProfile.ts file, you should codes like:

src/services/useUserProfile.ts
import {
makeFactoryProvider,
BasicProfiler,
StateController,
MachinatUser,
MachinatProfile,
} from '@machinat/core';
// ...
const useUserProfile =
(profiler: BasicProfiler, stateController: StateController) =>
async (user: MachinatUser) => {
// ...
return profile;
};

export default makeFactoryProvider({
deps: [BasicProfiler, StateController],
})(useUserProfile);

useUserProfile is a service provider that requires its deps just like a container. The difference is a provider can be required as deps so we can use it in the handler.

useUserProfile uses two built-in services: BasicProfiler and StateController.

Get User Profile​

BasicProfiler fetches a user’s profile from the chat platform. Like:

src/services/useUserProfile.ts
  const profile = await profiler.getUserProfile(user);

Access State​

StateController can access the user/chat/global state data from the storage. Like:

src/services/useUserProfile.ts
  const userState = stateController.userState(user);
const cached = await userState.get<ProfileCache>('profile_cache');
if (cached) {
return cached.profile;
}

const profile = await profiler.getUserProfile(user);
if (profile) {
await userState.set<ProfileCache>('profile_cache', { profile });
}

Here we use controller.userState(user).get(key) to get the cached profile of the user. If there isn't, we fetch the profile and cache it with controller.userState(user).set(key, value).

State Storage​

The state data is stored at .state_data.json file while in development. Check it and you should see the saved profile like:

.state_data.json
{
"channelStates": {},
"userStates": {
"messenger.12345.67890": {
"profile_cache": {
"$type": "MessengerUserProfile",
"$value": {
"id": "67890",
"name": "John Doe",
"first_name": "John",
"last_name": "Doe",
"profile_pic": "https://..."
}
}
}
},
"globalStates": {}
}

Providing Services​

Despite the built-in services, you might want to make your own ones to reuse logic. Let's create a new service to handle the CRUD of todos.

Create a Service​

First add the type of todos state:

src/types.ts
//...
export type TodoState = {
currentId: number;
todos: Todo[];
finishedTodos: Todo[];
};

To not repeat similar steps, please download the TodoController.ts file with this command:

curl -o ./src/services/TodoController.ts https://raw.githubusercontent.com/machinat/todo-example/main/src/services/TodoController.ts

In the file we create a TodoController service to manage todos. Check src/services/TodoController.ts, it's declared like this:

src/services/TodoController.ts
//...
export class TodoController {
stateController: StateController;

constructor(stateController: StateController) {
this.stateController = stateController;
}
//...
}

export default makeClassProvider({
deps: [StateController],
})(TodoController);

The makeClassProvider works just like makeFactoryProvider, except that the provider is a class. It also requires StateController to save/load todos data.

Channel State​

In the TodoController we store the todos data with channelState. It works the same as userState, but it saves the data of a chat instead.

src/services/TodoController.ts
//...
async getTodos(
channel: MachinatChannel
): Promise<{ todo: null; data: TodoState }> {
const data = await this.stateController
.channelState(channel)
.get<TodoState>('todo_data');

return {
todo: null,
data: data || { currentId: 0, todos: [], finishedTodos: [] },
};
}
//...

Register Services​

A new service must be registered in the app before using it. Register the TodoController in src/app.ts like:

src/app.ts
import TodoController from './services/TodoController';
//...
const createApp = (options?: CreateAppOptions) => {
return Machinat.createApp({
modules: [/* ... */],
platforms: [/* ... */],
services: [
TodoController,
// ...
],
});
};

Use TodoController​

Now TodoController can be used like other services. We can use it to easily complete the CRUD features. Edit handleChat like this:

src/handlers/handleChat.tsx

import TodoController from '../services/TodoController';
// ...
const handleChat = makeContainer({
deps: [useIntent, useUserProfile, TodoController],
})(
(getIntent, getUserProfile, todoController) =>
async (
ctx: ChatEventContext & { event: { category: 'message' | 'postback' } }
) => {
const { event, reply } = ctx;
const intent = await getIntent(event);

if (intent.type === 'list') {
const { data } = await todoController.getTodos(event.channel);
return reply(<TodoList todos={data.todos} />);
}
if (intent.type === 'finish') {
const { todo, data } = await todoController.finishTodo(
event.channel,
intent.payload.id
);
return reply(
<WithMenu todoCount={data.todos.length}>
{todo ? (
<p>Todo "<b>{todo.name}</b>" is done!</p>
) : (
<p>This todo is closed.</p>
)}
</WithMenu>
);
}

if (event.type === 'text') {
const matchingAddTodo = event.text.match(/add(s+todo)?(.*)/i);
if (matchingAddTodo) {
const todoName = matchingAddTodo[2].trim();

const { data } = await todoController.addTodo(event.channel, todoName);
return reply(
<WithMenu todoCount={data.todos.length}>
<p>Todo "<b>{todoName}</b>" is added!</p>
</WithMenu>
);
}
}

const profile = await profiler.getUserProfile(event.user);
const { data } = await todoController.getTodos(event.channel);
return reply(
<WithMenu todoCount={data.todos.length}>
<p>Hello{profile ? `, ${profile.name}` : ''}! I'm a Todo Bot 🤖</p>
</WithMenu>
);
}
);
//...

Now try adding a todo with add todo <name> command, and check the .state_data.json file. You should see the stored todo data like:

.state_data.json
{
"userStates": {...},
"channelStates": {
"messenger.12345.psid.67890": {
"todo_data": {
"currentId": 1,
"todos": [
{
"id": 1,
"name": "Master State Service"
}
],
"finishedTodos": []
}
}
},
"globalStates": {}
}

Then press Done ✓ button in the todos list, the bot should reply like:

Check .state_data.json, the todo should be moved to the "finishedTodos" section:

.state_data.json
    "finishedTodos": [
{
"id": 1,
"name": "Master State Service"
}
]

Now our bot can provide features with real data in the state. Next, we'll make the bot understand what we say.