Skip to content

Latest commit

 

History

History
1241 lines (989 loc) · 28.3 KB

Module_OLD.md

File metadata and controls

1241 lines (989 loc) · 28.3 KB

NOCOM_BOT Module Specification

Version: v1r37p2
Last updated: 18/05/2023

Table of contents

1. Overview

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.

2. Terms and Definitions

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.

3. Module protocol and format

3.1. Module programming language and format

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.

3.2. Core-Module communication protocol

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

3.3. Handshaking

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)>"
}

3.4. Keep-alive (Ping)

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>"
}

3.5. API call/response

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:

  1. 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>"
}
  1. 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).

  1. 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>"
}

4. Core API call

Note: The Core's API will be available at module ID core and namespace core to not complicate things up.

4.1. Get registered modules (get_registered_modules)

Data: none

Return:

{
    moduleID: string,
    type: string,
    namespace: string,
    displayname: string,
    running: boolean
}[]

4.2. Kill this module (kill)

Data: none

Return: null

4.3. Shutdown the Core (shutdown_core)

Data: none

Return: null

4.4. Restart the Core (restart_core)

Data: none

Return: null

4.5. Register event hook (register_event_hook)

Data:

{
    callbackFunction: string,
    eventName: string
}

Return:

{
    success: boolean
}

For more information, see 6.

4.6. Unregister event hook (unregister_event_hook)

Data:

{
    callbackFunction: string,
    eventName: string
}

Return:

{
    success: boolean
}

For more information, see 6.

4.7. Send event (send_event)

Data:

{
    eventName: string,
    data: any
}

Return:

{
    hasSubscribers: boolean
}

4.8. Register plugin (register_plugin)

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.

4.9. Unregister plugin (unregister_plugin)

Note: This API is intended for plugin handlers (or self-hosted plugin-like Module) only.

Data:

{
    namespace: string
}

Return:

{
    success: boolean
}

4.10. Ask for operator's input (prompt)

Data:

{
    promptInfo: string,
    promptType: "string" | "yes-no",
    defaultValue?: string | boolean
}

Return:

{
    data: string | boolean
}

4.11. Logging (log)

Data:

{
    level: "verbose" | "critical" | "error" | "warn" | "info" | "debug",
    namespace: string,
    data: any[]
}

Return: null

4.12. Wait for module (wait_for_module)

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)

4.13. Get data folder location (get_data_folder)

Data: none

Return: string with data folder location

4.14. Get temp folder location (get_temp_folder)

Data: none

Return: string with temp folder location

4.15. Install NPM/PNPM dependencies (pnpm_install)

Data:

{
    path: string
}

Return:

{
    success: boolean,
    error?: string
}

4.16. Install specific NPM/PNPM dependency (pnpm_install_specific)

Data:

{
    path: string,
    dep: string
}

Return:

{
    success: boolean,
    error?: string
}

4.17. Get plugin namespace info (get_plugin_namespace_info)

Data:

{
    namespace: string
}

Return:

{
    exist: boolean,
    pluginName?: string,
    version?: string,
    author?: string,
    resolver?: string
}

4.18. Get default database ID (get_default_db)

Data: none

Return:

{
    databaseID: number,
    resolver: string
}

4.19. Get database resolver by ID (get_db_resolver)

Data:

{
    databaseID: number
}

Return:

{
    resolver: string
}

This API throw error if database doesn't exist.

4.20. Get persistent data (get_persistent_data)

This can be used to restore data for functionality.

Data: none

Return: data stored

4.21. Set persistent data (set_persistent_data)

This can be used to save data in case of crashing.

Data: any, data you want to store

Return: true

4.22. Get operator list (get_operator_list)

Data: none

Return:

string[]

4.23. Wait for default database initialization (wait_for_default_db)

Data: none

Return: null

4.24. Get registered plugins (get_registered_plugins)

Data: none

Return:

{
    namespace: string,
    pluginName: string,
    version: string,
    author: string,
    resolver: string
}[]

5. Application-specific API call

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.

5.1. Interface handler (module type = "interface")

Note: Only one instance per interface will be created. The Core will expect the interface handler to handle multiple accounts.

5.1.1. Login (login)

Data:

{
    interfaceID: number,
    loginData: any // module defined
}

Return:

{
    success: boolean,
    interfaceID: number,
    accountName: string,
    rawAccountID: string,
    formattedAccountID: string,
    accountAdditionalData: any
}

5.1.2. Logout (logout)

Data:

{
    interfaceID: number
}

Return: null

5.1.3. Get User info (get_userinfo)

Data:

{
    interfaceID: number,
    userID: string
}

Return:

{
    name: string,
    ... // additional data is module-defined.
}

5.1.4. Get Thread/Channel info (get_channelinfo)

Data:

{
    interfaceID: number,
    channelID: string
}

Return:

{
    channelName: string,
    ... // additional data is module-defined.
}

5.1.5. Send message (send_message)

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.
}

5.2. Database (module type = "database")

Note: Only one instance per database type will be created. The Core will expect the database module to handle multiple connections.

5.2.1. Get default config (default_cfg)

Data: none

Return: default parameter for connecting to database

5.2.2. List connected database (list_db)

Data: none

Return:

{
    databaseID: number,
    databaseName: string
}[]

5.2.3. Connect database (connect_db)

Data:

{
    databaseID: number,
    params: any // module-defined
}

Return:

{
    success: boolean,
    databaseID: number
}

5.2.4. Get data (get_data)

Data:

{
    databaseID: number,
    table: string,
    key: string
}

Return:

{
    success: boolean,
    data: any
}

5.2.5. Set data (set_data)

Data:

{
    databaseID: number,
    table: string,
    key: string,
    value: any
}

Return:

{
    success: boolean
}

5.2.6. Delete data (delete_data)

Data:

{
    databaseID: number,
    table: string,
    key: string
}

Return:

{
    success: boolean
}

5.2.7. Delete table (delete_table)

Data:

{
    databaseID: number,
    table: string
}

Return:

{
    success: boolean
}

5.2.8. Disconnect database (disconnect_db)

Data:

{
    databaseID: number
}

Return: null

5.3. Plugins handler (module type = "pl_handler")

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.

5.3.1. Check plugin (check_plugin)

Data:

{
    filename?: string,
    pathname?: string
}

Return:

{
    compatible: boolean,
    pluginName?: string,
    namespace?: string,
    version?: string,
    author?: string
}

5.3.2. Load plugin (load_plugin)

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.

5.3.3. Unload plugin (unload_plugin)

Data:

{
    namespace: string
}

Return:

{
    error?: string
}

5.3.4. Call function inside plugin (plugin_call)

Data:

{
    namespace: string,
    funcName: string,
    args: any[]
}

Return:

{
    error?: string,
    returnData: any
}

5.3.5. Search for plugin inside a directory (plugin_search)

Data:

{
    pathname: string
}

Returns:

{
    valid: string[]
}

5.4. Command handler (module type = "cmd_handler")

Note: Namespaced commands call will automaticially redirect to correct plugin (as Command handler already knows what module handle that namespace).

5.4.1. Register command (register_cmd)

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
}

5.4.2. Unregister command (unregister_cmd)

Data:

{
    namespace: string,
    command: string
}

Return:

{
    success: boolean,
    error?: string
}

5.4.3. Get registered command list (cmd_list)

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
}

5.4.4. Get default configured language (get_default_lang)

Data: none

Return:

{
    language: string
}

5.4.5. Set user/channel/guild's language (set_lang)

Data:

({
    formattedUserID: string
} |
{
    formattedChannelID: string
} |
{
    formattedGuildID: string
}) & {
    lang: string
}

Return:

{
    success: boolean
}

5.4.6. Get user/channel/guild's language (get_lang)

Data:

{
    formattedUserID: string
} |
{
    formattedChannelID: string
} |
{
    formattedGuildID: string
}

Return:

{
    lang: string,
    isDefault: boolean,
    isOverriden: boolean,
    isInterfaceGiven: boolean
}

6. Events

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
}

6.1. Application-specific events

6.1.1. Message from bot users (interface_message)

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.

6.1.2. Register/unregister command event (cmdhandler_regevent)

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[]
}

7. Extra specification for modules

7.1. Plugins handler (module type = "pl_handler")

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
}

8. Notes

8.1. Localization: language code/locales

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