Skip to main content

Using State

A sociable app itself is a stateless server. So we need to keep the info of a chat/user in persistent storage. Therefore the bot can use the state data to provide services and better experiences.

Install State Module

In development, it's recommended to use FileState for easy debugging. But in production, you need to switch to other production-ready implementation, like RedisState.

You can register the state module like this:

import { FileState } from '@sociably/dev-tools';
import RedisState from '@sociably/redis-state';

const { NODE_ENV, REDIS_URL } = process.env;
const DEV = NODE_ENV !== 'production';

Sociably.createApp({
modules: [
DEV
? FileState.initModule({
path: './.state_data.json',
})
: RedisState.initModule({
clientOptions: {
url: REDIS_URL,
},
}),
//...
],
//...
})

For now the following state modules are officially supported, please check the references for more details:

Get Chat State

Once you set the state provider up, you can use the StateController service to access the state. For example:

import { makeContainer, StateController } from '@sociably/core';

app.onEvent(
makeContainer({ deps: [StateController] })(
(stateController) => async ({ event, reply }) => {
const bookmarks = await stateController
.channelState(event.channel)
.get('bookmarks');

if (bookmarks) {
await reply(`You have unread bookmarks:\n${bookmarks.join('\n')}`);
} else {
await reply('You have no saved bookmark');
}
};
);
);

controller.channelState(channel) method returns an accessor to the chat state. The state data is stored in key-value pairs, like a JavaScript Map.

accessor.get(key) resolves the value saved on a key. If no value has been saved before, it resolves undefined.

Update State

To set a state value, use the accessor.update(key, updater) method. For example:

app.onEvent(
makeContainer({ deps: [StateController] })(
(stateController) => async ({ event, reply } ) => {
if (event.type === 'text') {
const matchAdding = event.text.match(/^add (.*)$/i);

if (matchAdding) {
const newBookmark = matchAdding[1];

const bookmarks = await stateController
.channelState(event.channel)
.update(
'bookmarks',
(currentBookmarks = []) =>
[...currentBookmarks, newBookmark]
);
await reply(`You have ${bookmarks.length} bookmarks.`);
}
}
// ...
};
);
);

update takes a key and an updater function, which receives the current value and returns the new value. The returned value is then saved into the storage.

This mechanism makes it easy to update an array or object state value.

undefined Means Empty

If no value has been saved, the updater receives an undefined value. And if the updater returns undefined, the value on the key will be deleted.

We can use default parameter to handle undefined value elegantly:

(currentBookmarks = []) =>
[...currentBookmarks, newBookmark]

Cancel Updating

The new value is compared with the old value using ===. If the same value is returned, no saving action will be made. For example, this updating call is NOT going to work:

await stateController
.channelState(event.channel)
.update('my_data', (data) => {
data.foo = 'bar';
return data; // the value is the same object
});

So do not mutate the value in the updater. Always return a new one.

User State

Sometimes you might want to use the user state instead of chat state. Their scopes are different since a user can show up in many chatrooms.

To access the user state, use the controller.userState(user) method. For example:

app.onEvent(
makeContainer({ deps: [StateController] })(
(stateController) => async ({ event, reply }) => {
if (event.type === 'text') {
const matchCallMe = event.text.match(/^call me (.*)$/i);

if (matchCallMe) {
const nickname = matchCallMe[1];
await stateController
.userState(event.user)
.update('nickname', () => nickname);

return reply(`OK ${nickname}!`);
}
}

const nickname = await stateController
.userState(event.user)
.get('nickname');
await reply(nickname ? `Hi ${nickname}!` : 'What should I call you?');
}
);
);

The state accessor usage is exactly the same with channel state.

Global State

If you want to use state at the global scope, use controller.globalState(name). For example:

const newYorkWeather = await stateController
.globalState('weathers')
.get('new_york');