fwcd / d2

Command-based virtual assistant for Discord and other platforms
GNU General Public License v3.0
16 stars 5 forks source link
discord linux virtual-assistant

D2

Build

General-purpose assistant for Discord and IRC with more than 340 commands, including:

Getting Started

Locally

To build and run D2 locally, make sure to have the following installed:

On Ubuntu, run

Scripts/install-dependencies-apt

If you use another distribution, use your native package manager to install the equivalent packages.

On macOS, run

Scripts/install-dependencies-brew

Create a folder named local under the repository and add configuration files as described in the configuration section.

To install the dependencies for node packages used by D2, run

Scripts/install-node-dependencies

Finally, use swift build to build D2 and swift run to run it. With swift test you can run the test suite.

To suppress warnings, you can append -Xswiftc -suppress-warnings after swift build or swift run.

If your build fails with module redefinition errors regarding FFI includes, run Scripts/remove-commandlinetools-ffi-includes and try building again.

With Docker (Compose)

Make sure to have recent versions of Docker and Docker Compose installed and create a volume named d2local using docker volume create d2local.

In this volume, add configuration files as described in the configuration section.

See here for instructions on how to copy files into a Docker volume

You can then use docker-compose build to build the image and docker-compose up to run it (add the -d flag to run it in daemonized mode).

With Kubernetes (Helm)

Make sure to have kubectl + helm installed and connected to a Kubernetes cluster. The cluster should have a persistent volume available.

In a local cluster (where persistent volumes generally aren't provisioned automatically), you may find the d2-local-storage chart useful, which registers a persistent volume. To use it, create a folder such as ./local/k8s and run helm upgrade --install --set storage.hostPath=$PWD/local/k8s d2-local-storage Helm/d2-local-storage.

Create a values.yaml in some local location (e.g. in local or outside the repository) containing D2 configurations (see the configuration section for details on the schema):

d2:
  adminWhitelist:
    users:
    - value: 'YOUR_DISCORD_USER_ID'
      clientName: Discord
  config:
    commandPrefix: '%'
    hostInfo:
      instanceName: prod/k8s/yourDisplayName
  platformTokens:
    discord: 'YOUR_DISCORD_API_TOKEN'
  netApiKeys: # optional
    mapQuest: YOUR_MAP_QUEST_KEY
    wolframAlpha: YOUR_WOLFRAM_ALPHA_KEY
    gitlab: YOUR_GITLAB_KEY
    openweathermap: YOUR_OPENWEATHERMAP_KEY
    ...

You can now upgrade/install D2 to the cluster using helm upgrade --install -f path/to/local/values.yaml d2 Helm/d2.

To uninstall it, just run helm uninstall d2.

Note that the persistent volume storage claim by d2 might still persist after uninstalling, in which case you could do something like this (or reinstall d2-local-storage, if you installed it earlier).

Configuration

Navigate to your local folder or volume (as described in the sections above).

Required

Create a file named platformTokens.json in local containing the API tokens (at least one of them should be specified):

{
  "discord": "YOUR_DISCORD_API_TOKEN",
  "irc": [
    {
      "host": "YOUR_IRC_HOST",
      "port": 6667,
      "nickname": "YOUR_IRC_USERNAME",
      "password": "YOUR_IRC_PASSWORD"
    }
  ]
}

For more information e.g. on how to connect to the Twitch IRC API, see this guide

Optional

Create a file named config.json in local (or the d2local volume):

{
  "prefix": "%"
}

Create a file named adminWhitelist.json in local (or the d2local volume) containing a list of Discord usernames that have full permissions:

{
  "users": [
    {
      "value": "YOUR_DISCORD_USER_ID",
      "clientName": "Discord"
    }
  ]
}

Create a file named netApiKeys.json in local (or the d2local volume) containing various API keys:

{
  "mapQuest": "YOUR_MAP_QUEST_KEY",
  "wolframAlpha": "YOUR_WOLFRAM_ALPHA_KEY",
  "gitlab": "YOUR_GITLAB_PERSONAL_ACCESS_TOKEN"
}

Create a folder named memeTemplates in local containing PNG images. Any fully transparent sections will be filled by a user-defined image, once the corresponding command is invoked.

Architecture

The program consists of a single executable:

This executable depends on several library targets:

D2

The executable application. Sets up messaging backends (like Discord) and the top-level event handler (D2Delegate). Besides other events, the D2Delegate handles incoming messages and forwards them to multiple MessageHandlers. One of these is CommandHandler, which in turn parses the command and invokes the actual command.

D2Commands

At a basic level, the Command protocol consists of a single method named invoke that carries information about the user's request:

protocol Command: AnyObject {
    ...

    func invoke(with input: RichValue, output: any CommandOutput, context: CommandContext) async

    ...
}

The arguments each represent a part of the invocation context. Given a request such as %commandname arg1 arg2, the implementor would receive:

Parameter Value
input .text("arg1 arg2")
output DiscordOutput
context CommandContext containing the message, the client and the command registry

Since output: any CommandOutput represents a polymorphic object, the output of an invocation does not necessarily get sent to the Discord channel where the request originated from. For example, if the user creates a piped request such as %first | second | third, only the third command would operate on a DiscordOutput. Both the first and the second command call a PipeOutput instead that passes any values to the next command:

class PipeOutput: CommandOutput {
    private let sink: any Command
    private let context: CommandContext
    private let args: String
    private let next: (any CommandOutput)?

    init(withSink sink: any Command, context: CommandContext, args: String, next: (any CommandOutput)? = nil) {
        self.sink = sink
        self.args = args
        self.context = context
        self.next = next
    }

    func append(_ value: RichValue) async {
        let nextInput = args.isEmpty ? value : (.text(args) + value)
        await sink.invoke(with: nextInput, output: next ?? PrintOutput(), context: context)
    }
}

Often the Command protocol is too low-level to be adopted directly, since the input can be of any form (including embeds or images). To address this, there are subprotocols that provide a simpler template interface for implementors:

protocol StringCommand: Command {
    func invoke(with input: String, output: any CommandOutput, context: CommandContext) async
}

StringCommand is useful when the command accepts a single string as an argument or if a custom argument parser is used. Its default implementation of Command.invoke passes either args, if not empty, or otherwise input.content to StringCommand.invoke.

protocol ArgCommand: Command {
    associatedtype Args: Arg

    var argPattern: Args { get }

    func invoke(with input: [Args], output: any CommandOutput, context: CommandContext) async
}

ArgCommand should be adopted if the command expects a fixed structure of arguments.