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:
- Messenger
- Telegram
- LINE
//...
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>
);
}
);
//...
//...
const handleChat = makeContainer({
deps: [useIntent, useUserProfile],
})(
(getIntent, getUserProfile) =>
async (
ctx: ChatEventContext & { event: { category: 'message'| 'postback' } }
) => {
const { event, reply } = ctx;
const intent = await getIntent(event);
if (!event.channel) {
return;
}
//...
const profile = await getUserProfile(event.user);
return reply(
<WithMenu todoCount={3}>
<p>Hello{profile ? `, ${profile.name}` : ''}! I'm a Todo Bot 🤖</p>
</WithMenu>
);
}
);
//...
//...
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:
import {
makeFactoryProvider,
BasicProfiler,
StateController,
SociablyUser,
SociablyProfile,
} from '@sociably/core';
// ...
const useUserProfile =
(profiler: BasicProfiler, stateController: StateController) =>
async (user: SociablyUser) => {
// ...
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:
const profile = await profiler.getUserProfile(user);
Access State​
StateController
can access the user/chat/global state data from the storage.
Like:
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:
{
"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:
//...
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/sociably-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:
//...
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.
//...
async getTodos(
channel: SociablyChannel
): 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:
import TodoController from './services/TodoController';
//...
const createApp = (options?: CreateAppOptions) => {
return Sociably.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:
- Messenger
- Telegram
- LINE
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>
);
}
);
//...
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 (!event.channel) {
return;
}
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>
);
}
);
//...
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:
{
"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:
"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.