Version: v1r37p2
Last updated: 18/05/2023
- 1. Overview
- 2. Terms and Definitions
- 3. Module protocol and format
- 4. Core API call
- 4.1. Get registered modules (
get_registered_modules
) - 4.2. Kill this module (
kill
) - 4.3. Shutdown the Core (
shutdown_core
) - 4.4. Restart the Core (
restart_core
) - 4.5. Register event hook (
register_event_hook
) - 4.6. Unregister event hook (
unregister_event_hook
) - 4.7. Send event (
send_event
) - 4.8. Register plugin (
register_plugin
) - 4.9. Unregister plugin (
unregister_plugin
) - 4.10. Ask for operator's input (
prompt
) - 4.11. Logging (
log
) - 4.12. Wait for module (
wait_for_module
) - 4.13. Get data folder location (
get_data_folder
) - 4.14. Get temp folder location (
get_temp_folder
) - 4.15. Install NPM/PNPM dependencies (
pnpm_install
) - 4.16. Install specific NPM/PNPM dependency (
pnpm_install_specific
) - 4.17. Get plugin namespace info (
get_plugin_namespace_info
) - 4.18. Get default database ID (
get_default_db
) - 4.19. Get database resolver by ID (
get_db_resolver
) - 4.20. Get persistent data (
get_persistent_data
) - 4.21. Set persistent data (
set_persistent_data
) - 4.22. Get operator list (
get_operator_list
) - 4.23. Wait for default database initialization (
wait_for_default_db
) - 4.24. Get registered plugins (
get_registered_plugins
)
- 4.1. Get registered modules (
- 5. Application-specific API call
- 6. Events
- 7. Extra specification for modules
- 8. Notes
NOCOM_BOT is a powerful, flexible chatbot framework that allow bot developer to extends its functionality using modules and plugins.
Because of its flexible nature, there should be a specification to allow execution and communication with different modules. This specification aim to give modules a way to be executed and communicate using pre-defined format.
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in BCP 14 [RFC2119] [RFC8174] when, and only when, they appear in all capitals, as shown here.
Commonly used terms in this specification are described below.
-
Core: The main process which handles all communication from/to different modules.
-
Module: A process that is spawned from Core or a thread inside Core, used for all functionality of NOCOM_BOT.
To ensure the flexiblity of NOCOM_BOT, any programming language can be used to make Module.
If Module is a npm-compatible (Node.js) package (with package.json
), Module will be spawned as a new Node.js-compatible process with entry point described in main
in package.json
.
If Module is a single Node.js script, then Module will be spawned as a thread in Core (Node.js worker).
If Module is source code in other languages and needs a transpiler (for example, Python), you MUST install that languages before you can use the Module. Additionally, module description MUST contains how to run the source code.
Module MUST be packed in ZIP with module.json
describing the type of Module, and how it communicates, as below (Note: It's in TypeScript to write comments and possible value, but you MUST write JSON in the ZIP file).
{
type: (
"package" | // If Module is a npm-compatible package
"script" | // If Module is a Node.js script
"executable" | // If Module is an executable (usually compiled from other languages than JS)
"code-src" // If Module is source code writen in other languages
),
namespace: string,
communicationProtocol: (
"node_ipc" | // process.send, process.on("message", ...)
"node_worker" | // require("worker_threads")
"msgpack" // See 3.2
),
scriptSrc?: string, // Only required if type = "script", describing the entry point location relative to ZIP file root.
executable?: { // Only required if type = "executable"
[platform: typeof NodeJS.os.platform() /* [1] */]: {
[cpuArch: typeof NodeJS.os.arch() /* [2] */]: string // Path relative to ZIP file root.
}
}
executableArgs?: string, // Only required if type = "executable"
transplier?: string, // Only required if type = "code-src", describing how to execute the source code.
autoRestart: boolean
}
Please note that, when Module is being executed, all content inside the ZIP file will be extracted to <profile directory>/temp/${random hex}/${module namespace}/
, and the Module's current working directory (if type is "package", "executable" or "code-src") will be set to that directory.
If Module is a Node.js-compatible process and support process.send
and process.on("message", ...)
then Module SHOULD use that to communicate with Core.
If Module is a thread inside Core (Node.js worker), it MUST get parentPort
from require('worker_threads')
and use parentPort.on("message", ...)
, parentPort.postMessage()
to communicate with Core.
Alternatively, Module (that isn't a thread inside Core) can read STDIN and write to STDOUT in a way that's described below to communicate with Core (it's in hexadecimal, but you MUST use raw bytes when communicating).
<Message header (string AAAA): 41 41 41 41> <Length in 4-byte integer, big-endian (not string!)> <MessagePack binary serialization of the object received/sent>
For example, this is a message containing the object {foo:"bar"}
.
41 41 41 41 00 00 00 09 81 A3 66 6F 6F A3 62 61 72
To determine what the module should do, a handshake is required. Module MUST NOT do or send anything before receiving a handshake from Core.
Handshake MUST be done only once, and MUST start from Core.
The Core will send a message to Module with this data to initialize handshake:
{
"type": "handshake",
"id": "Module's instance ID",
"protocol_version": "1",
"config": {} // user-defined config
}
In return, Module MUST return a message with data described below in 30 seconds or else Module will be terminated.
{
"type": "handshake_success",
"module": "<module type that Module is acting>",
"module_displayname": "<user-friendly name of the module (example: MongoDB database, Discord interface, ...)>",
"module_namespace": "<MUST match with the JSON>"
}
If Module cannot act as the requested type, Module SHOULD return a message describing error and request to be terminated.
{
"type": "handshake_fail",
"error": "<... (for example: Unknown module type)>"
}
To prevent the process from hanging, an alive-or-dead test (challenge) will be issued from Core at random to determine whether if Module is hanging or not, and Module will be killed if hanging.
Module MUST response to a challenge in 30 seconds to keep running.
The Core will send a message described below to check if Module is alive.
{
"type": "challenge",
"challenge": "<random string>"
}
If Module received a challenge, it MUST response back a message described below.
{
"type": "challenge_response",
"challenge": "<random string from Core>"
}
Note: Every API call is async, you should not block thread to wait for a call to return.
Module can call API commands of other modules. When Module want to call an API command, Module MUST send a message:
{
"type": "api_send",
"call_to": "<target Module ID>",
"call_cmd": "<target API command from target module>",
"data": "...",
"nonce": "<rolling number for each API call or random number>"
}
The target Module will receive a message indicating an API call from other modules:
{
"type": "api_call",
"call_from": "<source Module ID>",
"call_cmd": "...",
"data": "...",
"nonce": "..."
}
There are 3 possible responses for the target Module:
- The API command don't exist and cannot be called
{
"type": "api_sendresponse",
"response_to": "<source Module ID>",
"exist": false,
"nonce": "<exact nonce from request>"
}
- The API command does exist, but the execution of it failed
{
"type": "api_sendresponse",
"response_to": "<source Module ID>",
"exist": true,
"error": "...",
"data": null,
"nonce": "<exact nonce from request>"
}
Note: Error MUST NOT be an Error object from JavaScript or the native implementation. It SHOULD be a string, or number, or even null (if there isn't an error message).
- The API command does exist, and executed successfully
{
"type": "api_sendresponse",
"response_to": "<source Module ID>",
"exist": true,
"error": null,
"data": "...",
"nonce": "<exact nonce from request>"
}
When the target Module has responded, source Module will receive an API response:
{
"type": "api_response",
"response_from": "<target Module ID>",
"exist": "...",
"data": "...", // This will be null if there's an error.
"error": "...", // This will be null if error doesn't occur.
"nonce": "<exact nonce from request>"
}
Note: The Core's API will be available at module ID
core
and namespacecore
to not complicate things up.
Data: none
Return:
{
moduleID: string,
type: string,
namespace: string,
displayname: string,
running: boolean
}[]
Data: none
Return: null
Data: none
Return: null
Data: none
Return: null
Data:
{
callbackFunction: string,
eventName: string
}
Return:
{
success: boolean
}
For more information, see 6.
Data:
{
callbackFunction: string,
eventName: string
}
Return:
{
success: boolean
}
For more information, see 6.
Data:
{
eventName: string,
data: any
}
Return:
{
hasSubscribers: boolean
}
Note: This API is intended for plugin handlers (or self-hosted plugin-like Module) only.
Data:
{
pluginName: string,
namespace: string,
version: string,
author: string
}
Return:
{
conflict: boolean
}
Note: If namespace is conflicted, it will not be registered, and other plugins will not be able to call function on that plugin.
Note: This API is intended for plugin handlers (or self-hosted plugin-like Module) only.
Data:
{
namespace: string
}
Return:
{
success: boolean
}
Data:
{
promptInfo: string,
promptType: "string" | "yes-no",
defaultValue?: string | boolean
}
Return:
{
data: string | boolean
}
Data:
{
level: "verbose" | "critical" | "error" | "warn" | "info" | "debug",
namespace: string,
data: any[]
}
Return: null
Data:
{
moduleNamespace: string,
timeout?: number //ms
}
Return (on loaded): true
Return (timed out): false
Note: Timeout option is optional, if unspecified then it will be Infinity (no timeout)
Data: none
Return: string with data folder location
Data: none
Return: string with temp folder location
Data:
{
path: string
}
Return:
{
success: boolean,
error?: string
}
Data:
{
path: string,
dep: string
}
Return:
{
success: boolean,
error?: string
}
Data:
{
namespace: string
}
Return:
{
exist: boolean,
pluginName?: string,
version?: string,
author?: string,
resolver?: string
}
Data: none
Return:
{
databaseID: number,
resolver: string
}
Data:
{
databaseID: number
}
Return:
{
resolver: string
}
This API throw error if database doesn't exist.
This can be used to restore data for functionality.
Data: none
Return: data stored
This can be used to save data in case of crashing.
Data: any, data you want to store
Return: true
Data: none
Return:
string[]
Data: none
Return: null
Data: none
Return:
{
namespace: string,
pluginName: string,
version: string,
author: string,
resolver: string
}[]
Note: If you are creating an interface that is using the module types defined below, you MUST implement all API call to maintain compatibility. Additional API commands MAY be defined if Module needs that.
Note: Only one instance per interface will be created. The Core will expect the interface handler to handle multiple accounts.
Data:
{
interfaceID: number,
loginData: any // module defined
}
Return:
{
success: boolean,
interfaceID: number,
accountName: string,
rawAccountID: string,
formattedAccountID: string,
accountAdditionalData: any
}
Data:
{
interfaceID: number
}
Return: null
Data:
{
interfaceID: number,
userID: string
}
Return:
{
name: string,
... // additional data is module-defined.
}
Data:
{
interfaceID: number,
channelID: string
}
Return:
{
channelName: string,
... // additional data is module-defined.
}
Data:
{
interfaceID: number,
content: string,
attachments: {
filename: string,
url: string // URL (data URI, http(s):// and file:// is allowed)
}[],
channelID: string,
replyMessageID?: string,
additionalInterfaceData: any // additional data is module-defined.
}
Return:
{
success: boolean,
messageID: string,
additionalInterfaceData: any // additional data is module-defined.
}
Note: Only one instance per database type will be created. The Core will expect the database module to handle multiple connections.
Data: none
Return: default parameter for connecting to database
Data: none
Return:
{
databaseID: number,
databaseName: string
}[]
Data:
{
databaseID: number,
params: any // module-defined
}
Return:
{
success: boolean,
databaseID: number
}
Data:
{
databaseID: number,
table: string,
key: string
}
Return:
{
success: boolean,
data: any
}
Data:
{
databaseID: number,
table: string,
key: string,
value: any
}
Return:
{
success: boolean
}
Data:
{
databaseID: number,
table: string,
key: string
}
Return:
{
success: boolean
}
Data:
{
databaseID: number,
table: string
}
Return:
{
success: boolean
}
Data:
{
databaseID: number
}
Return: null
Note: Not to be confused with Module! Plugin is used to add features in more secure, easier way. Plugin is handled by plugin handler, not Core.
Data:
{
filename?: string,
pathname?: string
}
Return:
{
compatible: boolean,
pluginName?: string,
namespace?: string,
version?: string,
author?: string
}
Data:
{
filename?: string,
pathname?: string
}
Return:
{
loaded: boolean,
error?: string,
pluginName?: string,
namespace?: string,
version?: string,
author?: string
}
Note: Plugin handler MUST register plugin (4.8) even when Core call this API.
Data:
{
namespace: string
}
Return:
{
error?: string
}
Data:
{
namespace: string,
funcName: string,
args: any[]
}
Return:
{
error?: string,
returnData: any
}
Data:
{
pathname: string
}
Returns:
{
valid: string[]
}
Note: Namespaced commands call will automaticially redirect to correct plugin (as Command handler already knows what module handle that namespace).
Data:
{
namespace: string,
command: string,
funcName: string,
description: {
fallback: string,
[ISOLanguageCode: string]: string
},
args: {
fallback: string,
[ISOLanguageCode: string]: string
},
argsName?: string[],
compatibilty: string[]
}
Note 1: args
SHOULD be in this standardized format:
<required arg1> <required arg2> [optional arg3]
For example (the entire command):
/rps <amount> [rock/paper/scissor]
Note 2: argsName is used for Discord slash command (or equivalent). It MUST only contain English character only, no spaces allowed.
Return:
{
success: boolean,
error?: string
}
Data:
{
namespace: string,
command: string
}
Return:
{
success: boolean,
error?: string
}
Data: none
Return:
{
commands: {
namespace: string,
command: string,
funcName: string,
description: {
fallback: string,
[ISOLanguageCode: string]: string
},
args: {
fallback: string,
[ISOLanguageCode: string]: string
},
argsName?: string[],
compatibilty: string[]
}[],
count: number
}
Data: none
Return:
{
language: string
}
Data:
({
formattedUserID: string
} |
{
formattedChannelID: string
} |
{
formattedGuildID: string
}) & {
lang: string
}
Return:
{
success: boolean
}
Data:
{
formattedUserID: string
} |
{
formattedChannelID: string
} |
{
formattedGuildID: string
}
Return:
{
lang: string,
isDefault: boolean,
isOverriden: boolean,
isInterfaceGiven: boolean
}
Sometimes you need to broadcast to a lot of modules interested in a topic without knowing which modules subscribed. This is where Events come in.
Events is part of the Core module. To register/unregister an event, use API call 4.5 and 4.6. Use API call 4.7 to send events.
The callback API will be called from Core with the following data when a module send an event:
{
calledFrom: string, // module ID
eventName: string,
eventData: any
}
Note: There are some edge cases (for example: Discord slash commands) where commands is not received as string to be parsed. Interface handler SHOULD convert that to format compatible with this if possible.
Data:
{
content: string,
attachments: {
filename: string,
url: string // http(s)/file/base64-encoded data URI
}[],
mentions: {
[formattedUserID: string]: {
start: number,
length: number
}
},
interfaceHandlerName: string,
interfaceID: number,
messageID: string,
formattedMessageID: string,
channelID: string,
formattedChannelID: string,
guildID: string,
formattedGuildID: string,
senderID: string,
formattedSenderID: string,
isDM: boolean,
language?: string,
isOperator: boolean,
additionalInterfaceData?: any
}
Note: Unless interface is Discord or other similar type, guildID/formattedGuildID will be the same as channelID/formattedChannelID.
Data:
{
isRegisterEvent: boolean, // true if registered, false if unregistered
namespace: string,
command: string,
description?: {
fallback: string,
[languageCode: string]: string
},
args?: {
fallback: string,
[languageCode: string]: string
},
argsName?: string[],
compatibility?: string[]
}
If plugin handler register a command, the function behind MUST accept a call with this parameter/argument:
{
interfaceID: number,
interfaceHandlerName: string,
cmd: string,
args: string[],
attachments: {
filename: string,
url: string // http(s)/file/base64-encoded data URI
}[],
mentions: {
[formattedUserID: string]: {
start: number,
length: number
}
},
messageID: string,
formattedMessageID: string,
channelID: string,
formattedChannelID: string,
guildID: string,
formattedGuildID: string,
senderID: string,
formattedSenderID: string,
originalContent: string,
prefix: string,
language: string,
isOperator: boolean,
additionalInterfaceData?: any
}
Function MUST return a response with this format, or return an error:
{
content: string,
attachments?: {
filename: string,
url: string // http(s)/file/base64-encoded data URI
}[],
additionalInterfaceData?: any
}
All language code MUST follow BCP 47 [RFC4647] [RFC5646] standard (also known as IETF language tag). Some examples of IETF language tag format is at below:
- English (United States):
en-US
- English (United Kingdom):
en-GB
- Vietnamese:
vi
- Japanese:
ja