mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 04:23:15 -05:00
feat(addons): add the Discord connector add-on
People talk to a saved agent from Discord, in direct messages and where the bot is mentioned in a channel or thread. The add-on keeps one Gateway link per bot token (discordgo runs hello, identify, heartbeats and resume), queues the messages that address the bot, and hands them to the host's poll. It sends, edits, splits at 2000 characters, moves files by handle (10 MB), and reads thread history. Safe defaults: guild channels answer only on a mention or reply, or in a thread the bot follows; bots, webhooks and system messages are never read; every message carries allowed_mentions with an empty parse list and mentions in model text are broken up; a thread's chat is its parent channel so an allowlist of channels covers it. No typing indicator, no reactions, no embeds, no slash commands. Tests use a fake Gateway (hello, identify, ready, resume, heartbeat ack, message and thread events, reconnect, close codes) and a fake REST server (429 with Retry-After, create, edit, upload, history, CDN). The page has its own node tests and a browser spec. Assisted-by: Claude:claude-sonnet-5-5 go
This commit is contained in:
27 files changed
+3441
-2
No files matched your search
@@ -0,0 +1,28 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Command discord is the executable of the Discord add-on. It carries the
|
||||
// messages of Discord bots for the chats: it keeps a Gateway link for each
|
||||
// binding that the host polls, and sends the answers that the host hands it.
|
||||
// The host passes the bot token on every call; it is never written to disk.
|
||||
// The add-on keeps only which threads it follows, in its data directory.
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/mudler/LocalAI/addons/discord/transport"
|
||||
"github.com/mudler/LocalAI/addons/internal/hostclient"
|
||||
"github.com/mudler/LocalAI/addons/internal/protocol"
|
||||
"github.com/mudler/LocalAI/pkg/addons/contract"
|
||||
)
|
||||
|
||||
func main() {
|
||||
os.Exit(protocol.Run(os.Args, func(s protocol.Startup) contract.Runtime {
|
||||
rt := transport.New(&transport.API{})
|
||||
rt.Gateway.StateDir = s.DataDir
|
||||
if h := hostclient.New(s); h != nil {
|
||||
rt.Host = h
|
||||
}
|
||||
return rt
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "discord",
|
||||
"version": "0.1.0",
|
||||
"executable": "discord",
|
||||
"os": "linux",
|
||||
"arch": "amd64",
|
||||
"host": { "min": 1, "max": 1 },
|
||||
"api": {"min": 2, "max": 2},
|
||||
"protocol": { "min": 1, "max": 1 },
|
||||
"dataFormat": { "min": 1, "max": 1 },
|
||||
"capabilities": {
|
||||
"hostOperations": {
|
||||
"optional": ["connector.file.get", "connector.file.put"]
|
||||
},
|
||||
"connector": {
|
||||
"kinds": [
|
||||
{
|
||||
"id": "discord",
|
||||
"title": "Discord",
|
||||
"description": "Talk to one of your saved agents from Discord, in direct messages and where the bot is mentioned in a channel or thread.",
|
||||
"senderHint": "A user ID or a channel ID (numbers, with Developer Mode on)",
|
||||
"delivery": "pull",
|
||||
"files": {
|
||||
"receive": { "maxBytes": 10485760 },
|
||||
"send": { "maxBytes": 10485760 }
|
||||
},
|
||||
"page": "bots",
|
||||
"previewMs": 1000,
|
||||
"history": { "thread": true, "maxMessages": 30 },
|
||||
"options": [
|
||||
{
|
||||
"id": "followThreads",
|
||||
"title": "Threads the bot follows",
|
||||
"description": "Whether the bot answers messages in a thread without a new mention.",
|
||||
"default": "mentioned",
|
||||
"values": [
|
||||
{ "id": "mentioned", "title": "Threads where it was mentioned" },
|
||||
{ "id": "never", "title": "None: always ask with a mention" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"ui": {
|
||||
"entry": "ui/discord.js",
|
||||
"css": ["ui/discord.css"],
|
||||
"sdk": { "min": 2, "max": 2 },
|
||||
"pages": ["bots"],
|
||||
"mainPage": "bots",
|
||||
"icon": "ui/icon.png",
|
||||
"title": "Discord",
|
||||
"description": "Bind a Discord bot to one of your saved agents, so people can talk to it from Discord."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
# Release platform matrix of the discord add-on package.
|
||||
#
|
||||
# A platform ships only while "enabled" is true. Enable a platform in the same
|
||||
# change that shows its native check passing on a runner of that platform;
|
||||
# until then it stays listed and disabled. The release workflow fails closed
|
||||
# when no platform is enabled, and it never builds a platform that is not
|
||||
# enabled here.
|
||||
#
|
||||
# runner: the GitHub runner label that builds and checks the platform
|
||||
# natively. Required for an enabled platform.
|
||||
# native_check: "executable-start": the packaged executable is extracted on
|
||||
# the native runner and started. Required for an enabled platform.
|
||||
# runtime: files besides the executable that the platform needs at run
|
||||
# time, relative to the package root. They are taken from the
|
||||
# runtime directory of the build and must exist.
|
||||
schema: 1
|
||||
addon: discord
|
||||
executable: discord
|
||||
manifest: addons/discord/manifest.json
|
||||
# The directory whose content is laid at the package root (ui/discord.js ...).
|
||||
ui: addons/discord/ui/dist
|
||||
platforms:
|
||||
- platform: linux/amd64
|
||||
enabled: true
|
||||
runner: ubuntu-24.04
|
||||
native_check: executable-start
|
||||
- platform: linux/arm64
|
||||
enabled: true
|
||||
runner: ubuntu-24.04-arm
|
||||
native_check: executable-start
|
||||
- platform: darwin/arm64
|
||||
enabled: true
|
||||
runner: macos-15
|
||||
native_check: executable-start
|
||||
- platform: darwin/amd64
|
||||
enabled: true
|
||||
runner: macos-15-intel
|
||||
native_check: executable-start
|
||||
@@ -0,0 +1,440 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package transport speaks Discord for the Discord add-on: the REST API for
|
||||
// sending, editing, files, history and who the bot is, and the Gateway (see
|
||||
// gateway.go) for the events that reach the bot. The host passes the bot token
|
||||
// on every call; the add-on keeps it only in memory, for the Gateway link of a
|
||||
// binding.
|
||||
package transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mudler/LocalAI/addons/internal/connector"
|
||||
"github.com/mudler/LocalAI/pkg/addons/contract"
|
||||
"github.com/mudler/LocalAI/pkg/httpclient"
|
||||
)
|
||||
|
||||
// DefaultBase is the REST API. Only a test sets another base URL.
|
||||
const DefaultBase = "https://discord.com/api/v10"
|
||||
|
||||
// DefaultCDNHosts are the hosts that serve the files of messages. A download
|
||||
// goes to one of them only, and never carries the bot token.
|
||||
var DefaultCDNHosts = []string{"cdn.discordapp.com", "media.discordapp.net"}
|
||||
|
||||
// MaxFileBytes is the largest file the add-on moves in either direction. The
|
||||
// limit of Discord depends on the server and the person; this one is safe for
|
||||
// a bot on every tier.
|
||||
const MaxFileBytes = 10 << 20
|
||||
|
||||
// ErrTooLarge means a file is larger than the add-on hands over.
|
||||
var ErrTooLarge = connector.ErrTooLarge
|
||||
|
||||
// userAgent is required by Discord for a bot that calls the REST API.
|
||||
const userAgent = "DiscordBot (https://localai.io, 0.1)"
|
||||
|
||||
// API speaks the REST API. The token goes in the Authorization header, never
|
||||
// in a URL, and no error repeats it.
|
||||
type API struct {
|
||||
// BaseURL is DefaultBase when empty.
|
||||
BaseURL string
|
||||
// CDNHosts is DefaultCDNHosts when empty.
|
||||
CDNHosts []string
|
||||
// Client sends the requests. Nil: a client of pkg/httpclient with a
|
||||
// timeout, which refuses redirects.
|
||||
Client *http.Client
|
||||
}
|
||||
|
||||
func (a *API) client() *http.Client {
|
||||
if a.Client != nil {
|
||||
return a.Client
|
||||
}
|
||||
return httpclient.NewWithTimeout(30 * time.Second)
|
||||
}
|
||||
|
||||
func (a *API) base() string {
|
||||
if a.BaseURL == "" {
|
||||
return DefaultBase
|
||||
}
|
||||
return strings.TrimSuffix(a.BaseURL, "/")
|
||||
}
|
||||
|
||||
func (a *API) cdnHosts() []string {
|
||||
if len(a.CDNHosts) > 0 {
|
||||
return a.CDNHosts
|
||||
}
|
||||
return DefaultCDNHosts
|
||||
}
|
||||
|
||||
// do sends one request and decodes a JSON answer into out. 429 becomes a
|
||||
// *connector.RetryAfter, 401 an ErrUnauthorized. The body of an error answer
|
||||
// is read for its message only, which Discord keeps free of the token.
|
||||
func (a *API) do(ctx context.Context, token, method, path string, body io.Reader, contentType string, out any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, method, a.base()+path, body)
|
||||
if err != nil {
|
||||
return errors.New("discord: could not build a request")
|
||||
}
|
||||
req.Header.Set("Authorization", "Bot "+token)
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
if contentType != "" {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
resp, err := a.client().Do(req)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
var ue *url.Error
|
||||
if errors.As(err, &ue) {
|
||||
err = ue.Err
|
||||
}
|
||||
return fmt.Errorf("discord: %s %s failed: %w", method, route(path), err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
if err != nil {
|
||||
return fmt.Errorf("discord: %s: could not read the answer", route(path))
|
||||
}
|
||||
switch {
|
||||
case resp.StatusCode == http.StatusTooManyRequests:
|
||||
return &connector.RetryAfter{Seconds: waitSeconds(resp.Header.Get("Retry-After"), raw)}
|
||||
case resp.StatusCode == http.StatusUnauthorized:
|
||||
return fmt.Errorf("discord: the bot token was refused: %w", connector.ErrUnauthorized)
|
||||
case resp.StatusCode >= 300:
|
||||
var e struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &e)
|
||||
msg := strings.ReplaceAll(e.Message, token, "<token>")
|
||||
if len(msg) > 200 {
|
||||
msg = msg[:200]
|
||||
}
|
||||
return fmt.Errorf("discord: %s %s: HTTP %d, code %d: %s", method, route(path), resp.StatusCode, e.Code, msg)
|
||||
}
|
||||
if out != nil {
|
||||
if err := json.Unmarshal(raw, out); err != nil {
|
||||
return fmt.Errorf("discord: %s: unexpected answer", route(path))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// route is a path without the IDs in it, for error messages and logs.
|
||||
func route(path string) string {
|
||||
parts := strings.Split(strings.SplitN(path, "?", 2)[0], "/")
|
||||
for i, p := range parts {
|
||||
if p != "" && strings.Trim(p, "0123456789") == "" {
|
||||
parts[i] = ":id"
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "/")
|
||||
}
|
||||
|
||||
// waitSeconds reads the wait of a 429: the header, else retry_after in the
|
||||
// body (seconds, with a fraction). It rounds up, and is at least one second
|
||||
// and at most five minutes.
|
||||
func waitSeconds(header string, body []byte) int {
|
||||
if f, err := strconv.ParseFloat(strings.TrimSpace(header), 64); err == nil && f > 0 {
|
||||
return min(int(math.Ceil(f)), 300)
|
||||
}
|
||||
var b struct {
|
||||
RetryAfter float64 `json:"retry_after"`
|
||||
}
|
||||
if json.Unmarshal(body, &b) == nil && b.RetryAfter > 0 {
|
||||
return min(int(math.Ceil(b.RetryAfter)), 300)
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// User is a Discord user, as the REST API and the Gateway name one.
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
GlobalName string `json:"global_name"`
|
||||
Bot bool `json:"bot"`
|
||||
}
|
||||
|
||||
// Display is the name people see: the display name, else the username.
|
||||
func (u User) Display() string {
|
||||
if u.GlobalName != "" {
|
||||
return u.GlobalName
|
||||
}
|
||||
return u.Username
|
||||
}
|
||||
|
||||
// Me returns the bot of a token.
|
||||
func (a *API) Me(ctx context.Context, token string) (User, error) {
|
||||
var u User
|
||||
err := a.do(ctx, token, http.MethodGet, "/users/@me", nil, "", &u)
|
||||
if err == nil && u.ID == "" {
|
||||
err = errors.New("discord: users/@me named no bot")
|
||||
}
|
||||
return u, err
|
||||
}
|
||||
|
||||
// Application flags that say the Message Content intent is on. The first is
|
||||
// for a bot of 100 servers or more that Discord verified, the second for a
|
||||
// smaller one.
|
||||
const (
|
||||
flagMessageContent = 1 << 18
|
||||
flagMessageContentLimited = 1 << 19
|
||||
)
|
||||
|
||||
// MessageContentOn says whether the application of the token has the Message
|
||||
// Content intent. Without it the Gateway hands over no text from servers. A
|
||||
// failed call gives ok false: the answer is not known.
|
||||
func (a *API) MessageContentOn(ctx context.Context, token string) (on, ok bool) {
|
||||
var app struct {
|
||||
Flags int64 `json:"flags"`
|
||||
}
|
||||
if a.do(ctx, token, http.MethodGet, "/applications/@me", nil, "", &app) != nil {
|
||||
return false, false
|
||||
}
|
||||
return app.Flags&(flagMessageContent|flagMessageContentLimited) != 0, true
|
||||
}
|
||||
|
||||
// Channel is the part of a channel the add-on reads.
|
||||
type Channel struct {
|
||||
ID string `json:"id"`
|
||||
Type int `json:"type"`
|
||||
ParentID string `json:"parent_id"`
|
||||
GuildID string `json:"guild_id"`
|
||||
}
|
||||
|
||||
// The channel types of threads.
|
||||
const (
|
||||
typeAnnouncementThread = 10
|
||||
typePublicThread = 11
|
||||
typePrivateThread = 12
|
||||
)
|
||||
|
||||
// IsThread says whether a channel type is a thread.
|
||||
func IsThread(t int) bool {
|
||||
return t == typeAnnouncementThread || t == typePublicThread || t == typePrivateThread
|
||||
}
|
||||
|
||||
// Channel reads one channel.
|
||||
func (a *API) Channel(ctx context.Context, token, id string) (Channel, error) {
|
||||
var c Channel
|
||||
if !isSnowflake(id) {
|
||||
return c, errors.New("discord: not a channel ID")
|
||||
}
|
||||
err := a.do(ctx, token, http.MethodGet, "/channels/"+id, nil, "", &c)
|
||||
return c, err
|
||||
}
|
||||
|
||||
// isSnowflake says whether s is a Discord ID: only digits, and a plausible
|
||||
// length. An ID goes into a URL path, so nothing else is let through.
|
||||
func isSnowflake(s string) bool {
|
||||
if len(s) < 5 || len(s) > 20 {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// allowedMentions switches off every ping. A message that Discord would
|
||||
// otherwise parse for @everyone, @here and mentions of people and roles pings
|
||||
// no one: the text still shows the mention.
|
||||
var allowedMentions = map[string]any{"parse": []string{}}
|
||||
|
||||
type messageBody struct {
|
||||
Content string `json:"content"`
|
||||
AllowedMentions map[string]any `json:"allowed_mentions"`
|
||||
}
|
||||
|
||||
type sentMessage struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
// PostMessage sends text to a channel or a thread and returns the message ID.
|
||||
func (a *API) PostMessage(ctx context.Context, token, channel, text string) (string, error) {
|
||||
if !isSnowflake(channel) {
|
||||
return "", errors.New("discord: not a channel ID")
|
||||
}
|
||||
raw, _ := json.Marshal(messageBody{Content: text, AllowedMentions: allowedMentions})
|
||||
var m sentMessage
|
||||
if err := a.do(ctx, token, http.MethodPost, "/channels/"+channel+"/messages", strings.NewReader(string(raw)), "application/json", &m); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return m.ID, nil
|
||||
}
|
||||
|
||||
// EditMessage replaces the text of a message of the bot.
|
||||
func (a *API) EditMessage(ctx context.Context, token, channel, id, text string) error {
|
||||
if !isSnowflake(channel) || !isSnowflake(id) {
|
||||
return errors.New("discord: not a channel or message ID")
|
||||
}
|
||||
raw, _ := json.Marshal(messageBody{Content: text, AllowedMentions: allowedMentions})
|
||||
return a.do(ctx, token, http.MethodPatch, "/channels/"+channel+"/messages/"+id, strings.NewReader(string(raw)), "application/json", nil)
|
||||
}
|
||||
|
||||
// DMChannel opens (or returns) the direct conversation with a user.
|
||||
func (a *API) DMChannel(ctx context.Context, token, user string) (string, error) {
|
||||
if !isSnowflake(user) {
|
||||
return "", errors.New("discord: not a user ID")
|
||||
}
|
||||
raw, _ := json.Marshal(map[string]string{"recipient_id": user})
|
||||
var c Channel
|
||||
if err := a.do(ctx, token, http.MethodPost, "/users/@me/channels", strings.NewReader(string(raw)), "application/json", &c); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return c.ID, nil
|
||||
}
|
||||
|
||||
// Attachment is a file of a message.
|
||||
type Attachment struct {
|
||||
ID string `json:"id"`
|
||||
Filename string `json:"filename"`
|
||||
ContentType string `json:"content_type"`
|
||||
Size int64 `json:"size"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// Message is the part of a message the add-on reads.
|
||||
type Message struct {
|
||||
ID string `json:"id"`
|
||||
ChannelID string `json:"channel_id"`
|
||||
Type int `json:"type"`
|
||||
Content string `json:"content"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
WebhookID string `json:"webhook_id"`
|
||||
Author User `json:"author"`
|
||||
Mentions []User `json:"mentions"`
|
||||
Attachments []Attachment `json:"attachments"`
|
||||
}
|
||||
|
||||
// Message reads one message. It names the current, signed URLs of its files.
|
||||
func (a *API) Message(ctx context.Context, token, channel, id string) (Message, error) {
|
||||
var m Message
|
||||
if !isSnowflake(channel) || !isSnowflake(id) {
|
||||
return m, errors.New("discord: not a channel or message ID")
|
||||
}
|
||||
err := a.do(ctx, token, http.MethodGet, "/channels/"+channel+"/messages/"+id, nil, "", &m)
|
||||
return m, err
|
||||
}
|
||||
|
||||
// Before reads up to limit messages (1 to 100) of a channel or thread sent
|
||||
// before the message id, newest first.
|
||||
func (a *API) Before(ctx context.Context, token, channel, before string, limit int) ([]Message, error) {
|
||||
if !isSnowflake(channel) || (before != "" && !isSnowflake(before)) {
|
||||
return nil, errors.New("discord: not a channel or message ID")
|
||||
}
|
||||
q := url.Values{"limit": {strconv.Itoa(min(max(limit, 1), 100))}}
|
||||
if before != "" {
|
||||
q.Set("before", before)
|
||||
}
|
||||
var out []Message
|
||||
err := a.do(ctx, token, http.MethodGet, "/channels/"+channel+"/messages?"+q.Encode(), nil, "", &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// DownloadStream opens a file of a message. The URL must be on a host of the
|
||||
// Discord CDN over https, and the request carries no token.
|
||||
func (a *API) DownloadStream(ctx context.Context, raw string) (io.ReadCloser, error) {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Scheme == "" || u.Host == "" || !a.cdnAllowed(u) {
|
||||
return nil, errors.New("discord: the file is not on the Discord CDN")
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||
if err != nil {
|
||||
return nil, errors.New("discord: could not build a download")
|
||||
}
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
resp, err := a.client().Do(req)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
return nil, errors.New("discord: the download failed")
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
_ = resp.Body.Close()
|
||||
return nil, fmt.Errorf("discord: the download answered HTTP %d", resp.StatusCode)
|
||||
}
|
||||
return resp.Body, nil
|
||||
}
|
||||
|
||||
func (a *API) cdnAllowed(u *url.URL) bool {
|
||||
if u.Scheme != "https" && a.CDNHosts == nil {
|
||||
return false
|
||||
}
|
||||
for _, h := range a.cdnHosts() {
|
||||
if strings.EqualFold(u.Host, h) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// UploadStream sends a file to a channel or thread from a stream of size
|
||||
// bytes, in a message with no text.
|
||||
func (a *API) UploadStream(ctx context.Context, token, channel, name string, size int64, body io.Reader) (string, error) {
|
||||
if !isSnowflake(channel) {
|
||||
return "", errors.New("discord: not a channel ID")
|
||||
}
|
||||
if size > MaxFileBytes {
|
||||
return "", ErrTooLarge
|
||||
}
|
||||
name = contract.SafeFileName(name)
|
||||
pr, pw := io.Pipe()
|
||||
mw := multipart.NewWriter(pw)
|
||||
go func() {
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"allowed_mentions": allowedMentions,
|
||||
"attachments": []map[string]any{{"id": 0, "filename": name}},
|
||||
})
|
||||
err := func() error {
|
||||
h := textproto.MIMEHeader{}
|
||||
h.Set("Content-Disposition", `form-data; name="payload_json"`)
|
||||
h.Set("Content-Type", "application/json")
|
||||
w, err := mw.CreatePart(h)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.Write(payload); err != nil {
|
||||
return err
|
||||
}
|
||||
h = textproto.MIMEHeader{}
|
||||
h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="files[0]"; filename=%q`, name))
|
||||
h.Set("Content-Type", "application/octet-stream")
|
||||
w, err = mw.CreatePart(h)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// One byte past the size shows a file that is larger than it said.
|
||||
n, err := io.Copy(w, io.LimitReader(body, size+1))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n != size {
|
||||
return fmt.Errorf("discord: the file has %d bytes, not the %d it said", n, size)
|
||||
}
|
||||
return mw.Close()
|
||||
}()
|
||||
_ = pw.CloseWithError(err)
|
||||
}()
|
||||
var m sentMessage
|
||||
if err := a.do(ctx, token, http.MethodPost, "/channels/"+channel+"/messages", pr, mw.FormDataContentType(), &m); err != nil {
|
||||
_ = pr.CloseWithError(err)
|
||||
return "", err
|
||||
}
|
||||
return m.ID, nil
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
package transport_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"github.com/mudler/LocalAI/addons/discord/transport"
|
||||
"github.com/mudler/LocalAI/pkg/addons/contract"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// secretToken is a made-up token. No test may let it out in an error.
|
||||
const secretToken = "MTAwMDAwMDAwMDAwMDAwMDAw.GabcDE.SECRET-token-value"
|
||||
|
||||
const (
|
||||
botID = "100000000000000001"
|
||||
guildID = "200000000000000001"
|
||||
chanID = "300000000000000001"
|
||||
threadI = "300000000000000002"
|
||||
userID = "400000000000000001"
|
||||
dmID = "500000000000000001"
|
||||
)
|
||||
|
||||
// restCall is a request the fake REST server got.
|
||||
type restCall struct {
|
||||
Method string
|
||||
Path string // without /api/vN
|
||||
Query url.Values
|
||||
Header http.Header
|
||||
Body map[string]any
|
||||
// File is the file of a multipart upload.
|
||||
File struct {
|
||||
Name string
|
||||
Data []byte
|
||||
}
|
||||
}
|
||||
|
||||
// reply is the answer of the fake REST server.
|
||||
type reply struct {
|
||||
Status int
|
||||
Body string
|
||||
Header map[string]string
|
||||
}
|
||||
|
||||
// handshake is a packet a client sent on the Gateway: identify or resume.
|
||||
type handshake struct {
|
||||
Op int
|
||||
Data map[string]any
|
||||
}
|
||||
|
||||
// fakeDiscord is Discord in one test server: the REST API, the CDN and the
|
||||
// Gateway websocket. Each REST call is recorded; the answer of a route is set
|
||||
// with on. The Gateway speaks hello, identify/resume, ready/resumed and
|
||||
// heartbeat acknowledgements, and sends the events that a test dispatches.
|
||||
type fakeDiscord struct {
|
||||
srv *httptest.Server
|
||||
|
||||
mu sync.Mutex
|
||||
calls []restCall
|
||||
on map[string]func(restCall) reply
|
||||
nextID int
|
||||
// history is the messages of a channel, newest first.
|
||||
history map[string][]map[string]any
|
||||
// messages are single messages by channel/id.
|
||||
messages map[string]map[string]any
|
||||
files map[string][]byte
|
||||
// closeIdentify, when not zero, closes a link with this code after identify.
|
||||
closeIdentify int
|
||||
intentsOff bool
|
||||
|
||||
conn *websocket.Conn
|
||||
wmu sync.Mutex
|
||||
shakes []handshake
|
||||
seq int
|
||||
sessions int
|
||||
heartbeats atomic.Int32
|
||||
}
|
||||
|
||||
func newFakeDiscord() *fakeDiscord {
|
||||
f := &fakeDiscord{on: map[string]func(restCall) reply{}, history: map[string][]map[string]any{}, messages: map[string]map[string]any{}, files: map[string][]byte{}, nextID: 900000}
|
||||
f.srv = httptest.NewServer(http.HandlerFunc(f.serve))
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fakeDiscord) Close() { f.srv.Close() }
|
||||
|
||||
var apiPrefix = regexp.MustCompile(`^/api/v\d+`)
|
||||
|
||||
func (f *fakeDiscord) serve(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/ws") {
|
||||
f.gateway(w, r)
|
||||
return
|
||||
}
|
||||
if rest, ok := strings.CutPrefix(r.URL.Path, "/cdn/"); ok {
|
||||
f.mu.Lock()
|
||||
f.calls = append(f.calls, restCall{Method: r.Method, Path: "/cdn/" + rest, Header: r.Header.Clone()})
|
||||
data, found := f.files[rest]
|
||||
f.mu.Unlock()
|
||||
if !found {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(data)
|
||||
return
|
||||
}
|
||||
c := restCall{Method: r.Method, Path: apiPrefix.ReplaceAllString(r.URL.Path, ""), Query: r.URL.Query(), Header: r.Header.Clone()}
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
if mt, params, _ := mime.ParseMediaType(r.Header.Get("Content-Type")); mt == "multipart/form-data" {
|
||||
mr := multipart.NewReader(bytes.NewReader(raw), params["boundary"])
|
||||
for {
|
||||
p, err := mr.NextPart()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
data, _ := io.ReadAll(p)
|
||||
if p.FormName() == "payload_json" {
|
||||
_ = json.Unmarshal(data, &c.Body)
|
||||
} else {
|
||||
c.File.Name, c.File.Data = p.FileName(), data
|
||||
}
|
||||
}
|
||||
} else if len(raw) > 0 {
|
||||
_ = json.Unmarshal(raw, &c.Body)
|
||||
}
|
||||
f.mu.Lock()
|
||||
f.calls = append(f.calls, c)
|
||||
h := f.on[c.Method+" "+shape(c.Path)]
|
||||
f.mu.Unlock()
|
||||
rep := f.defaults(c)
|
||||
if h != nil {
|
||||
rep = h(c)
|
||||
}
|
||||
for k, v := range rep.Header {
|
||||
w.Header().Set(k, v)
|
||||
}
|
||||
if rep.Status == 0 {
|
||||
rep.Status = 200
|
||||
}
|
||||
w.WriteHeader(rep.Status)
|
||||
_, _ = io.WriteString(w, rep.Body)
|
||||
}
|
||||
|
||||
// shape turns the IDs of a path into :id, so a route is one key.
|
||||
func shape(p string) string {
|
||||
parts := strings.Split(p, "/")
|
||||
for i, s := range parts {
|
||||
if s != "" && strings.Trim(s, "0123456789") == "" {
|
||||
parts[i] = ":id"
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "/")
|
||||
}
|
||||
|
||||
func jsonOf(v any) string {
|
||||
b, _ := json.Marshal(v)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func (f *fakeDiscord) defaults(c restCall) reply {
|
||||
switch c.Method + " " + shape(c.Path) {
|
||||
case "GET /gateway":
|
||||
return reply{Body: jsonOf(map[string]any{"url": "ws" + strings.TrimPrefix(f.srv.URL, "http") + "/ws"})}
|
||||
case "GET /users/@me":
|
||||
return reply{Body: jsonOf(map[string]any{"id": botID, "username": "localai-bot", "bot": true})}
|
||||
case "GET /applications/@me":
|
||||
flags := 1 << 19
|
||||
if f.intentsOff {
|
||||
flags = 0
|
||||
}
|
||||
return reply{Body: jsonOf(map[string]any{"id": botID, "flags": flags})}
|
||||
case "GET /channels/:id":
|
||||
id := strings.TrimPrefix(c.Path, "/channels/")
|
||||
if id == threadI {
|
||||
return reply{Body: jsonOf(map[string]any{"id": threadI, "type": 11, "parent_id": chanID, "guild_id": guildID})}
|
||||
}
|
||||
return reply{Body: jsonOf(map[string]any{"id": id, "type": 0, "guild_id": guildID})}
|
||||
case "POST /channels/:id/messages":
|
||||
f.mu.Lock()
|
||||
f.nextID++
|
||||
id := f.nextID
|
||||
f.mu.Unlock()
|
||||
return reply{Body: jsonOf(map[string]any{"id": strconv.Itoa(id)})}
|
||||
case "PATCH /channels/:id/messages/:id":
|
||||
return reply{Body: `{}`}
|
||||
case "GET /channels/:id/messages":
|
||||
ch := strings.Split(c.Path, "/")[2]
|
||||
f.mu.Lock()
|
||||
list := f.history[ch]
|
||||
f.mu.Unlock()
|
||||
if before := c.Query.Get("before"); before != "" {
|
||||
for i, m := range list {
|
||||
if m["id"] == before {
|
||||
list = list[i+1:]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
limit, _ := strconv.Atoi(c.Query.Get("limit"))
|
||||
if limit > 0 && len(list) > limit {
|
||||
list = list[:limit]
|
||||
}
|
||||
return reply{Body: jsonOf(list)}
|
||||
case "GET /channels/:id/messages/:id":
|
||||
parts := strings.Split(c.Path, "/")
|
||||
f.mu.Lock()
|
||||
m := f.messages[parts[2]+"/"+parts[4]]
|
||||
f.mu.Unlock()
|
||||
if m == nil {
|
||||
return reply{Status: 404, Body: `{"code":10008,"message":"Unknown Message"}`}
|
||||
}
|
||||
return reply{Body: jsonOf(m)}
|
||||
case "POST /users/@me/channels":
|
||||
return reply{Body: jsonOf(map[string]any{"id": dmID, "type": 1})}
|
||||
}
|
||||
return reply{Status: 404, Body: `{"code":0,"message":"404: Not Found"}`}
|
||||
}
|
||||
|
||||
// called returns the recorded calls of a route, such as "POST /channels/:id/messages".
|
||||
func (f *fakeDiscord) called(route string) []restCall {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var out []restCall
|
||||
for _, c := range f.calls {
|
||||
if c.Method+" "+shape(c.Path) == route {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// api is the REST client of the add-on, pointed at the fake.
|
||||
func (f *fakeDiscord) api() *transport.API {
|
||||
return &transport.API{BaseURL: f.srv.URL + "/api/v10", CDNHosts: []string{strings.TrimPrefix(f.srv.URL, "http://")}}
|
||||
}
|
||||
|
||||
// redirect sends the requests of discordgo, which go to discord.com, to the fake.
|
||||
type redirect struct{ to *url.URL }
|
||||
|
||||
func (r redirect) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
req = req.Clone(req.Context())
|
||||
req.URL.Scheme, req.URL.Host = r.to.Scheme, r.to.Host
|
||||
return http.DefaultTransport.RoundTrip(req)
|
||||
}
|
||||
|
||||
// newGateway is a Gateway for tests: fast backoff and heartbeat, over the fake.
|
||||
func (f *fakeDiscord) newGateway(stateDir string) *transport.Gateway {
|
||||
u, _ := url.Parse(f.srv.URL)
|
||||
return &transport.Gateway{API: f.api(), Client: &http.Client{Transport: redirect{u}, Timeout: 10 * time.Second},
|
||||
Backoff: 20 * time.Millisecond, IdleAfter: time.Minute, StateDir: stateDir}
|
||||
}
|
||||
|
||||
// runtime is a Runtime over the fake, with the gateway of newGateway().
|
||||
func (f *fakeDiscord) runtime(host *memHost) *transport.Runtime {
|
||||
rt := transport.New(f.api())
|
||||
rt.Gateway = f.newGateway("")
|
||||
if host != nil {
|
||||
rt.Host = host
|
||||
}
|
||||
return rt
|
||||
}
|
||||
|
||||
var upgrader = websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
|
||||
|
||||
func (f *fakeDiscord) send(c *websocket.Conn, v any) {
|
||||
f.wmu.Lock()
|
||||
defer f.wmu.Unlock()
|
||||
_ = c.WriteJSON(v)
|
||||
}
|
||||
|
||||
// gateway serves one websocket: hello, then a loop that answers the packets
|
||||
// of the client.
|
||||
func (f *fakeDiscord) gateway(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
f.mu.Lock()
|
||||
f.conn = c
|
||||
f.mu.Unlock()
|
||||
f.send(c, map[string]any{"op": 10, "d": map[string]any{"heartbeat_interval": 100}})
|
||||
for {
|
||||
var p struct {
|
||||
Op int `json:"op"`
|
||||
D any `json:"d"`
|
||||
}
|
||||
if err := c.ReadJSON(&p); err != nil {
|
||||
return
|
||||
}
|
||||
switch p.Op {
|
||||
case 1:
|
||||
f.heartbeats.Add(1)
|
||||
f.send(c, map[string]any{"op": 11})
|
||||
case 2, 6:
|
||||
f.mu.Lock()
|
||||
d, _ := p.D.(map[string]any)
|
||||
f.shakes = append(f.shakes, handshake{Op: p.Op, Data: d})
|
||||
code := f.closeIdentify
|
||||
if p.Op == 2 {
|
||||
f.sessions++
|
||||
}
|
||||
sess := f.sessions
|
||||
f.seq++
|
||||
seq := f.seq
|
||||
f.mu.Unlock()
|
||||
if code != 0 && p.Op == 2 {
|
||||
f.wmu.Lock()
|
||||
_ = c.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(code, "closed"))
|
||||
f.wmu.Unlock()
|
||||
return
|
||||
}
|
||||
if p.Op == 2 {
|
||||
f.send(c, map[string]any{"op": 0, "t": "READY", "s": seq, "d": map[string]any{"v": 10, "session_id": fmt.Sprintf("sess-%d", sess),
|
||||
"user": map[string]any{"id": botID, "username": "localai-bot", "bot": true}, "guilds": []any{}, "application": map[string]any{"id": botID}}})
|
||||
} else {
|
||||
f.send(c, map[string]any{"op": 0, "t": "RESUMED", "s": seq, "d": map[string]any{}})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// dispatch sends an event to the connected client.
|
||||
func (f *fakeDiscord) dispatch(t string, d any) {
|
||||
f.mu.Lock()
|
||||
c := f.conn
|
||||
f.seq++
|
||||
seq := f.seq
|
||||
f.mu.Unlock()
|
||||
Expect(c).NotTo(BeNil(), "no client is connected")
|
||||
f.send(c, map[string]any{"op": 0, "t": t, "s": seq, "d": d})
|
||||
}
|
||||
|
||||
// drop closes the websocket without a close frame, as a network loss does.
|
||||
func (f *fakeDiscord) drop() {
|
||||
f.mu.Lock()
|
||||
c := f.conn
|
||||
f.mu.Unlock()
|
||||
if c != nil {
|
||||
_ = c.UnderlyingConn().Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeDiscord) handshakes() []handshake {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return append([]handshake(nil), f.shakes...)
|
||||
}
|
||||
|
||||
// author is the user of a message.
|
||||
func author(id, name string, bot bool) map[string]any {
|
||||
return map[string]any{"id": id, "username": name, "global_name": strings.ToUpper(name[:1]) + name[1:], "bot": bot}
|
||||
}
|
||||
|
||||
var snow atomic.Int64
|
||||
|
||||
func nextSnow() string { return strconv.FormatInt(700000000000000000+snow.Add(1), 10) }
|
||||
|
||||
// msg is a MESSAGE_CREATE payload. mentionBot adds the bot to the mentions.
|
||||
type msg struct {
|
||||
channel, guild, sender, text string
|
||||
mentionBot bool
|
||||
bot, webhook bool
|
||||
reply string // the author of the message this one replies to
|
||||
attachments []map[string]any
|
||||
typ int
|
||||
}
|
||||
|
||||
func (m msg) payload() map[string]any {
|
||||
p := map[string]any{"id": nextSnow(), "channel_id": m.channel, "content": m.text, "type": m.typ,
|
||||
"author": author(m.sender, "ada", m.bot), "mentions": []any{}, "attachments": []any{}}
|
||||
if m.guild != "" {
|
||||
p["guild_id"] = m.guild
|
||||
}
|
||||
if m.mentionBot {
|
||||
p["mentions"] = []any{author(botID, "localai-bot", true)}
|
||||
}
|
||||
if m.webhook {
|
||||
p["webhook_id"] = nextSnow()
|
||||
}
|
||||
if m.reply != "" {
|
||||
p["type"] = 19
|
||||
p["message_reference"] = map[string]any{"message_id": nextSnow(), "channel_id": m.channel}
|
||||
p["referenced_message"] = map[string]any{"id": nextSnow(), "channel_id": m.channel, "author": author(m.reply, "someone", m.reply == botID)}
|
||||
}
|
||||
if len(m.attachments) > 0 {
|
||||
p["attachments"] = m.attachments
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// memHost is the host side of a file handle in memory: it keeps the chunks that
|
||||
// the add-on puts, and serves the bytes of src to its gets.
|
||||
type memHost struct {
|
||||
got bytes.Buffer
|
||||
src []byte
|
||||
}
|
||||
|
||||
func (h *memHost) Do(_ context.Context, _, _ string, args, out any) error {
|
||||
switch a := args.(type) {
|
||||
case contract.ConnectorFilePut:
|
||||
if a.Offset == int64(h.got.Len()) {
|
||||
h.got.Write(a.Data)
|
||||
}
|
||||
*(out.(*contract.ConnectorFilePutResult)) = contract.ConnectorFilePutResult{Received: int64(h.got.Len())}
|
||||
case contract.ConnectorFileGet:
|
||||
end := min(a.Offset+a.Length, int64(len(h.src)))
|
||||
*(out.(*contract.ConnectorFileGetResult)) = contract.ConnectorFileGetResult{Data: h.src[a.Offset:end], EOF: end == int64(len(h.src))}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
package transport
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mudler/LocalAI/addons/internal/atomicfile"
|
||||
)
|
||||
|
||||
// FollowOption is the ID of the option that says which threads the bot follows.
|
||||
const FollowOption = "followThreads"
|
||||
|
||||
// The values of the option FollowOption.
|
||||
const (
|
||||
// FollowMentioned follows a thread once the bot is mentioned in it: later
|
||||
// messages of the thread need no new mention. It is the default.
|
||||
FollowMentioned = "mentioned"
|
||||
// FollowNever follows nothing: every message needs a mention.
|
||||
FollowNever = "never"
|
||||
)
|
||||
|
||||
// followMode is the value of the option, or FollowMentioned for an unknown one.
|
||||
func followMode(v string) string {
|
||||
if v == FollowNever {
|
||||
return v
|
||||
}
|
||||
return FollowMentioned
|
||||
}
|
||||
|
||||
// touchEvery is the shortest time between two writes that only renew a
|
||||
// thread that is followed already.
|
||||
const touchEvery = 10 * time.Minute
|
||||
|
||||
// stateFile is the file of the followed threads of one link: the name holds a
|
||||
// hash of the token, never the token.
|
||||
func stateFile(dir, key string) string {
|
||||
return filepath.Join(dir, "threads-"+key[:16]+".json")
|
||||
}
|
||||
|
||||
type storedThread struct {
|
||||
Channel string `json:"channel"`
|
||||
Thread string `json:"thread"`
|
||||
At int64 `json:"at"`
|
||||
}
|
||||
|
||||
type storedThreads struct {
|
||||
Threads []storedThread `json:"threads"`
|
||||
}
|
||||
|
||||
// loadThreads reads the followed threads of a link and drops the expired
|
||||
// ones. A missing or damaged file is no threads: the bot then waits for a
|
||||
// mention, which is the safe side.
|
||||
func loadThreads(dir, key string, now time.Time) map[string]time.Time {
|
||||
out := map[string]time.Time{}
|
||||
if dir == "" {
|
||||
return out
|
||||
}
|
||||
raw, err := os.ReadFile(stateFile(dir, key))
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
var st storedThreads
|
||||
if json.Unmarshal(raw, &st) != nil {
|
||||
return out
|
||||
}
|
||||
for _, t := range st.Threads {
|
||||
at := time.Unix(t.At, 0)
|
||||
if t.Channel != "" && t.Thread != "" && now.Sub(at) < threadLife && len(out) < maxThreads {
|
||||
out[threadKey(t.Channel, t.Thread)] = at
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func threadKey(channel, thread string) string { return channel + "\x00" + thread }
|
||||
|
||||
// saveThreads writes the followed threads atomically: a file next to the
|
||||
// target is written and renamed over it. An empty set removes the file.
|
||||
func saveThreads(dir, key string, threads map[string]time.Time) error {
|
||||
if dir == "" {
|
||||
return nil
|
||||
}
|
||||
path := stateFile(dir, key)
|
||||
if len(threads) == 0 {
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
var st storedThreads
|
||||
for k, at := range threads {
|
||||
channel, thread, _ := strings.Cut(k, "\x00")
|
||||
st.Threads = append(st.Threads, storedThread{Channel: channel, Thread: thread, At: at.Unix()})
|
||||
}
|
||||
raw, err := json.Marshal(st)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
return atomicfile.Write(path, raw, 0o600)
|
||||
}
|
||||
@@ -0,0 +1,623 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
package transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"slices"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"github.com/mudler/LocalAI/addons/internal/connector"
|
||||
"github.com/mudler/LocalAI/pkg/addons/contract"
|
||||
)
|
||||
|
||||
// Gateway holds one Discord Gateway link for each bot token that is polled,
|
||||
// and the host pulls: it asks the route for updates with the op poll. The
|
||||
// link is a websocket that discordgo runs (hello, identify, heartbeats and
|
||||
// their acknowledgements, resume). The add-on reads the events it needs,
|
||||
// queues the messages that address the bot, and hands them over on the next
|
||||
// poll. The cursor of an update is a time in microseconds, so a new process
|
||||
// never hands out a cursor below the offset that the host stored before. A
|
||||
// link that is not polled for IdleAfter is closed: the binding was switched
|
||||
// off or deleted.
|
||||
//
|
||||
// State mode: per instance. The links and the queues live in the add-on
|
||||
// process; the host polls them from its one polling frontend. A queued
|
||||
// message that was not handed over when the process stops is lost. The
|
||||
// threads the bot follows are kept in StateDir.
|
||||
//
|
||||
// Two safeguards protect the queue: a sender who writes more than
|
||||
// SenderPerMinute messages a minute is dropped, and a queue that is full
|
||||
// drops its oldest message. Every drop is counted (Dropped) and logged.
|
||||
type Gateway struct {
|
||||
API *API
|
||||
// Client sends the REST calls of the Gateway client (discovery of the
|
||||
// gateway address). Nil: the client of discordgo.
|
||||
Client *http.Client
|
||||
// Dialer opens the websocket. Nil: the dialer of discordgo.
|
||||
Dialer *websocket.Dialer
|
||||
// IdleAfter is two minutes when zero.
|
||||
IdleAfter time.Duration
|
||||
// Backoff is the first wait before a link is opened again after it failed;
|
||||
// it doubles up to five minutes. Zero: five seconds. Discord allows 1000
|
||||
// identifies a day for a bot, so a loop must not be tight.
|
||||
Backoff time.Duration
|
||||
// Logger is slog.Default when nil. It never gets a token.
|
||||
Logger *slog.Logger
|
||||
// StateDir is where the add-on keeps the threads it follows, so that a
|
||||
// restart does not forget them. Empty: they are kept in memory only.
|
||||
StateDir string
|
||||
|
||||
dropped atomic.Uint64
|
||||
mu sync.Mutex
|
||||
links map[string]*link
|
||||
}
|
||||
|
||||
// Limits of the queue of a link and of what it remembers.
|
||||
const (
|
||||
maxQueued = 500
|
||||
maxPoll = 100
|
||||
maxSeen = 2000
|
||||
maxThreads = 1000
|
||||
maxChans = 5000
|
||||
inboxSize = 256
|
||||
// SenderPerMinute is how many messages of one sender a link queues in a
|
||||
// minute. The host limits what it answers; this keeps a flood in a public
|
||||
// channel from filling the queue before the host sees it.
|
||||
SenderPerMinute = 30
|
||||
threadLife = 24 * time.Hour
|
||||
)
|
||||
|
||||
// Intents the link asks for: servers (for their channels and threads), the
|
||||
// messages of servers and of direct conversations, and their content. The
|
||||
// last one is privileged: it must be on for the bot in the developer portal.
|
||||
const intents = discordgo.IntentsGuilds | discordgo.IntentsGuildMessages | discordgo.IntentsDirectMessages | discordgo.IntentMessageContent
|
||||
|
||||
type chanInfo struct {
|
||||
typ int
|
||||
parent string
|
||||
}
|
||||
|
||||
type link struct {
|
||||
key string
|
||||
token string
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
g *Gateway
|
||||
|
||||
// in carries the messages of the Gateway reader to the worker, so that
|
||||
// the reader never waits for a lookup.
|
||||
in chan *discordgo.Message
|
||||
down chan struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
queue []contract.ConnectorMessage
|
||||
wake chan struct{}
|
||||
last int64
|
||||
lastPoll time.Time
|
||||
fatal error
|
||||
bot User
|
||||
seen map[string]struct{}
|
||||
seenList []string
|
||||
threads map[string]time.Time
|
||||
chans map[string]chanInfo
|
||||
rate map[string][]time.Time
|
||||
mode string
|
||||
dir string
|
||||
reported uint64
|
||||
|
||||
saveFailed bool
|
||||
}
|
||||
|
||||
func keyOf(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func (g *Gateway) logger() *slog.Logger {
|
||||
if g.Logger != nil {
|
||||
return g.Logger
|
||||
}
|
||||
return slog.Default()
|
||||
}
|
||||
|
||||
// Dropped is how many messages the links dropped: a full queue, a full inbox
|
||||
// or a sender over the limit.
|
||||
func (g *Gateway) Dropped() uint64 { return g.dropped.Load() }
|
||||
|
||||
// Links is the number of open links, for tests.
|
||||
func (g *Gateway) Links() int {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
return len(g.links)
|
||||
}
|
||||
|
||||
func (g *Gateway) idleAfter() time.Duration {
|
||||
if g.IdleAfter > 0 {
|
||||
return g.IdleAfter
|
||||
}
|
||||
return 2 * time.Minute
|
||||
}
|
||||
|
||||
func (g *Gateway) backoff() time.Duration {
|
||||
if g.Backoff > 0 {
|
||||
return g.Backoff
|
||||
}
|
||||
return 5 * time.Second
|
||||
}
|
||||
|
||||
func missing() error {
|
||||
return fmt.Errorf("discord: the bot token is missing: %w", connector.ErrUnauthorized)
|
||||
}
|
||||
|
||||
// Poll hands over the updates from offset on, and waits up to wait seconds
|
||||
// for one when there is none. The first poll of a token opens its link.
|
||||
func (g *Gateway) Poll(ctx context.Context, token string, offset int64, wait int) ([]contract.ConnectorMessage, error) {
|
||||
if token == "" {
|
||||
return nil, missing()
|
||||
}
|
||||
l := g.link(token, offset)
|
||||
l.mu.Lock()
|
||||
l.mode = followMode(connector.OptionsFrom(ctx)[FollowOption])
|
||||
l.mu.Unlock()
|
||||
deadline := time.NewTimer(time.Duration(wait) * time.Second)
|
||||
defer deadline.Stop()
|
||||
for {
|
||||
l.mu.Lock()
|
||||
l.lastPoll = time.Now()
|
||||
// What the host has handled is below its offset.
|
||||
l.queue = slices.DeleteFunc(l.queue, func(m contract.ConnectorMessage) bool { return m.Cursor < offset })
|
||||
if l.fatal != nil {
|
||||
err := l.fatal
|
||||
l.mu.Unlock()
|
||||
g.drop(l)
|
||||
return nil, err
|
||||
}
|
||||
if len(l.queue) > 0 || wait <= 0 {
|
||||
out := slices.Clone(l.queue[:min(len(l.queue), maxPoll)])
|
||||
l.mu.Unlock()
|
||||
l.reportDrops()
|
||||
return out, nil
|
||||
}
|
||||
wake := l.wake
|
||||
l.mu.Unlock()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-deadline.C:
|
||||
wait = 0
|
||||
case <-wake:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// reportDrops logs once when messages were dropped since the last report.
|
||||
func (l *link) reportDrops() {
|
||||
n := l.g.dropped.Load()
|
||||
l.mu.Lock()
|
||||
changed := n != l.reported
|
||||
l.reported = n
|
||||
l.mu.Unlock()
|
||||
if changed {
|
||||
l.g.logger().Warn("discord: messages were dropped (queue full, inbox full or sender over the limit)", "dropped_total", n)
|
||||
}
|
||||
}
|
||||
|
||||
// link returns the link of a token, opening it when there is none.
|
||||
func (g *Gateway) link(token string, offset int64) *link {
|
||||
key := keyOf(token)
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if l := g.links[key]; l != nil {
|
||||
return l
|
||||
}
|
||||
if g.links == nil {
|
||||
g.links = map[string]*link{}
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
l := &link{key: key, token: token, cancel: cancel, done: make(chan struct{}), g: g,
|
||||
in: make(chan *discordgo.Message, inboxSize), down: make(chan struct{}, 1), wake: make(chan struct{}),
|
||||
last: offset - 1, lastPoll: time.Now(), seen: map[string]struct{}{}, threads: loadThreads(g.StateDir, key, time.Now()),
|
||||
chans: map[string]chanInfo{}, rate: map[string][]time.Time{}, mode: FollowMentioned, dir: g.StateDir}
|
||||
g.links[key] = l
|
||||
go l.work(ctx)
|
||||
go func() {
|
||||
defer close(l.done)
|
||||
g.run(ctx, l)
|
||||
}()
|
||||
return l
|
||||
}
|
||||
|
||||
// drop forgets a link and stops it.
|
||||
func (g *Gateway) drop(l *link) {
|
||||
g.mu.Lock()
|
||||
if g.links[l.key] == l {
|
||||
delete(g.links, l.key)
|
||||
}
|
||||
g.mu.Unlock()
|
||||
l.cancel()
|
||||
}
|
||||
|
||||
// Close stops every link and waits for them.
|
||||
func (g *Gateway) Close() {
|
||||
g.mu.Lock()
|
||||
all := make([]*link, 0, len(g.links))
|
||||
for _, l := range g.links {
|
||||
all = append(all, l)
|
||||
}
|
||||
g.links = nil
|
||||
g.mu.Unlock()
|
||||
for _, l := range all {
|
||||
l.cancel()
|
||||
<-l.done
|
||||
}
|
||||
}
|
||||
|
||||
// botID is the ID of the bot of a token: from the open link when there is
|
||||
// one, else from the REST API. It is empty when it cannot be told.
|
||||
func (g *Gateway) botID(ctx context.Context, token string) string {
|
||||
g.mu.Lock()
|
||||
l := g.links[keyOf(token)]
|
||||
g.mu.Unlock()
|
||||
if l != nil {
|
||||
l.mu.Lock()
|
||||
id := l.bot.ID
|
||||
l.mu.Unlock()
|
||||
if id != "" {
|
||||
return id
|
||||
}
|
||||
}
|
||||
u, err := g.API.Me(ctx, token)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return u.ID
|
||||
}
|
||||
|
||||
// run keeps the link of a token open until it is idle, stopped, or Discord
|
||||
// refuses the token or the intents.
|
||||
func (g *Gateway) run(ctx context.Context, l *link) {
|
||||
// A link that failed for good stays known until a poll reads the failure,
|
||||
// or until it is idle, so the host learns why it stopped.
|
||||
defer func() {
|
||||
l.mu.Lock()
|
||||
fatal := l.fatal
|
||||
l.mu.Unlock()
|
||||
if fatal == nil {
|
||||
g.drop(l)
|
||||
}
|
||||
}()
|
||||
idle := time.NewTicker(max(g.idleAfter()/4, 10*time.Millisecond))
|
||||
defer idle.Stop()
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-idle.C:
|
||||
l.mu.Lock()
|
||||
gone := time.Since(l.lastPoll) > g.idleAfter()
|
||||
l.mu.Unlock()
|
||||
if gone {
|
||||
g.drop(l)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
var dg *discordgo.Session
|
||||
defer func() {
|
||||
if dg != nil {
|
||||
_ = dg.Close()
|
||||
}
|
||||
}()
|
||||
backoff := g.backoff()
|
||||
for ctx.Err() == nil {
|
||||
started := time.Now()
|
||||
var err error
|
||||
dg, err = g.connect(ctx, l, dg)
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
var ra *connector.RetryAfter
|
||||
wait := backoff
|
||||
switch {
|
||||
case errors.Is(err, connector.ErrUnauthorized):
|
||||
l.mu.Lock()
|
||||
l.fatal = err
|
||||
close(l.wake)
|
||||
l.wake = make(chan struct{})
|
||||
l.mu.Unlock()
|
||||
return
|
||||
case errors.As(err, &ra):
|
||||
wait = time.Duration(ra.Seconds) * time.Second
|
||||
default:
|
||||
g.logger().Warn("discord: the Gateway link failed; it is opened again", "error", err, "in", backoff.String())
|
||||
}
|
||||
if time.Since(started) > time.Minute {
|
||||
backoff = g.backoff()
|
||||
} else {
|
||||
backoff = min(backoff*2, 5*time.Minute)
|
||||
}
|
||||
t := time.NewTimer(wait)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Stop()
|
||||
return
|
||||
case <-t.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close codes of the Gateway that no new try fixes.
|
||||
const (
|
||||
closeAuthFailed = 4004
|
||||
closeInvalidIntents = 4013
|
||||
closeDisallowedIntent = 4014
|
||||
)
|
||||
|
||||
var errLinkLost = errors.New("discord: the Gateway link was closed")
|
||||
|
||||
// connect opens the link, or opens it again with the session of the last
|
||||
// try (discordgo then resumes), and holds it until it is lost or ctx ends.
|
||||
func (g *Gateway) connect(ctx context.Context, l *link, dg *discordgo.Session) (*discordgo.Session, error) {
|
||||
bot, err := g.API.Me(ctx, l.token)
|
||||
if err != nil {
|
||||
return dg, err
|
||||
}
|
||||
l.mu.Lock()
|
||||
l.bot = bot
|
||||
l.mu.Unlock()
|
||||
if dg == nil {
|
||||
dg = g.newSession(l)
|
||||
}
|
||||
select {
|
||||
case <-l.down:
|
||||
default:
|
||||
}
|
||||
if err := dg.Open(); err != nil {
|
||||
return dg, openError(err)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return dg, ctx.Err()
|
||||
case <-l.down:
|
||||
return dg, errLinkLost
|
||||
}
|
||||
}
|
||||
|
||||
// openError maps the way a Gateway open failed. A close code that says the
|
||||
// token or the intents are wrong is final.
|
||||
func openError(err error) error {
|
||||
var ce *websocket.CloseError
|
||||
if errors.As(err, &ce) {
|
||||
switch ce.Code {
|
||||
case closeAuthFailed:
|
||||
return fmt.Errorf("discord: the Gateway refused the bot token: %w", connector.ErrUnauthorized)
|
||||
case closeInvalidIntents, closeDisallowedIntent:
|
||||
return fmt.Errorf("discord: the Gateway refused the intents: turn on the Message Content intent of the bot in the developer portal: %w", connector.ErrUnauthorized)
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// newSession makes the discordgo session of a link. The session keeps its
|
||||
// Gateway state between opens, so a lost link resumes. discordgo does not
|
||||
// reconnect by itself: connect and run do, with a backoff.
|
||||
func (g *Gateway) newSession(l *link) *discordgo.Session {
|
||||
dg, _ := discordgo.New("Bot " + l.token)
|
||||
dg.StateEnabled = false
|
||||
dg.ShouldReconnectOnError = false
|
||||
dg.ShouldReconnectVoiceOnSessionError = false
|
||||
dg.SyncEvents = true
|
||||
dg.Identify.Intents = intents
|
||||
if g.Client != nil {
|
||||
dg.Client = g.Client
|
||||
}
|
||||
if g.Dialer != nil {
|
||||
dg.Dialer = g.Dialer
|
||||
}
|
||||
dg.AddHandler(func(_ *discordgo.Session, e *discordgo.MessageCreate) { l.offer(e.Message) })
|
||||
dg.AddHandler(func(_ *discordgo.Session, _ *discordgo.Disconnect) {
|
||||
select {
|
||||
case l.down <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
})
|
||||
dg.AddHandler(func(_ *discordgo.Session, e *discordgo.GuildCreate) {
|
||||
for _, c := range e.Channels {
|
||||
l.learn(c)
|
||||
}
|
||||
for _, c := range e.Threads {
|
||||
l.learn(c)
|
||||
}
|
||||
})
|
||||
dg.AddHandler(func(_ *discordgo.Session, e *discordgo.ChannelCreate) { l.learn(e.Channel) })
|
||||
dg.AddHandler(func(_ *discordgo.Session, e *discordgo.ChannelUpdate) { l.learn(e.Channel) })
|
||||
dg.AddHandler(func(_ *discordgo.Session, e *discordgo.ThreadCreate) { l.learn(e.Channel) })
|
||||
dg.AddHandler(func(_ *discordgo.Session, e *discordgo.ThreadUpdate) { l.learn(e.Channel) })
|
||||
dg.AddHandler(func(_ *discordgo.Session, e *discordgo.ThreadListSync) {
|
||||
for _, c := range e.Threads {
|
||||
l.learn(c)
|
||||
}
|
||||
})
|
||||
return dg
|
||||
}
|
||||
|
||||
// learn remembers the type and the parent of a channel or thread.
|
||||
func (l *link) learn(c *discordgo.Channel) {
|
||||
if c == nil || c.ID == "" {
|
||||
return
|
||||
}
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if len(l.chans) >= maxChans {
|
||||
clear(l.chans)
|
||||
}
|
||||
l.chans[c.ID] = chanInfo{typ: int(c.Type), parent: c.ParentID}
|
||||
}
|
||||
|
||||
// offer hands a message of the Gateway reader to the worker. It never waits:
|
||||
// a full inbox drops the message and counts it.
|
||||
func (l *link) offer(m *discordgo.Message) {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case l.in <- m:
|
||||
default:
|
||||
l.g.dropped.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
// work turns the messages of the inbox into queued updates, in order.
|
||||
func (l *link) work(ctx context.Context) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case m := <-l.in:
|
||||
l.take(ctx, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// channel tells the type and parent of a channel, from what the Gateway
|
||||
// sent, else from the REST API.
|
||||
func (l *link) channel(ctx context.Context, id string) (chanInfo, bool) {
|
||||
l.mu.Lock()
|
||||
c, ok := l.chans[id]
|
||||
l.mu.Unlock()
|
||||
if ok {
|
||||
return c, true
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
ch, err := l.g.API.Channel(cctx, l.token, id)
|
||||
if err != nil {
|
||||
return chanInfo{}, false
|
||||
}
|
||||
c = chanInfo{typ: ch.Type, parent: ch.ParentID}
|
||||
l.mu.Lock()
|
||||
if len(l.chans) >= maxChans {
|
||||
clear(l.chans)
|
||||
}
|
||||
l.chans[id] = c
|
||||
l.mu.Unlock()
|
||||
return c, true
|
||||
}
|
||||
|
||||
// take queues the update of a message, when the bot should read it.
|
||||
func (l *link) take(ctx context.Context, m *discordgo.Message) {
|
||||
l.mu.Lock()
|
||||
if _, dup := l.seen[m.ID]; dup {
|
||||
l.mu.Unlock()
|
||||
return
|
||||
}
|
||||
l.seen[m.ID] = struct{}{}
|
||||
l.seenList = append(l.seenList, m.ID)
|
||||
if len(l.seenList) > maxSeen {
|
||||
delete(l.seen, l.seenList[0])
|
||||
l.seenList = l.seenList[1:]
|
||||
}
|
||||
botID := l.bot.ID
|
||||
l.mu.Unlock()
|
||||
if !readable(m, botID) {
|
||||
return
|
||||
}
|
||||
var info chanInfo
|
||||
var known bool
|
||||
if m.GuildID != "" {
|
||||
info, known = l.channel(ctx, m.ChannelID)
|
||||
}
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
msg, ok := l.message(m, info, known)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !l.allowSender(msg.SenderID) {
|
||||
l.g.dropped.Add(1)
|
||||
return
|
||||
}
|
||||
l.last = max(l.last+1, time.Now().UnixMicro())
|
||||
msg.Cursor = l.last
|
||||
l.queue = append(l.queue, msg)
|
||||
if len(l.queue) > maxQueued {
|
||||
l.g.dropped.Add(uint64(len(l.queue) - maxQueued))
|
||||
l.queue = l.queue[len(l.queue)-maxQueued:]
|
||||
}
|
||||
close(l.wake)
|
||||
l.wake = make(chan struct{})
|
||||
}
|
||||
|
||||
// allowSender applies the per-sender limit of the queue. The caller holds l.mu.
|
||||
func (l *link) allowSender(id string) bool {
|
||||
now := time.Now()
|
||||
if len(l.rate) > maxThreads {
|
||||
for k, ts := range l.rate {
|
||||
if len(ts) == 0 || now.Sub(ts[len(ts)-1]) > time.Minute {
|
||||
delete(l.rate, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
ts := slices.DeleteFunc(l.rate[id], func(t time.Time) bool { return now.Sub(t) > time.Minute })
|
||||
if len(ts) >= SenderPerMinute {
|
||||
l.rate[id] = ts
|
||||
return false
|
||||
}
|
||||
l.rate[id] = append(ts, now)
|
||||
return true
|
||||
}
|
||||
|
||||
// follow remembers a thread that the bot answers in, and keeps it on disk
|
||||
// when the add-on has a state directory. The caller holds l.mu.
|
||||
func (l *link) follow(channel, thread string) {
|
||||
now := time.Now()
|
||||
if len(l.threads) >= maxThreads {
|
||||
for k, at := range l.threads {
|
||||
if now.Sub(at) > threadLife || len(l.threads) >= maxThreads {
|
||||
delete(l.threads, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
l.threads[threadKey(channel, thread)] = now
|
||||
l.saveThreads()
|
||||
}
|
||||
|
||||
// follows says whether a message without a mention in the thread is addressed
|
||||
// to the bot. A follow mode of never follows nothing. A followed thread that
|
||||
// is active is renewed, so it lives for threadLife after its last message.
|
||||
// The caller holds l.mu.
|
||||
func (l *link) follows(channel, thread string) bool {
|
||||
if l.mode == FollowNever {
|
||||
return false
|
||||
}
|
||||
k := threadKey(channel, thread)
|
||||
at, ok := l.threads[k]
|
||||
if !ok || time.Since(at) >= threadLife {
|
||||
return false
|
||||
}
|
||||
if time.Since(at) >= touchEvery {
|
||||
l.threads[k] = time.Now()
|
||||
l.saveThreads()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (l *link) saveThreads() {
|
||||
if err := saveThreads(l.dir, l.key, l.threads); err != nil && !l.saveFailed {
|
||||
l.saveFailed = true
|
||||
l.g.logger().Warn("discord: could not save the followed threads", "error", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
package transport_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mudler/LocalAI/addons/internal/connector"
|
||||
"github.com/mudler/LocalAI/pkg/addons/contract"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
|
||||
"github.com/mudler/LocalAI/addons/discord/transport"
|
||||
)
|
||||
|
||||
// connected polls once to open the link and waits until the fake Gateway
|
||||
// has seen the identify.
|
||||
func connected(f *fakeDiscord, g *transport.Gateway) {
|
||||
_, err := g.Poll(context.Background(), secretToken, 1, 0)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Eventually(func() int { return len(f.handshakes()) }, 10*time.Second, 10*time.Millisecond).Should(BeNumerically(">=", 1))
|
||||
Eventually(func() bool {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.conn != nil
|
||||
}).Should(BeTrue())
|
||||
// READY is sent after identify; give the client a moment to take it.
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
|
||||
// next polls until n updates are there.
|
||||
func next(g *transport.Gateway, offset int64, n int) []contract.ConnectorMessage {
|
||||
var out []contract.ConnectorMessage
|
||||
Eventually(func() int {
|
||||
var err error
|
||||
out, err = g.Poll(context.Background(), secretToken, offset, 1)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
return len(out)
|
||||
}, 10*time.Second, 10*time.Millisecond).Should(BeNumerically(">=", n))
|
||||
return out
|
||||
}
|
||||
|
||||
// none says that no update arrives: a message that is read is queued before
|
||||
// the one that follows, so a marker message shows the earlier ones were dropped.
|
||||
func settle(f *fakeDiscord, g *transport.Gateway) []contract.ConnectorMessage {
|
||||
f.dispatch("MESSAGE_CREATE", msg{channel: dmID, sender: userID, text: "marker"}.payload())
|
||||
return next(g, 1, 1)
|
||||
}
|
||||
|
||||
var _ = Describe("the Gateway link", func() {
|
||||
var (
|
||||
f *fakeDiscord
|
||||
g *transport.Gateway
|
||||
)
|
||||
BeforeEach(func() {
|
||||
f = newFakeDiscord()
|
||||
g = f.newGateway("")
|
||||
DeferCleanup(func() { g.Close(); f.Close() })
|
||||
})
|
||||
|
||||
It("identifies with the intents it needs and the token, and answers heartbeats", func() {
|
||||
connected(f, g)
|
||||
shake := f.handshakes()[0]
|
||||
Expect(shake.Op).To(Equal(2))
|
||||
Expect(shake.Data["token"]).To(ContainSubstring(secretToken))
|
||||
Expect(shake.Data["intents"]).To(BeNumerically("==", 1+512+4096+32768), "guilds, server messages, direct messages, message content")
|
||||
Eventually(func() int32 { return f.heartbeats.Load() }, 5*time.Second).Should(BeNumerically(">=", 2))
|
||||
Expect(f.called("GET /users/@me")).NotTo(BeEmpty(), "the token is checked before the link opens")
|
||||
Expect(f.called("GET /users/@me")[0].Header.Get("Authorization")).To(Equal("Bot " + secretToken))
|
||||
})
|
||||
|
||||
It("reads a direct message as direct, with the sender and the text", func() {
|
||||
connected(f, g)
|
||||
f.dispatch("MESSAGE_CREATE", msg{channel: dmID, sender: userID, text: "hello"}.payload())
|
||||
got := next(g, 1, 1)
|
||||
Expect(got).To(HaveLen(1))
|
||||
m := got[0]
|
||||
Expect(m.Direct).To(BeTrue())
|
||||
Expect(m.Chat).To(Equal(dmID))
|
||||
Expect(m.Thread).To(BeEmpty())
|
||||
Expect(m.SenderID).To(Equal(userID))
|
||||
Expect(m.SenderName).To(Equal("Ada"))
|
||||
Expect(m.SenderUsername).To(BeEmpty(), "only the ID names a sender: a username can change hands")
|
||||
Expect(m.Text).To(Equal("hello"))
|
||||
Expect(m.Cursor).To(BeNumerically(">", 1))
|
||||
})
|
||||
|
||||
It("reads a server message only when it mentions the bot or replies to it", func() {
|
||||
connected(f, g)
|
||||
f.dispatch("MESSAGE_CREATE", msg{channel: chanID, guild: guildID, sender: userID, text: "just talking"}.payload())
|
||||
f.dispatch("MESSAGE_CREATE", msg{channel: chanID, guild: guildID, sender: userID, text: "@everyone look"}.payload())
|
||||
f.dispatch("MESSAGE_CREATE", msg{channel: chanID, guild: guildID, sender: userID, text: fmt.Sprintf("<@%s> what is 2+2?", botID), mentionBot: true}.payload())
|
||||
f.dispatch("MESSAGE_CREATE", msg{channel: chanID, guild: guildID, sender: userID, text: "and then?", reply: botID}.payload())
|
||||
f.dispatch("MESSAGE_CREATE", msg{channel: chanID, guild: guildID, sender: userID, text: "reply to someone else", reply: userID}.payload())
|
||||
got := next(g, 1, 2)
|
||||
Expect(got).To(HaveLen(2))
|
||||
Expect(got[0].Mention).To(BeTrue())
|
||||
Expect(got[0].Direct).To(BeFalse())
|
||||
Expect(got[0].Chat).To(Equal(chanID), "the chat is the channel, so an allowlist of channels works")
|
||||
Expect(got[0].Text).To(Equal("what is 2+2?"), "the mention of the bot is not part of the question")
|
||||
Expect(got[1].Text).To(Equal("and then?"))
|
||||
Expect(got[1].ReplyTo).NotTo(BeEmpty())
|
||||
Expect(got[1].Mention).To(BeTrue())
|
||||
})
|
||||
|
||||
It("ignores every message that a bot, a webhook or Discord wrote, and the bot's own", func() {
|
||||
connected(f, g)
|
||||
f.dispatch("MESSAGE_CREATE", msg{channel: dmID, sender: botID, text: "my own"}.payload())
|
||||
f.dispatch("MESSAGE_CREATE", msg{channel: dmID, sender: "400000000000000009", text: "another bot", bot: true}.payload())
|
||||
f.dispatch("MESSAGE_CREATE", msg{channel: chanID, guild: guildID, sender: userID, text: "webhook", mentionBot: true, webhook: true}.payload())
|
||||
f.dispatch("MESSAGE_CREATE", msg{channel: chanID, guild: guildID, sender: userID, text: "pinned a message", mentionBot: true, typ: 6}.payload())
|
||||
got := settle(f, g)
|
||||
Expect(got).To(HaveLen(1))
|
||||
Expect(got[0].Text).To(Equal("marker"))
|
||||
})
|
||||
|
||||
It("puts the parent channel in the chat of a thread message, from what the Gateway told", func() {
|
||||
connected(f, g)
|
||||
f.dispatch("THREAD_CREATE", map[string]any{"id": threadI, "type": 11, "parent_id": chanID, "guild_id": guildID, "newly_created": true})
|
||||
f.dispatch("MESSAGE_CREATE", msg{channel: threadI, guild: guildID, sender: userID, text: "in the thread", mentionBot: true}.payload())
|
||||
got := next(g, 1, 1)
|
||||
Expect(got[0].Chat).To(Equal(chanID), "the allowlist covers the threads of a listed channel")
|
||||
Expect(got[0].Thread).To(Equal(threadI))
|
||||
Expect(got[0].Mention).To(BeTrue())
|
||||
Expect(f.called("GET /channels/:id")).To(BeEmpty(), "no lookup was needed")
|
||||
})
|
||||
|
||||
It("learns a thread from the guild it belongs to, and asks for one it does not know", func() {
|
||||
connected(f, g)
|
||||
f.dispatch("GUILD_CREATE", map[string]any{"id": guildID, "name": "g", "channels": []any{map[string]any{"id": chanID, "type": 0}},
|
||||
"threads": []any{map[string]any{"id": "300000000000000077", "type": 12, "parent_id": chanID}}})
|
||||
f.dispatch("MESSAGE_CREATE", msg{channel: "300000000000000077", guild: guildID, sender: userID, text: "private thread", mentionBot: true}.payload())
|
||||
got := next(g, 1, 1)
|
||||
Expect(got[0].Thread).To(Equal("300000000000000077"))
|
||||
Expect(got[0].Chat).To(Equal(chanID))
|
||||
// A thread the Gateway never named: one lookup of the channel.
|
||||
f.dispatch("MESSAGE_CREATE", msg{channel: threadI, guild: guildID, sender: userID, text: "unknown thread", mentionBot: true}.payload())
|
||||
got = next(g, got[0].Cursor+1, 1)
|
||||
Expect(got[0].Chat).To(Equal(chanID))
|
||||
Expect(got[0].Thread).To(Equal(threadI))
|
||||
Expect(f.called("GET /channels/:id")).To(HaveLen(1))
|
||||
})
|
||||
|
||||
It("follows a thread once the bot was mentioned in it, and not with the option never", func() {
|
||||
connected(f, g)
|
||||
f.dispatch("THREAD_CREATE", map[string]any{"id": threadI, "type": 11, "parent_id": chanID, "guild_id": guildID})
|
||||
f.dispatch("MESSAGE_CREATE", msg{channel: threadI, guild: guildID, sender: userID, text: "no mention before"}.payload())
|
||||
f.dispatch("MESSAGE_CREATE", msg{channel: threadI, guild: guildID, sender: userID, text: "hey bot", mentionBot: true}.payload())
|
||||
f.dispatch("MESSAGE_CREATE", msg{channel: threadI, guild: guildID, sender: "400000000000000002", text: "a second person, no mention"}.payload())
|
||||
got := next(g, 1, 2)
|
||||
Expect(got).To(HaveLen(2), "the first message came before the mention")
|
||||
Expect(got[0].Text).To(Equal("hey bot"))
|
||||
Expect(got[1].Text).To(Equal("a second person, no mention"))
|
||||
Expect(got[1].Mention).To(BeTrue(), "addressed because the bot follows the thread")
|
||||
Expect(got[1].Thread).To(Equal(threadI))
|
||||
})
|
||||
|
||||
It("keeps the followed threads over a restart", func() {
|
||||
dir := GinkgoT().TempDir()
|
||||
g.Close()
|
||||
g = f.newGateway(dir)
|
||||
connected(f, g)
|
||||
f.dispatch("THREAD_CREATE", map[string]any{"id": threadI, "type": 11, "parent_id": chanID, "guild_id": guildID})
|
||||
f.dispatch("MESSAGE_CREATE", msg{channel: threadI, guild: guildID, sender: userID, text: "start", mentionBot: true}.payload())
|
||||
first := next(g, 1, 1)
|
||||
g.Close()
|
||||
|
||||
g = f.newGateway(dir)
|
||||
connected(f, g)
|
||||
f.dispatch("THREAD_CREATE", map[string]any{"id": threadI, "type": 11, "parent_id": chanID, "guild_id": guildID})
|
||||
f.dispatch("MESSAGE_CREATE", msg{channel: threadI, guild: guildID, sender: userID, text: "after the restart"}.payload())
|
||||
got := next(g, first[0].Cursor+1, 1)
|
||||
Expect(got[0].Text).To(Equal("after the restart"))
|
||||
})
|
||||
|
||||
It("hands over a file as a reference to the channel, message and file", func() {
|
||||
connected(f, g)
|
||||
p := msg{channel: dmID, sender: userID, text: "", attachments: []map[string]any{{"id": "800000000000000001", "filename": "a.png", "size": 10, "url": "https://cdn.discordapp.com/x"}}}.payload()
|
||||
f.dispatch("MESSAGE_CREATE", p)
|
||||
got := next(g, 1, 1)
|
||||
Expect(got[0].Media).To(Equal([]string{dmID + "/" + p["id"].(string) + "/800000000000000001"}))
|
||||
Expect(got[0].Unsupported).To(BeFalse())
|
||||
f.dispatch("MESSAGE_CREATE", msg{channel: dmID, sender: userID}.payload())
|
||||
got = next(g, got[0].Cursor+1, 1)
|
||||
Expect(got[0].Unsupported).To(BeTrue(), "no text and no file")
|
||||
})
|
||||
|
||||
It("does not hand over the same message twice, nor one the host has handled", func() {
|
||||
connected(f, g)
|
||||
p := msg{channel: dmID, sender: userID, text: "once"}.payload()
|
||||
f.dispatch("MESSAGE_CREATE", p)
|
||||
f.dispatch("MESSAGE_CREATE", p)
|
||||
got := next(g, 1, 1)
|
||||
Expect(got).To(HaveLen(1))
|
||||
again, err := g.Poll(context.Background(), secretToken, 1, 0)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(again).To(HaveLen(1), "not handled yet: still there")
|
||||
done, err := g.Poll(context.Background(), secretToken, got[0].Cursor+1, 0)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(done).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("resumes the session after the link is lost and goes on with the messages", func() {
|
||||
connected(f, g)
|
||||
f.dispatch("MESSAGE_CREATE", msg{channel: dmID, sender: userID, text: "before"}.payload())
|
||||
first := next(g, 1, 1)
|
||||
f.drop()
|
||||
Eventually(func() int { return len(f.handshakes()) }, 15*time.Second, 20*time.Millisecond).Should(BeNumerically(">=", 2))
|
||||
shake := f.handshakes()[1]
|
||||
Expect(shake.Op).To(Equal(6), "a resume, not a new identify")
|
||||
Expect(shake.Data["session_id"]).To(Equal("sess-1"))
|
||||
Expect(shake.Data["seq"]).To(BeNumerically(">", 0))
|
||||
Expect(shake.Data["token"]).To(ContainSubstring(secretToken))
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
f.dispatch("MESSAGE_CREATE", msg{channel: dmID, sender: userID, text: "after"}.payload())
|
||||
got := next(g, first[0].Cursor+1, 1)
|
||||
Expect(got[0].Text).To(Equal("after"))
|
||||
})
|
||||
|
||||
DescribeTable("stops for good and says why when Discord refuses the token or the intents",
|
||||
func(code int, want string) {
|
||||
f.closeIdentify = code
|
||||
var err error
|
||||
Eventually(func() error {
|
||||
_, err = g.Poll(context.Background(), secretToken, 1, 1)
|
||||
return err
|
||||
}, 15*time.Second, 50*time.Millisecond).Should(HaveOccurred())
|
||||
Expect(errors.Is(err, connector.ErrUnauthorized)).To(BeTrue())
|
||||
Expect(err.Error()).To(ContainSubstring(want))
|
||||
Expect(err.Error()).NotTo(ContainSubstring("SECRET"))
|
||||
Expect(len(f.handshakes())).To(Equal(1), "a refused token is not tried again: identifies are limited")
|
||||
},
|
||||
Entry("a bad token", 4004, "refused the bot token"),
|
||||
Entry("intents that are not allowed", 4014, "Message Content"),
|
||||
Entry("invalid intents", 4013, "Message Content"),
|
||||
)
|
||||
|
||||
It("stops for good when the REST API refuses the token", func() {
|
||||
f.on["GET /users/@me"] = func(restCall) reply { return reply{Status: 401, Body: `{"message":"401: Unauthorized","code":0}`} }
|
||||
var err error
|
||||
Eventually(func() error {
|
||||
_, err = g.Poll(context.Background(), secretToken, 1, 1)
|
||||
return err
|
||||
}, 5*time.Second, 20*time.Millisecond).Should(HaveOccurred())
|
||||
Expect(errors.Is(err, connector.ErrUnauthorized)).To(BeTrue())
|
||||
Expect(f.handshakes()).To(BeEmpty(), "no Gateway link for a token that is refused")
|
||||
})
|
||||
|
||||
It("closes a link that nobody polls", func() {
|
||||
g.Close()
|
||||
g = f.newGateway("")
|
||||
g.IdleAfter = 300 * time.Millisecond
|
||||
connected(f, g)
|
||||
Expect(g.Links()).To(Equal(1))
|
||||
Eventually(g.Links, 5*time.Second, 50*time.Millisecond).Should(Equal(0))
|
||||
})
|
||||
|
||||
It("never hands out a cursor below the offset the host stored", func() {
|
||||
connected(f, g)
|
||||
high := time.Now().Add(time.Hour).UnixMicro()
|
||||
g.Close()
|
||||
g = f.newGateway("")
|
||||
_, err := g.Poll(context.Background(), secretToken, high, 0)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Eventually(func() int { return len(f.handshakes()) }, 10*time.Second).Should(BeNumerically(">=", 2))
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
f.dispatch("MESSAGE_CREATE", msg{channel: dmID, sender: userID, text: "late"}.payload())
|
||||
got := next(g, high, 1)
|
||||
Expect(got[0].Cursor).To(BeNumerically(">=", high))
|
||||
})
|
||||
|
||||
It("limits the messages of one sender and counts what it drops", func() {
|
||||
connected(f, g)
|
||||
total := transport.SenderPerMinute + 10
|
||||
for i := range total {
|
||||
f.dispatch("MESSAGE_CREATE", msg{channel: dmID, sender: userID, text: fmt.Sprintf("m%d", i)}.payload())
|
||||
}
|
||||
f.dispatch("MESSAGE_CREATE", msg{channel: dmID, sender: "400000000000000002", text: "someone else"}.payload())
|
||||
Eventually(g.Dropped, 5*time.Second).Should(BeEquivalentTo(10))
|
||||
got := next(g, 1, transport.SenderPerMinute+1)
|
||||
Expect(got).To(HaveLen(transport.SenderPerMinute + 1))
|
||||
Expect(got[len(got)-1].Text).To(Equal("someone else"), "another sender is not held back")
|
||||
})
|
||||
|
||||
It("keeps the queue bounded: it drops the oldest messages and counts them", func() {
|
||||
connected(f, g)
|
||||
senders := 20
|
||||
perSender := 29
|
||||
for i := range senders * perSender {
|
||||
f.dispatch("MESSAGE_CREATE", msg{channel: dmID, sender: fmt.Sprintf("4000000000000001%02d", i%senders), text: fmt.Sprintf("m%d", i)}.payload())
|
||||
}
|
||||
Eventually(g.Dropped, 10*time.Second).Should(BeEquivalentTo(senders*perSender - 500))
|
||||
var got []contract.ConnectorMessage
|
||||
Eventually(func() int {
|
||||
var err error
|
||||
got, err = g.Poll(context.Background(), secretToken, 1, 0)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
return len(got)
|
||||
}, 5*time.Second).Should(BeNumerically(">", 0))
|
||||
Expect(len(got)).To(BeNumerically("<=", 100), "one poll hands over at most 100")
|
||||
Expect(strings.HasPrefix(got[0].Text, "m")).To(BeTrue())
|
||||
Expect(got[0].Text).NotTo(Equal("m0"), "the oldest were dropped")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,113 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
package transport_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
|
||||
"github.com/mudler/LocalAI/addons/discord/transport"
|
||||
"github.com/mudler/LocalAI/pkg/addons/contract"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
const (
|
||||
msgFile = "800000000000000001"
|
||||
attID = "810000000000000001"
|
||||
)
|
||||
|
||||
var pngBytes = append([]byte("\x89PNG\r\n\x1a\n"), bytes.Repeat([]byte{7}, 100)...)
|
||||
|
||||
var _ = Describe("the files of messages", func() {
|
||||
var (
|
||||
f *fakeDiscord
|
||||
h *memHost
|
||||
r *transport.Runtime
|
||||
)
|
||||
ref := dmID + "/" + msgFile + "/" + attID
|
||||
BeforeEach(func() {
|
||||
f = newFakeDiscord()
|
||||
h = &memHost{}
|
||||
r = f.runtime(h)
|
||||
DeferCleanup(func() { r.Close(); f.Close() })
|
||||
f.files["files/pic.png"] = pngBytes
|
||||
f.messages[dmID+"/"+msgFile] = map[string]any{"id": msgFile, "channel_id": dmID, "content": "", "author": author(userID, "ada", false),
|
||||
"attachments": []any{map[string]any{"id": attID, "filename": "../../pic.png", "content_type": "image/png", "size": len(pngBytes),
|
||||
"url": f.srv.URL + "/cdn/files/pic.png?ex=1&is=2&hm=3"}}}
|
||||
})
|
||||
handle := &contract.ConnectorTransfer{Handle: "h", MaxBytes: 1 << 20}
|
||||
|
||||
It("streams a file of a message to the host by handle, with a safe name, from a fresh URL", func() {
|
||||
out := call(r, contract.ConnectorRequest{Op: "media", Media: ref, Transfer: handle})
|
||||
Expect(out.Error).To(BeNil())
|
||||
Expect(out.Received.Name).To(Equal("pic.png"))
|
||||
Expect(out.Received.MIME).To(Equal("image/png"))
|
||||
Expect(h.got.Bytes()).To(Equal(pngBytes))
|
||||
Expect(f.called("GET /channels/:id/messages/:id")).To(HaveLen(1), "the message is read for the current URL")
|
||||
cdn := f.called("GET /cdn/files/pic.png")
|
||||
Expect(cdn).To(HaveLen(1))
|
||||
Expect(cdn[0].Header.Get("Authorization")).To(BeEmpty(), "the CDN never gets the token")
|
||||
})
|
||||
|
||||
DescribeTable("refuses a file it must not fetch",
|
||||
func(ref string, max int64, mutate func(), code string, downloads int) {
|
||||
mutate()
|
||||
out := call(r, contract.ConnectorRequest{Op: "media", Media: ref, Transfer: &contract.ConnectorTransfer{Handle: "h", MaxBytes: max}})
|
||||
Expect(out.Error).NotTo(BeNil())
|
||||
Expect(out.Error.Code).To(Equal(code))
|
||||
Expect(out.Error.Message).NotTo(ContainSubstring("SECRET"))
|
||||
Expect(out.Received).To(BeNil())
|
||||
Expect(f.called("GET /cdn/files/pic.png")).To(HaveLen(downloads))
|
||||
},
|
||||
Entry("larger than the bound of the handle", ref, int64(50), func() {}, contract.ConnectorTooLarge, 0),
|
||||
Entry("larger than the add-on hands over", ref, int64(1<<30), func() {
|
||||
f.messages[dmID+"/"+msgFile]["attachments"].([]any)[0].(map[string]any)["size"] = transport.MaxFileBytes + 1
|
||||
}, contract.ConnectorTooLarge, 0),
|
||||
Entry("a reference with a path in it", dmID+"/../"+attID, int64(1<<20), func() {}, contract.ConnectorFailed, 0),
|
||||
Entry("a reference of the wrong shape", "pic.png", int64(1<<20), func() {}, contract.ConnectorFailed, 0),
|
||||
Entry("a file that the message no longer has", dmID+"/"+msgFile+"/819999999999999999", int64(1<<20), func() {}, contract.ConnectorFailed, 0),
|
||||
Entry("a URL outside the Discord CDN", ref, int64(1<<20), func() {
|
||||
f.messages[dmID+"/"+msgFile]["attachments"].([]any)[0].(map[string]any)["url"] = "http://evil.example/x"
|
||||
}, contract.ConnectorFailed, 0),
|
||||
Entry("a download that fails", ref, int64(1<<20), func() { delete(f.files, "files/pic.png") }, contract.ConnectorFailed, 1),
|
||||
)
|
||||
|
||||
It("keeps the token out of the error of a failed message read", func() {
|
||||
f.on["GET /channels/:id/messages/:id"] = func(restCall) reply {
|
||||
return reply{Status: 403, Body: `{"code":50001,"message":"Missing Access ` + secretToken + `"}`}
|
||||
}
|
||||
out := call(r, contract.ConnectorRequest{Op: "media", Media: ref, Transfer: handle})
|
||||
Expect(out.Error).NotTo(BeNil())
|
||||
Expect(out.Error.Message).NotTo(ContainSubstring("SECRET"))
|
||||
})
|
||||
|
||||
It("uploads a file from the host as one attachment in a message with no text and no pings", func() {
|
||||
h.src = pngBytes
|
||||
out := call(r, contract.ConnectorRequest{Op: "send_file", Chat: chanID, Thread: threadI,
|
||||
Transfer: &contract.ConnectorTransfer{Handle: "h", Name: "pic.png", Size: int64(len(pngBytes))}})
|
||||
Expect(out.Error).To(BeNil())
|
||||
Expect(out.MessageID).NotTo(BeEmpty())
|
||||
c := f.called("POST /channels/:id/messages")
|
||||
Expect(c).To(HaveLen(1))
|
||||
Expect(c[0].Path).To(Equal("/channels/" + threadI + "/messages"))
|
||||
Expect(c[0].File.Name).To(Equal("pic.png"))
|
||||
Expect(c[0].File.Data).To(Equal(pngBytes))
|
||||
Expect(c[0].Body["allowed_mentions"]).To(Equal(map[string]any{"parse": []any{}}))
|
||||
Expect(c[0].Header.Get("Content-Type")).To(HavePrefix("multipart/form-data"))
|
||||
})
|
||||
|
||||
It("refuses to upload a file over the limit before it reads a byte", func() {
|
||||
out := call(r, contract.ConnectorRequest{Op: "send_file", Chat: chanID,
|
||||
Transfer: &contract.ConnectorTransfer{Handle: "h", Name: "big.bin", Size: transport.MaxFileBytes + 1}})
|
||||
Expect(out.Error).NotTo(BeNil())
|
||||
Expect(out.Error.Code).To(Equal(contract.ConnectorTooLarge))
|
||||
Expect(f.called("POST /channels/:id/messages")).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("fails an upload whose file is not the size it said", func() {
|
||||
h.src = []byte(strings.Repeat("a", 50))
|
||||
out := call(r, contract.ConnectorRequest{Op: "send_file", Chat: chanID,
|
||||
Transfer: &contract.ConnectorTransfer{Handle: "h", Name: "x.txt", Size: 10}})
|
||||
Expect(out.Error).NotTo(BeNil())
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,110 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
package transport
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
|
||||
"github.com/mudler/LocalAI/pkg/addons/contract"
|
||||
)
|
||||
|
||||
// readable says whether a message can be one that the bot reads at all: it was
|
||||
// written by a person, in a normal way. The bot's own messages, those of any
|
||||
// other bot, of webhooks and of Discord (joins, pins, thread starters) are
|
||||
// left out, so two bots can never answer each other.
|
||||
func readable(m *discordgo.Message, bot string) bool {
|
||||
if m.Author == nil || m.Author.ID == "" || m.Author.ID == bot || m.Author.Bot || m.Author.System || m.WebhookID != "" {
|
||||
return false
|
||||
}
|
||||
return m.Type == discordgo.MessageTypeDefault || m.Type == discordgo.MessageTypeReply
|
||||
}
|
||||
|
||||
func users(in []*discordgo.User) []User {
|
||||
out := make([]User, 0, len(in))
|
||||
for _, u := range in {
|
||||
if u != nil {
|
||||
out = append(out, User{ID: u.ID, Username: u.Username, GlobalName: u.GlobalName, Bot: u.Bot})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mediaRef names a file of a message so that the op media can find it again,
|
||||
// with a fresh signed URL: channel, message and attachment IDs.
|
||||
func mediaRef(channel, message, attachment string) string {
|
||||
return channel + "/" + message + "/" + attachment
|
||||
}
|
||||
|
||||
// splitRef is the reverse of mediaRef.
|
||||
func splitRef(ref string) (channel, message, attachment string, ok bool) {
|
||||
p := strings.Split(ref, "/")
|
||||
if len(p) != 3 || !isSnowflake(p[0]) || !isSnowflake(p[1]) || !isSnowflake(p[2]) {
|
||||
return "", "", "", false
|
||||
}
|
||||
return p[0], p[1], p[2], true
|
||||
}
|
||||
|
||||
// message turns a Gateway message into the update that the host reads, or
|
||||
// false when the bot does not read it:
|
||||
//
|
||||
// - A message in a direct conversation (no server) is direct. The host
|
||||
// answers it only for a listed person, or when the binding accepts anyone.
|
||||
// - A message in a server channel addresses the bot when it mentions the
|
||||
// bot or replies to a message of the bot. Anything else is not read. The
|
||||
// chat is the channel, so an allowlist of channels works.
|
||||
// - A message in a thread has the parent channel as its chat and the thread
|
||||
// as its thread, so the allowlist of the channel covers its threads. It
|
||||
// addresses the bot with a mention, or when the bot follows the thread
|
||||
// (the option followThreads): a thread in which the bot was mentioned.
|
||||
// - @everyone, @here and role mentions never address the bot.
|
||||
//
|
||||
// info and known describe the channel of a message of a server. The caller
|
||||
// holds l.mu.
|
||||
func (l *link) message(m *discordgo.Message, info chanInfo, known bool) (contract.ConnectorMessage, bool) {
|
||||
bot := l.bot.ID
|
||||
mentions := users(m.Mentions)
|
||||
addressed := false
|
||||
for _, u := range m.Mentions {
|
||||
if u != nil && u.ID == bot {
|
||||
addressed = true
|
||||
}
|
||||
}
|
||||
if r := m.ReferencedMessage; r != nil && r.Author != nil && r.Author.ID == bot {
|
||||
addressed = true
|
||||
}
|
||||
author := User{ID: m.Author.ID, Username: m.Author.Username, GlobalName: m.Author.GlobalName}
|
||||
out := contract.ConnectorMessage{
|
||||
SenderID: author.ID,
|
||||
SenderName: author.Display(),
|
||||
MessageID: m.ID,
|
||||
Text: Plain(m.Content, mentions, bot),
|
||||
}
|
||||
// Only the ID names a sender: a username can change hands, so a name
|
||||
// in the allowlist would not be a safe key.
|
||||
if m.MessageReference != nil && m.Type == discordgo.MessageTypeReply {
|
||||
out.ReplyTo = m.MessageReference.MessageID
|
||||
}
|
||||
switch {
|
||||
case m.GuildID == "":
|
||||
out.Direct, out.Chat = true, m.ChannelID
|
||||
case known && IsThread(info.typ) && info.parent != "":
|
||||
out.Chat, out.Thread = info.parent, m.ChannelID
|
||||
out.Mention = addressed || l.follows(info.parent, m.ChannelID)
|
||||
if addressed && l.mode != FollowNever {
|
||||
l.follow(info.parent, m.ChannelID)
|
||||
}
|
||||
default:
|
||||
out.Chat, out.Mention = m.ChannelID, addressed
|
||||
}
|
||||
if !out.Direct && !out.Mention {
|
||||
return contract.ConnectorMessage{}, false
|
||||
}
|
||||
for _, a := range m.Attachments {
|
||||
if a != nil && isSnowflake(a.ID) && len(out.Media) < contract.MaxConnectorMedia {
|
||||
out.Media = append(out.Media, mediaRef(m.ChannelID, m.ID, a.ID))
|
||||
}
|
||||
}
|
||||
out.Unsupported = out.Text == "" && len(out.Media) == 0
|
||||
return out, true
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
package transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mudler/LocalAI/addons/internal/connector"
|
||||
"github.com/mudler/LocalAI/addons/internal/idle"
|
||||
"github.com/mudler/LocalAI/pkg/addons/contract"
|
||||
)
|
||||
|
||||
// Kind is the one kind of binding this add-on serves.
|
||||
const Kind = "discord"
|
||||
|
||||
// MaxMessageRunes is the longest text of one message that Discord takes.
|
||||
const MaxMessageRunes = 2000
|
||||
|
||||
// Runtime is the add-on runtime: the connector route over the REST API and
|
||||
// the Gateway. It declares no tool, so every other operation is refused as in
|
||||
// any add-on that has only a UI.
|
||||
type Runtime struct {
|
||||
idle.Runtime
|
||||
API *API
|
||||
Gateway *Gateway
|
||||
// Host is the host operation channel, with which a file moves by handle.
|
||||
// Nil: the add-on cannot move files.
|
||||
Host connector.Host
|
||||
|
||||
mu sync.Mutex
|
||||
dms map[string]dmChannel
|
||||
}
|
||||
|
||||
type dmChannel struct {
|
||||
id string
|
||||
at time.Time
|
||||
}
|
||||
|
||||
// New returns a runtime over api.
|
||||
func New(api *API) *Runtime {
|
||||
return &Runtime{API: api, Gateway: &Gateway{API: api}, dms: map[string]dmChannel{}}
|
||||
}
|
||||
|
||||
var _ contract.ConnectorRuntime = (*Runtime)(nil)
|
||||
|
||||
// Connector serves one call of the host through the shared route of the
|
||||
// connector add-ons.
|
||||
func (r *Runtime) Connector(ctx context.Context, req contract.ConnectorRequest) (contract.ConnectorResponse, error) {
|
||||
return connector.ServeWith(ctx, platform{r}, r.Host, req)
|
||||
}
|
||||
|
||||
// Close closes the Gateway links.
|
||||
func (r *Runtime) Close() { r.Gateway.Close() }
|
||||
|
||||
// platform is Discord as a connector.Platform. The secret of a binding is the
|
||||
// bot token.
|
||||
type platform struct{ r *Runtime }
|
||||
|
||||
var (
|
||||
_ connector.Formatter = platform{}
|
||||
_ connector.Splitter = platform{}
|
||||
_ connector.HistoryReader = platform{}
|
||||
_ connector.StreamReader = platform{}
|
||||
_ connector.StreamSender = platform{}
|
||||
)
|
||||
|
||||
func (platform) Kind() string { return Kind }
|
||||
func (platform) MaxRunes() int { return MaxMessageRunes }
|
||||
func (platform) Format(markdown string) string { return Format(markdown) }
|
||||
func (platform) Split(text string) []string { return connector.SplitFenced(text, MaxMessageRunes) }
|
||||
|
||||
func (p platform) Poll(ctx context.Context, token string, offset int64, wait int) ([]contract.ConnectorMessage, error) {
|
||||
return p.r.Gateway.Poll(ctx, token, offset, wait)
|
||||
}
|
||||
|
||||
// userPrefix marks a chat that is a person: the add-on opens the direct
|
||||
// conversation with them. It is for the notifications of a binding, whose
|
||||
// setting holds one ID that can be a channel or a person.
|
||||
const userPrefix = "user:"
|
||||
|
||||
// channel is where a request writes: the thread when it has one, else the
|
||||
// chat. A chat of the form user:<ID> is the direct conversation with a person.
|
||||
func (r *Runtime) channel(ctx context.Context, req contract.ConnectorRequest) (string, error) {
|
||||
if req.Thread != "" {
|
||||
return req.Thread, nil
|
||||
}
|
||||
user, ok := strings.CutPrefix(req.Chat, userPrefix)
|
||||
if !ok {
|
||||
return req.Chat, nil
|
||||
}
|
||||
key := keyOf(req.Token) + user
|
||||
r.mu.Lock()
|
||||
d, hit := r.dms[key]
|
||||
r.mu.Unlock()
|
||||
if hit && time.Since(d.at) < time.Hour {
|
||||
return d.id, nil
|
||||
}
|
||||
id, err := r.API.DMChannel(ctx, req.Token, user)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
r.mu.Lock()
|
||||
if len(r.dms) > maxThreads {
|
||||
clear(r.dms)
|
||||
}
|
||||
r.dms[key] = dmChannel{id: id, at: time.Now()}
|
||||
r.mu.Unlock()
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (p platform) Send(ctx context.Context, req contract.ConnectorRequest) (string, error) {
|
||||
ch, err := p.r.channel(ctx, req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return p.r.API.PostMessage(ctx, req.Token, ch, req.Text)
|
||||
}
|
||||
|
||||
func (p platform) Edit(ctx context.Context, req contract.ConnectorRequest) error {
|
||||
ch, err := p.r.channel(ctx, req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return p.r.API.EditMessage(ctx, req.Token, ch, req.MessageID, req.Text)
|
||||
}
|
||||
|
||||
// Typing does nothing. The typing indicator of Discord lasts ten seconds and
|
||||
// its documentation discourages it for bots; the host shows its own working
|
||||
// message and edits it.
|
||||
func (platform) Typing(context.Context, contract.ConnectorRequest) error { return nil }
|
||||
|
||||
// Me checks the token and names the bot. It also says when the Message
|
||||
// Content intent is off, because the bot then reads no text in servers.
|
||||
func (p platform) Me(ctx context.Context, token string) (connector.Bot, error) {
|
||||
u, err := p.r.API.Me(ctx, token)
|
||||
if err != nil {
|
||||
return connector.Bot{}, err
|
||||
}
|
||||
name := u.Username
|
||||
if on, ok := p.r.API.MessageContentOn(ctx, token); ok && !on {
|
||||
name += " (warning: the Message Content intent is off, so the bot cannot read messages in servers; turn it on in the developer portal)"
|
||||
}
|
||||
return connector.Bot{ID: u.ID, Username: name}, nil
|
||||
}
|
||||
|
||||
// MediaStream streams a file of a message to the host. The reference holds
|
||||
// the channel, message and file IDs; the message is read again for the current
|
||||
// signed URL of the file. The size is checked first, so a file over the bound
|
||||
// is refused before any byte moves.
|
||||
func (p platform) MediaStream(ctx context.Context, token, ref string, bound int64) (connector.MediaStream, error) {
|
||||
channel, message, attachment, ok := splitRef(ref)
|
||||
if !ok {
|
||||
return connector.MediaStream{}, errors.New("discord: not a file reference")
|
||||
}
|
||||
m, err := p.r.API.Message(ctx, token, channel, message)
|
||||
if err != nil {
|
||||
return connector.MediaStream{}, err
|
||||
}
|
||||
for _, a := range m.Attachments {
|
||||
if a.ID != attachment {
|
||||
continue
|
||||
}
|
||||
if a.Size > min(bound, MaxFileBytes) {
|
||||
return connector.MediaStream{}, ErrTooLarge
|
||||
}
|
||||
body, err := p.r.API.DownloadStream(ctx, a.URL)
|
||||
if err != nil {
|
||||
return connector.MediaStream{}, err
|
||||
}
|
||||
name := a.Filename
|
||||
if name == "" {
|
||||
name = a.ID
|
||||
}
|
||||
return connector.MediaStream{Name: name, MIME: a.ContentType, Size: a.Size, Body: body}, nil
|
||||
}
|
||||
return connector.MediaStream{}, errors.New("discord: the message has no such file any more")
|
||||
}
|
||||
|
||||
// SendStream uploads a file that the host hands over by handle.
|
||||
func (p platform) SendStream(ctx context.Context, req contract.ConnectorRequest, body io.Reader) (string, error) {
|
||||
ch, err := p.r.channel(ctx, req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return p.r.API.UploadStream(ctx, req.Token, ch, req.Transfer.Name, req.Transfer.Size, body)
|
||||
}
|
||||
|
||||
// History returns the earlier messages of a thread: the newest req.Limit
|
||||
// before req.Before, oldest first. A message of the bot is marked Own.
|
||||
// Messages of other bots and of Discord (joins, pins) are left out, as the
|
||||
// bot does not read them live either. The file bytes are never read, only
|
||||
// their names. Discord returns at most 100 messages for one call.
|
||||
func (p platform) History(ctx context.Context, req contract.ConnectorRequest) ([]contract.ConnectorHistoryMessage, bool, error) {
|
||||
want := min(req.Limit+1, 100)
|
||||
raw, err := p.r.API.Before(ctx, req.Token, req.Thread, req.Before, want)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
more := len(raw) >= want
|
||||
self := p.r.Gateway.botID(ctx, req.Token)
|
||||
out := make([]contract.ConnectorHistoryMessage, 0, len(raw))
|
||||
// Discord returns the newest first.
|
||||
for i := len(raw) - 1; i >= 0; i-- {
|
||||
m := raw[i]
|
||||
own := self != "" && m.Author.ID == self
|
||||
if !own && (m.Author.Bot || m.WebhookID != "" || (m.Type != 0 && m.Type != 19)) {
|
||||
continue
|
||||
}
|
||||
h := contract.ConnectorHistoryMessage{
|
||||
MessageID: m.ID, SenderID: m.Author.ID, SenderName: m.Author.Display(),
|
||||
Text: Plain(m.Content, m.Mentions, self), TS: m.Timestamp.UTC(), Own: own,
|
||||
}
|
||||
for _, a := range m.Attachments {
|
||||
h.Media++
|
||||
if len(h.MediaNames) < contract.MaxHistoryMedia && a.Filename != "" {
|
||||
h.MediaNames = append(h.MediaNames, a.Filename)
|
||||
}
|
||||
}
|
||||
out = append(out, h)
|
||||
}
|
||||
if len(out) > req.Limit {
|
||||
out, more = out[len(out)-req.Limit:], true
|
||||
}
|
||||
return out, more, nil
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
package transport_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/mudler/LocalAI/addons/discord/transport"
|
||||
"github.com/mudler/LocalAI/pkg/addons/contract"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func call(rt *transport.Runtime, req contract.ConnectorRequest) contract.ConnectorResponse {
|
||||
req.Kind, req.Token = "discord", secretToken
|
||||
out, err := rt.Connector(context.Background(), req)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
return out
|
||||
}
|
||||
|
||||
var _ = Describe("the connector route", func() {
|
||||
var (
|
||||
f *fakeDiscord
|
||||
rt *transport.Runtime
|
||||
)
|
||||
BeforeEach(func() {
|
||||
f = newFakeDiscord()
|
||||
rt = f.runtime(nil)
|
||||
DeferCleanup(func() { rt.Close(); f.Close() })
|
||||
})
|
||||
|
||||
Describe("me", func() {
|
||||
It("names the bot and says nothing else when the Message Content intent is on", func() {
|
||||
out := call(rt, contract.ConnectorRequest{Op: "me"})
|
||||
Expect(out.Error).To(BeNil())
|
||||
Expect(out.BotID).To(Equal(botID))
|
||||
Expect(out.BotUsername).To(Equal("localai-bot"))
|
||||
})
|
||||
|
||||
It("warns in the name when the Message Content intent is off", func() {
|
||||
f.intentsOff = true
|
||||
out := call(rt, contract.ConnectorRequest{Op: "me"})
|
||||
Expect(out.Error).To(BeNil())
|
||||
Expect(out.BotUsername).To(ContainSubstring("Message Content intent is off"))
|
||||
})
|
||||
|
||||
It("keeps a failing application call from failing the check", func() {
|
||||
f.on["GET /applications/@me"] = func(restCall) reply { return reply{Status: 403, Body: `{"message":"no"}`} }
|
||||
out := call(rt, contract.ConnectorRequest{Op: "me"})
|
||||
Expect(out.Error).To(BeNil())
|
||||
Expect(out.BotUsername).To(Equal("localai-bot"))
|
||||
})
|
||||
|
||||
It("reports a refused token as unauthorized, without the token", func() {
|
||||
f.on["GET /users/@me"] = func(restCall) reply { return reply{Status: 401, Body: `{"message":"401: Unauthorized"}`} }
|
||||
out := call(rt, contract.ConnectorRequest{Op: "me"})
|
||||
Expect(out.Error).NotTo(BeNil())
|
||||
Expect(out.Error.Code).To(Equal(contract.ConnectorUnauthorized))
|
||||
Expect(out.Error.Message).NotTo(ContainSubstring("SECRET"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("send", func() {
|
||||
It("sends with allowed_mentions that parse nothing, and breaks up the mentions of the text", func() {
|
||||
out := call(rt, contract.ConnectorRequest{Op: "send", Chat: chanID, Text: "hi @everyone and @here, <@400000000000000001> and <@&600000000000000001>"})
|
||||
Expect(out.Error).To(BeNil())
|
||||
Expect(out.MessageID).NotTo(BeEmpty())
|
||||
c := f.called("POST /channels/:id/messages")
|
||||
Expect(c).To(HaveLen(1))
|
||||
Expect(c[0].Path).To(Equal("/channels/" + chanID + "/messages"))
|
||||
Expect(c[0].Header.Get("Authorization")).To(Equal("Bot " + secretToken))
|
||||
Expect(c[0].Body["allowed_mentions"]).To(Equal(map[string]any{"parse": []any{}}))
|
||||
text := c[0].Body["content"].(string)
|
||||
Expect(text).NotTo(ContainSubstring("@everyone"))
|
||||
Expect(text).NotTo(ContainSubstring("@here"))
|
||||
Expect(text).NotTo(ContainSubstring("<@4"))
|
||||
Expect(text).NotTo(ContainSubstring("<@&"))
|
||||
Expect(strings.ReplaceAll(text, "", "")).To(Equal("hi @everyone and @here, <@400000000000000001> and <@&600000000000000001>"), "the text reads the same")
|
||||
})
|
||||
|
||||
It("cuts a long answer into messages of at most 2000 characters, none of them without allowed_mentions", func() {
|
||||
long := strings.Repeat("word ", 1500)
|
||||
out := call(rt, contract.ConnectorRequest{Op: "send", Chat: chanID, Text: long})
|
||||
Expect(out.Error).To(BeNil())
|
||||
calls := f.called("POST /channels/:id/messages")
|
||||
Expect(len(calls)).To(BeNumerically(">=", 4))
|
||||
var all strings.Builder
|
||||
for _, c := range calls {
|
||||
text := c.Body["content"].(string)
|
||||
Expect([]rune(text)).To(HaveLen(len([]rune(text))))
|
||||
Expect(len([]rune(text))).To(BeNumerically("<=", 2000))
|
||||
Expect(c.Body["allowed_mentions"]).To(Equal(map[string]any{"parse": []any{}}))
|
||||
all.WriteString(text + " ")
|
||||
}
|
||||
Expect(strings.Fields(all.String())).To(HaveLen(1500))
|
||||
})
|
||||
|
||||
It("keeps a code block whole in each part", func() {
|
||||
code := "```go\n" + strings.Repeat("x := 1\n", 400) + "```"
|
||||
call(rt, contract.ConnectorRequest{Op: "send", Chat: chanID, Text: code})
|
||||
for _, c := range f.called("POST /channels/:id/messages") {
|
||||
text := c.Body["content"].(string)
|
||||
Expect(strings.Count(text, "```")%2).To(Equal(0), "each part opens and closes its block")
|
||||
Expect(len([]rune(text))).To(BeNumerically("<=", 2000))
|
||||
}
|
||||
})
|
||||
|
||||
It("writes in the thread when the update has one", func() {
|
||||
call(rt, contract.ConnectorRequest{Op: "send", Chat: chanID, Thread: threadI, Text: "in the thread"})
|
||||
Expect(f.called("POST /channels/:id/messages")[0].Path).To(Equal("/channels/" + threadI + "/messages"))
|
||||
})
|
||||
|
||||
It("opens the direct conversation for a chat of the form user:<id>, once", func() {
|
||||
call(rt, contract.ConnectorRequest{Op: "send", Chat: "user:" + userID, Text: "a notification"})
|
||||
call(rt, contract.ConnectorRequest{Op: "send", Chat: "user:" + userID, Text: "another"})
|
||||
Expect(f.called("POST /users/@me/channels")).To(HaveLen(1))
|
||||
Expect(f.called("POST /users/@me/channels")[0].Body).To(HaveKeyWithValue("recipient_id", userID))
|
||||
Expect(f.called("POST /channels/:id/messages")[1].Path).To(Equal("/channels/" + dmID + "/messages"))
|
||||
})
|
||||
|
||||
It("refuses a chat that is not an ID, so nothing else goes into the URL", func() {
|
||||
out := call(rt, contract.ConnectorRequest{Op: "send", Chat: "../users/@me", Text: "x"})
|
||||
Expect(out.Error).NotTo(BeNil())
|
||||
Expect(f.called("POST /channels/:id/messages")).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("replaces a working message with the first part, and posts a new one when that fails", func() {
|
||||
out := call(rt, contract.ConnectorRequest{Op: "send", Chat: chanID, MessageID: "800000000000000009", Text: "the answer"})
|
||||
Expect(out.Error).To(BeNil())
|
||||
Expect(f.called("PATCH /channels/:id/messages/:id")).To(HaveLen(1))
|
||||
Expect(f.called("POST /channels/:id/messages")).To(BeEmpty())
|
||||
f.on["PATCH /channels/:id/messages/:id"] = func(restCall) reply { return reply{Status: 404, Body: `{"code":10008,"message":"Unknown Message"}`} }
|
||||
out = call(rt, contract.ConnectorRequest{Op: "send", Chat: chanID, MessageID: "800000000000000009", Text: "again"})
|
||||
Expect(out.Error).To(BeNil())
|
||||
Expect(f.called("POST /channels/:id/messages")).To(HaveLen(1))
|
||||
})
|
||||
|
||||
It("waits the time Discord asks for after a 429, then sends once more", func() {
|
||||
var n atomic.Int32
|
||||
f.on["POST /channels/:id/messages"] = func(restCall) reply {
|
||||
if n.Add(1) == 1 {
|
||||
return reply{Status: 429, Body: `{"message":"You are being rate limited.","retry_after":0.2,"global":false}`, Header: map[string]string{"Retry-After": "1"}}
|
||||
}
|
||||
return reply{Body: `{"id":"900000000000000001"}`}
|
||||
}
|
||||
start := time.Now()
|
||||
out := call(rt, contract.ConnectorRequest{Op: "send", Chat: chanID, Text: "hello"})
|
||||
Expect(out.Error).To(BeNil())
|
||||
Expect(out.MessageID).To(Equal("900000000000000001"))
|
||||
Expect(n.Load()).To(BeEquivalentTo(2))
|
||||
Expect(time.Since(start)).To(BeNumerically(">=", 900*time.Millisecond))
|
||||
})
|
||||
|
||||
It("reads the wait from the body when the header is missing, rounded up", func() {
|
||||
f.on["POST /channels/:id/messages"] = func(restCall) reply {
|
||||
return reply{Status: 429, Body: `{"message":"You are being rate limited.","retry_after":0.4,"global":true}`}
|
||||
}
|
||||
out := call(rt, contract.ConnectorRequest{Op: "send", Chat: chanID, Text: "x"})
|
||||
Expect(out.Error).NotTo(BeNil())
|
||||
Expect(out.Error.Code).To(Equal(contract.ConnectorRateLimited))
|
||||
Expect(out.Error.RetryAfter).To(Equal(1))
|
||||
})
|
||||
|
||||
It("reports a second 429 to the host with the wait", func() {
|
||||
f.on["POST /channels/:id/messages"] = func(restCall) reply {
|
||||
return reply{Status: 429, Body: `{"retry_after":0.1}`, Header: map[string]string{"Retry-After": "1"}}
|
||||
}
|
||||
out := call(rt, contract.ConnectorRequest{Op: "send", Chat: chanID, Text: "x"})
|
||||
Expect(out.Error).NotTo(BeNil())
|
||||
Expect(out.Error.Code).To(Equal(contract.ConnectorRateLimited))
|
||||
Expect(out.Error.RetryAfter).To(Equal(1))
|
||||
})
|
||||
|
||||
It("keeps the token out of the error of a failed send", func() {
|
||||
f.on["POST /channels/:id/messages"] = func(restCall) reply {
|
||||
return reply{Status: 403, Body: `{"code":50013,"message":"Missing Permissions ` + secretToken + `"}`}
|
||||
}
|
||||
out := call(rt, contract.ConnectorRequest{Op: "send", Chat: chanID, Text: "x"})
|
||||
Expect(out.Error).NotTo(BeNil())
|
||||
Expect(out.Error.Code).To(Equal(contract.ConnectorFailed))
|
||||
Expect(out.Error.Message).To(ContainSubstring("50013"))
|
||||
Expect(out.Error.Message).NotTo(ContainSubstring("SECRET"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("edit", func() {
|
||||
It("replaces the text, with allowed_mentions, and cuts it to one message", func() {
|
||||
out := call(rt, contract.ConnectorRequest{Op: "edit", Chat: chanID, MessageID: "800000000000000009", Text: strings.Repeat("a ", 2500) + "@everyone"})
|
||||
Expect(out.Error).To(BeNil())
|
||||
c := f.called("PATCH /channels/:id/messages/:id")
|
||||
Expect(c).To(HaveLen(1))
|
||||
Expect(c[0].Path).To(Equal("/channels/" + chanID + "/messages/800000000000000009"))
|
||||
Expect(len([]rune(c[0].Body["content"].(string)))).To(BeNumerically("<=", 2000))
|
||||
Expect(c[0].Body["allowed_mentions"]).To(Equal(map[string]any{"parse": []any{}}))
|
||||
})
|
||||
|
||||
It("is not repeated after a 429: a preview is skipped, not waited for", func() {
|
||||
f.on["PATCH /channels/:id/messages/:id"] = func(restCall) reply {
|
||||
return reply{Status: 429, Body: `{"retry_after":1}`, Header: map[string]string{"Retry-After": "1"}}
|
||||
}
|
||||
out := call(rt, contract.ConnectorRequest{Op: "edit", Chat: chanID, MessageID: "800000000000000009", Text: "x"})
|
||||
Expect(out.Error).NotTo(BeNil())
|
||||
Expect(out.Error.Code).To(Equal(contract.ConnectorRateLimited))
|
||||
Expect(f.called("PATCH /channels/:id/messages/:id")).To(HaveLen(1))
|
||||
})
|
||||
})
|
||||
|
||||
It("has no typing indicator: the op does nothing", func() {
|
||||
out := call(rt, contract.ConnectorRequest{Op: "typing", Chat: chanID})
|
||||
Expect(out.Error).To(BeNil())
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for _, c := range f.calls {
|
||||
Expect(c.Path).NotTo(ContainSubstring("typing"))
|
||||
}
|
||||
})
|
||||
|
||||
Describe("poll", func() {
|
||||
It("hands over the updates with the next offset", func() {
|
||||
call(rt, contract.ConnectorRequest{Op: "poll", Offset: 1})
|
||||
Eventually(func() int { return len(f.handshakes()) }, 10*time.Second).Should(BeNumerically(">=", 1))
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
f.dispatch("MESSAGE_CREATE", msg{channel: dmID, sender: userID, text: "hi"}.payload())
|
||||
var out contract.ConnectorResponse
|
||||
Eventually(func() int {
|
||||
out = call(rt, contract.ConnectorRequest{Op: "poll", Offset: 1, TimeoutSeconds: 1})
|
||||
return len(out.Updates)
|
||||
}, 10*time.Second).Should(Equal(1))
|
||||
Expect(out.NextOffset).To(Equal(out.Updates[0].Cursor + 1))
|
||||
})
|
||||
|
||||
It("follows no thread with the option followThreads set to never", func() {
|
||||
opts := map[string]string{"followThreads": "never"}
|
||||
call(rt, contract.ConnectorRequest{Op: "poll", Offset: 1, Options: opts})
|
||||
Eventually(func() int { return len(f.handshakes()) }, 10*time.Second).Should(BeNumerically(">=", 1))
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
f.dispatch("THREAD_CREATE", map[string]any{"id": threadI, "type": 11, "parent_id": chanID, "guild_id": guildID})
|
||||
f.dispatch("MESSAGE_CREATE", msg{channel: threadI, guild: guildID, sender: userID, text: "mention", mentionBot: true}.payload())
|
||||
f.dispatch("MESSAGE_CREATE", msg{channel: threadI, guild: guildID, sender: userID, text: "no mention"}.payload())
|
||||
f.dispatch("MESSAGE_CREATE", msg{channel: dmID, sender: userID, text: "marker"}.payload())
|
||||
var out contract.ConnectorResponse
|
||||
Eventually(func() int {
|
||||
out = call(rt, contract.ConnectorRequest{Op: "poll", Offset: 1, TimeoutSeconds: 1, Options: opts})
|
||||
return len(out.Updates)
|
||||
}, 10*time.Second).Should(Equal(2))
|
||||
Expect(out.Updates[0].Text).To(Equal("mention"))
|
||||
Expect(out.Updates[1].Text).To(Equal("marker"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("history", func() {
|
||||
hist := func(id, sender, text string, extra map[string]any) map[string]any {
|
||||
m := map[string]any{"id": id, "channel_id": threadI, "content": text, "type": 0, "timestamp": "2026-11-04T10:00:00.000000+00:00",
|
||||
"author": author(sender, "person", sender == "100000000000000009"), "mentions": []any{}, "attachments": []any{}}
|
||||
for k, v := range extra {
|
||||
m[k] = v
|
||||
}
|
||||
return m
|
||||
}
|
||||
BeforeEach(func() {
|
||||
// Newest first, as Discord returns them.
|
||||
f.history[threadI] = []map[string]any{
|
||||
hist("800000000000000007", userID, "the question now", nil),
|
||||
hist("800000000000000006", botID, "my earlier answer", nil),
|
||||
hist("800000000000000005", userID, "look at this", map[string]any{"attachments": []any{map[string]any{"id": "1", "filename": "plan.pdf", "size": 5}}}),
|
||||
hist("800000000000000004", "100000000000000009", "a webhook-ish bot", nil),
|
||||
hist("800000000000000003", userID, "joined", map[string]any{"type": 7}),
|
||||
hist("800000000000000002", userID, fmt.Sprintf("<@%s> first", botID), map[string]any{"mentions": []any{author(botID, "localai-bot", true)}}),
|
||||
hist("800000000000000001", userID, "older", nil),
|
||||
}
|
||||
})
|
||||
|
||||
It("returns the messages before the one being handled, oldest first, with the bot's own marked", func() {
|
||||
out := call(rt, contract.ConnectorRequest{Op: "history", Chat: chanID, Thread: threadI, Before: "800000000000000007", Limit: 30})
|
||||
Expect(out.Error).To(BeNil())
|
||||
var texts []string
|
||||
for _, m := range out.Messages {
|
||||
texts = append(texts, m.Text)
|
||||
}
|
||||
Expect(texts).To(Equal([]string{"older", "first", "look at this", "my earlier answer"}), "no bot, no system message, the bot's mention removed")
|
||||
Expect(out.Truncated).To(BeFalse())
|
||||
own := out.Messages[3]
|
||||
Expect(own.Own).To(BeTrue())
|
||||
Expect(out.Messages[2].Media).To(Equal(1))
|
||||
Expect(out.Messages[2].MediaNames).To(Equal([]string{"plan.pdf"}))
|
||||
Expect(out.Messages[0].SenderName).To(Equal("Person"))
|
||||
Expect(out.Messages[0].TS.IsZero()).To(BeFalse())
|
||||
c := f.called("GET /channels/:id/messages")
|
||||
Expect(c).To(HaveLen(1))
|
||||
Expect(c[0].Path).To(Equal("/channels/" + threadI + "/messages"))
|
||||
Expect(c[0].Query.Get("before")).To(Equal("800000000000000007"))
|
||||
Expect(c[0].Query.Get("limit")).To(Equal("31"), "one more than asked, to know that older ones exist")
|
||||
})
|
||||
|
||||
It("says that older messages exist when the limit cuts the thread", func() {
|
||||
out := call(rt, contract.ConnectorRequest{Op: "history", Chat: chanID, Thread: threadI, Before: "800000000000000007", Limit: 2})
|
||||
Expect(out.Error).To(BeNil())
|
||||
Expect(out.Messages).To(HaveLen(2))
|
||||
Expect(out.Messages[1].Text).To(Equal("my earlier answer"))
|
||||
Expect(out.Truncated).To(BeTrue())
|
||||
})
|
||||
|
||||
It("refuses a request without a thread", func() {
|
||||
_, err := rt.Connector(context.Background(), contract.ConnectorRequest{Op: "history", Kind: "discord", Token: secretToken, Chat: chanID, Limit: 5})
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("is not repeated after a 429", func() {
|
||||
f.on["GET /channels/:id/messages"] = func(restCall) reply {
|
||||
return reply{Status: 429, Body: `{"retry_after":1}`, Header: map[string]string{"Retry-After": "1"}}
|
||||
}
|
||||
out := call(rt, contract.ConnectorRequest{Op: "history", Chat: chanID, Thread: threadI, Limit: 5})
|
||||
Expect(out.Error).NotTo(BeNil())
|
||||
Expect(out.Error.Code).To(Equal(contract.ConnectorRateLimited))
|
||||
Expect(f.called("GET /channels/:id/messages")).To(HaveLen(1))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
package transport_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestTransport(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Discord transport")
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
package transport
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// zwsp is a zero-width space. Put inside a mention it keeps the text as it is
|
||||
// for the eye and stops Discord from reading it as a mention.
|
||||
const zwsp = ""
|
||||
|
||||
var (
|
||||
// <@123>, <@!123> and <@&123>: a person or a role.
|
||||
mentionTag = regexp.MustCompile(`<@([!&]?)(\d+)>`)
|
||||
// @everyone and @here, also when a letter or a zero-width mark follows.
|
||||
broadcast = regexp.MustCompile(`@(everyone|here)\b`)
|
||||
)
|
||||
|
||||
// Escape makes the text of an answer safe to send: it cannot mention anyone.
|
||||
// The model writes this text from untrusted input, so a mention in it is
|
||||
// broken up. Every message also carries allowed_mentions with an empty parse
|
||||
// list, which is the guard that holds when this one misses a case.
|
||||
func Escape(s string) string {
|
||||
s = broadcast.ReplaceAllString(s, "@"+zwsp+"$1")
|
||||
return mentionTag.ReplaceAllString(s, "<"+zwsp+"@$1$2>")
|
||||
}
|
||||
|
||||
// Format is the markup step of an answer. Discord reads Markdown, so the text
|
||||
// stays as it is, except for the mentions.
|
||||
func Format(markdown string) string { return Escape(markdown) }
|
||||
|
||||
// Plain turns the content of a message into the text the agent reads. The bot's
|
||||
// own mention is removed, and a mention of another person becomes @name, from
|
||||
// the mentions of the message. A role or @everyone stays as written.
|
||||
func Plain(content string, mentions []User, bot string) string {
|
||||
names := map[string]string{}
|
||||
for _, u := range mentions {
|
||||
names[u.ID] = u.Display()
|
||||
}
|
||||
out := mentionTag.ReplaceAllStringFunc(content, func(tag string) string {
|
||||
m := mentionTag.FindStringSubmatch(tag)
|
||||
if m[1] == "&" {
|
||||
return tag
|
||||
}
|
||||
if m[2] == bot {
|
||||
return ""
|
||||
}
|
||||
if n := names[m[2]]; n != "" {
|
||||
return "@" + n
|
||||
}
|
||||
return tag
|
||||
})
|
||||
return strings.TrimSpace(out)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
package transport_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/mudler/LocalAI/addons/discord/transport"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("the text of messages", func() {
|
||||
DescribeTable("breaks up every form of mention in an answer",
|
||||
func(in string) {
|
||||
out := transport.Escape(in)
|
||||
Expect(out).NotTo(Equal(in))
|
||||
Expect(strings.ReplaceAll(out, "", "")).To(Equal(in))
|
||||
Expect(out).NotTo(MatchRegexp(`<@[!&]?\d+>|@everyone|@here`))
|
||||
},
|
||||
Entry("everyone", "@everyone"),
|
||||
Entry("here", "see @here now"),
|
||||
Entry("a user", "<@123456789012345678>"),
|
||||
Entry("a user with a nickname", "<@!123456789012345678>"),
|
||||
Entry("a role", "<@&123456789012345678>"),
|
||||
)
|
||||
|
||||
It("leaves the rest of Markdown alone", func() {
|
||||
in := "**bold** `code` <#123456789012345678> [link](https://example.com) user@example.org"
|
||||
Expect(transport.Escape(in)).To(Equal(in))
|
||||
})
|
||||
|
||||
It("removes the mention of the bot and names the people mentioned", func() {
|
||||
users := []transport.User{{ID: "1", Username: "bot"}, {ID: "2", Username: "ada", GlobalName: "Ada L"}}
|
||||
Expect(transport.Plain("<@1> ask <@!2> and <@&9>\nsecond line", users, "1")).To(Equal("ask @Ada L and <@&9>\nsecond line"))
|
||||
Expect(transport.Plain(" <@!1> hi", users, "1")).To(Equal("hi"))
|
||||
Expect(transport.Plain("<@77> unknown", users, "1")).To(Equal("<@77> unknown"))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,2 @@
|
||||
# Written by build.mjs; the package build makes it.
|
||||
/dist/
|
||||
@@ -0,0 +1,15 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { buildAddonUI } from '../../internal/uibuild/uibuild.mjs'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
export async function build() {
|
||||
// The page keeps the host module names it was written with; the compat
|
||||
// directory answers them through the SDK (addons/internal/host-compat).
|
||||
return buildAddonUI({ root: here, name: 'discord', rgb: [88, 101, 242], compat: resolve(here, '../../internal/host-compat/src') })
|
||||
}
|
||||
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||
build().then((dir) => console.log(`built ${dir}`), (e) => { console.error(e); process.exit(1) })
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "localai-discord-ui",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"description": "Native UI of the Discord add-on: a standalone ES module that the host loads after its own build.",
|
||||
"type": "module",
|
||||
"scripts": { "build": "node build.mjs", "test": "node test/run.mjs" }
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */
|
||||
import { useState } from 'react'
|
||||
import { RouteProvider } from 'react-router-dom'
|
||||
import { registerLocales } from '../../../internal/host-compat/src/i18n.js'
|
||||
import ConnectorPage from '../../../internal/connector-ui/src/ConnectorPage'
|
||||
import { Field, FieldError, Notice, TextInput } from '../../../internal/connector-ui/src/controls'
|
||||
import en from './i18n/discord.en.json'
|
||||
import { DISCORD_LISTS, PERMISSIONS, PERMISSION_INTEGER, badEntries, discordSecret, inviteUrl, tokenProblem } from './ids'
|
||||
|
||||
registerLocales({ discord: { en } })
|
||||
|
||||
// The bot token. It is typed only to replace it.
|
||||
function DiscordToken({ form, setForm, t }) {
|
||||
const problem = tokenProblem(form)
|
||||
return (
|
||||
<>
|
||||
<Field label={t('token')} hint={t('tokenHelp')}>
|
||||
{(p) => (
|
||||
<TextInput {...p} mono type="password" autoComplete="off" value={form.token || ''} required={!form.id}
|
||||
placeholder={form.id ? t('tokenSaved') : ''} aria-invalid={problem ? 'true' : undefined}
|
||||
onChange={(e) => setForm({ ...form, token: e.target.value })} />
|
||||
)}
|
||||
</Field>
|
||||
{problem && <FieldError role="status" data-testid="token-problem">{t(problem)}</FieldError>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// What this version fixes, and what is wrong with the typed IDs.
|
||||
function DiscordRules({ form, t }) {
|
||||
const bad = badEntries(form)
|
||||
return (
|
||||
<>
|
||||
<Notice tone="info" role="note" testId="discord-rules">
|
||||
<p className="cn-notice__text">{t('rules')}</p>
|
||||
</Notice>
|
||||
{bad.length > 0 && (
|
||||
<Notice tone="warn" role="alert" testId="discord-bad-ids">
|
||||
<p className="cn-notice__text">{t('badIds', { list: bad.join(', ') })}</p>
|
||||
</Notice>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// How to make the bot in the developer portal, with the link that adds it to a server.
|
||||
function DiscordHelp({ t }) {
|
||||
const [appId, setAppId] = useState('')
|
||||
const [copied, setCopied] = useState(false)
|
||||
const link = inviteUrl(appId)
|
||||
return (
|
||||
<details className="cn-help" data-testid="discord-setup">
|
||||
<summary>{t('setup.title')}</summary>
|
||||
<ol>
|
||||
<li>{t('setup.step1')}</li>
|
||||
<li>{t('setup.step2')}</li>
|
||||
<li>{t('setup.step3')}</li>
|
||||
<li>{t('setup.step4')}</li>
|
||||
<li>{t('setup.step5')}</li>
|
||||
<li>{t('setup.step6')}</li>
|
||||
</ol>
|
||||
<Field label={t('setup.appId')} hint={t('setup.appIdHelp')}>
|
||||
{(p) => <TextInput {...p} mono inputMode="numeric" value={appId} data-testid="discord-app-id" onChange={(e) => { setAppId(e.target.value); setCopied(false) }} />}
|
||||
</Field>
|
||||
{appId.trim() && !link && <FieldError role="status">{t('setup.appIdBad')}</FieldError>}
|
||||
{link && (
|
||||
<>
|
||||
<pre className="cn-code" data-testid="discord-invite" aria-label={t('setup.invite')}>{link}</pre>
|
||||
<button type="button" className="dk-btn dk-btn--secondary dk-btn--sm"
|
||||
onClick={async () => { try { await navigator.clipboard.writeText(link); setCopied(true) } catch (_e) { setCopied(false) } }}>
|
||||
{copied ? t('setup.copied') : t('setup.copy')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<p className="cn-muted">{t('setup.permissions', { number: PERMISSION_INTEGER })}</p>
|
||||
<ul>
|
||||
{PERMISSIONS.map((p) => <li key={p.key}>{t(`setup.perm.${p.key}`)}</li>)}
|
||||
</ul>
|
||||
<p className="cn-muted">{t('setup.never')}</p>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
|
||||
export function createDiscord() {
|
||||
function Component({ page, path, search }) {
|
||||
if (page !== 'bots') return null
|
||||
return (
|
||||
<RouteProvider value={{ page, path: path || '', search: search || '' }}>
|
||||
<ConnectorPage kind="discord" ns="discord" testId="discord-page" TokenFields={DiscordToken} tokenOf={discordSecret}
|
||||
lists={DISCORD_LISTS} Help={DiscordHelp} notifyMode="text"
|
||||
ExtraFields={DiscordRules} extraValid={(form) => badEntries(form).length === 0 && !tokenProblem(form)} />
|
||||
</RouteProvider>
|
||||
)
|
||||
}
|
||||
return { Component, components: {}, chatActions: [], dispose() {} }
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
{
|
||||
"title": "Discord",
|
||||
"intro": "Bind a Discord bot to one of your saved agents. People talk to the agent in a direct message with the bot, or by mentioning it in a channel or thread. Each person, and each thread, gets a chat of its own.",
|
||||
"new": "New Discord bot",
|
||||
"edit": "Edit Discord bot",
|
||||
"name": "Name",
|
||||
"token": "Bot token",
|
||||
"tokenHelp": "From the Bot page of your application in the Discord developer portal (Reset Token). It is kept sealed and never shown again.",
|
||||
"tokenSaved": "Saved. Type a new token to replace it.",
|
||||
"tokenSpace": "The token has no spaces. Paste only the token, without the word Bot.",
|
||||
"tokenShape": "This is too short for a bot token.",
|
||||
"agent": "Agent",
|
||||
"agentNone": "Choose an agent",
|
||||
"agentNoModel": "{{name}} (needs a model)",
|
||||
"who": "Who may use it",
|
||||
"onlyListed": "Only the people and channels I list",
|
||||
"anyone": "Anyone who can message the bot, or mention it in a channel it can read",
|
||||
"anyoneWarning": "This is for public channels and for agents that only read. Anyone who can reach the bot can talk to this agent: by direct message if they share a server with the bot, and in every channel where the bot can read and they mention it. People who are not on a list hold no permission of their own: the agent cannot save memory, propose triggers or take their files for them, and the sensitive actions stay closed. The agent's other tools still work for everyone. What people write in a public channel is untrusted text, and it can try to steer the agent (prompt injection). Give the agent read-only tools for this connection, and none that sends messages, spends money or changes files. Each person gets a chat of their own, and the number of messages a person can send in a minute is limited.",
|
||||
"anyoneConfirm": "I understand that anyone can use this agent, and that its tools should be read-only",
|
||||
"allowIds": "Allowed people and channels",
|
||||
"allowIdsHint": "A user ID or a channel ID (numbers)",
|
||||
"rules": "Listed people can write in a direct message and mention the bot in any channel. A listed channel lets everyone in it mention the bot there, but they get no permission of their own. In a server the bot answers only when it is mentioned or replied to, or inside a thread where it was mentioned. It never answers bots or webhooks, and its messages never ping anyone. Use channel IDs: a server (guild) ID matches nothing. IDs only, no names: in Discord turn on Developer Mode (Settings, Advanced), then right-click a person or channel and choose Copy ID.",
|
||||
"badIds": "These are not Discord IDs: {{list}}. Use the number from Copy ID (17 to 20 digits). Names are not accepted.",
|
||||
"notifyChat": "Send my notifications to this channel or person",
|
||||
"notifyHelp": "Optional. A channel ID, or user: followed by your user ID to get them as direct messages.",
|
||||
"test": "Test",
|
||||
"testOk": "Connected to {{bot}}.",
|
||||
"checkFailed": "Saved. The token check failed: {{reason}}",
|
||||
"senders": "Senders",
|
||||
"noSenders": "Nobody has written to this bot yet.",
|
||||
"allowButton": "Allow",
|
||||
"ignored": "Ignored",
|
||||
"accepted": "Accepted",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"editButton": "Edit",
|
||||
"delete": "Delete",
|
||||
"empty": "No Discord bots yet.",
|
||||
"agentOf": "Agent: {{name}}",
|
||||
"accepts": "Accepts: {{who}}",
|
||||
"modes": {
|
||||
"anyone": "anyone",
|
||||
"nobody": "nobody yet",
|
||||
"listed_one": "1 listed entry",
|
||||
"listed_other": "{{count}} listed entries"
|
||||
},
|
||||
"on": "On",
|
||||
"onFor": "{{name}} on",
|
||||
"inactive": "Not running: {{reason}}.",
|
||||
"confirmDelete": {
|
||||
"title": "Delete {{name}}?",
|
||||
"message": "The bot stops answering, and the token saved with it is deleted. To set it up again you must enter the token again.",
|
||||
"confirm": "Delete"
|
||||
},
|
||||
"setup": {
|
||||
"title": "How to make the Discord bot",
|
||||
"step1": "At discord.com/developers/applications choose New Application and give it a name.",
|
||||
"step2": "On the Bot page choose Reset Token, copy the token once, and paste it into the field Bot token here. Keep it secret.",
|
||||
"step3": "On the same page, under Privileged Gateway Intents, turn on Message Content Intent. Without it the bot cannot read what people write in a server. Leave the other two intents off.",
|
||||
"step4": "Optional: turn Public Bot off, so only you can add the bot to a server.",
|
||||
"step5": "Type the Application ID (General Information) below, and open the link it builds. Choose the server, and keep the permissions as they are.",
|
||||
"step6": "In the server, check that the role of the bot can see the channels it should answer in. Then list the channel IDs here, and mention the bot to try it.",
|
||||
"appId": "Application ID",
|
||||
"appIdHelp": "The number from General Information of your application. It is not secret. It only builds the link; nothing is saved.",
|
||||
"appIdBad": "The Application ID is a number of 17 to 20 digits.",
|
||||
"invite": "Link that adds the bot to a server",
|
||||
"copy": "Copy the link",
|
||||
"copied": "Copied",
|
||||
"permissions": "The link asks for these permissions (the number {{number}}):",
|
||||
"perm": {
|
||||
"viewChannels": "View Channels: to see where it is mentioned",
|
||||
"sendMessages": "Send Messages: to answer",
|
||||
"sendInThreads": "Send Messages in Threads: to answer in threads",
|
||||
"attachFiles": "Attach Files: to send files that the agent makes",
|
||||
"readHistory": "Read Message History: to read a thread and the files of a message"
|
||||
},
|
||||
"never": "Never give the bot Administrator, Manage Messages or Mention Everyone. It needs none of them."
|
||||
},
|
||||
"back": "Back to connectors",
|
||||
"answer": {
|
||||
"reply": {
|
||||
"off": "The answer only (Discord has no typing indicator here)"
|
||||
},
|
||||
"replyHelp": "Progress and Live show one working message, and the answer replaces it. Edits are limited by Discord, so Live updates about once a second. A long answer goes on in new messages of at most 2000 characters.",
|
||||
"conversationHelp": "Send !new in Discord to start a new chat. A message that starts with a slash may be taken by Discord for one of its own commands."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Helpers of the Discord page. No React, so a node test drives them.
|
||||
|
||||
// A Discord ID (a snowflake): 17 to 20 digits. Anything else is not an ID the
|
||||
// bot can match. Names are refused on purpose: they are not unique and they
|
||||
// change hands, so an allowlist by name would not be a safe key.
|
||||
export const isSnowflake = (x) => /^\d{17,20}$/.test(x)
|
||||
|
||||
// The entries of the allowlist that are not IDs, in the order they were added.
|
||||
export function badEntries(form) {
|
||||
if (form?.accept_any) return []
|
||||
return (form?.allow || []).filter((x) => !isSnowflake(String(x).trim()))
|
||||
}
|
||||
|
||||
// The one allowlist of the page. It holds people and channels, as bare IDs:
|
||||
// the host matches the sender of a message against the person IDs, and the
|
||||
// channel of the message against the channel IDs. Both are numbers, so one
|
||||
// list shows them together.
|
||||
export const DISCORD_LISTS = [
|
||||
{ key: 'ids', label: 'allowIds', hint: 'allowIdsHint', testId: 'connector-allow' },
|
||||
]
|
||||
|
||||
// What is wrong with the typed bot token, as a key of the page's words, or ''.
|
||||
// Nothing typed is no problem: an update without a token keeps the stored one.
|
||||
// A token has no space in it, and is long. The shape is not checked further:
|
||||
// Discord may change it, and the server asks Discord whether it is good.
|
||||
export function tokenProblem(form) {
|
||||
const t = (form?.token || '').trim()
|
||||
if (!t) return ''
|
||||
if (/\s/.test(t)) return 'tokenSpace'
|
||||
if (t.length < 30) return 'tokenShape'
|
||||
return ''
|
||||
}
|
||||
|
||||
// The secret the form makes: the trimmed token, '' when not typed or wrong.
|
||||
export const discordSecret = (form) => (tokenProblem(form) ? '' : (form?.token || '').trim())
|
||||
|
||||
// The permissions the bot needs, by Discord's bit numbers. The integer is
|
||||
// their sum. Administrator, Mention Everyone and Manage Messages are not in
|
||||
// it, on purpose.
|
||||
export const PERMISSIONS = [
|
||||
{ key: 'viewChannels', bit: 10 },
|
||||
{ key: 'sendMessages', bit: 11 },
|
||||
{ key: 'attachFiles', bit: 15 },
|
||||
{ key: 'readHistory', bit: 16 },
|
||||
{ key: 'sendInThreads', bit: 38 },
|
||||
]
|
||||
|
||||
export const PERMISSION_INTEGER = PERMISSIONS.reduce((sum, p) => sum + 2n ** BigInt(p.bit), 0n).toString()
|
||||
|
||||
// The link that adds the bot to a server. The application ID is the number
|
||||
// from General Information of the application in the developer portal.
|
||||
export function inviteUrl(applicationId) {
|
||||
const id = (applicationId || '').trim()
|
||||
if (!isSnowflake(id)) return ''
|
||||
return `https://discord.com/oauth2/authorize?client_id=${id}&scope=bot&permissions=${PERMISSION_INTEGER}`
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// The entry of the Discord add-on. Its page edits the bindings of Discord bots
|
||||
// through the core API, with the signed-in person's own session. It uses the
|
||||
// host's React, router and i18n through the stand-ins of
|
||||
// addons/internal/host-compat, like the Telegram and Slack add-ons.
|
||||
import { requireHost, setSdk } from '../../../internal/host-compat/src/runtime.js'
|
||||
|
||||
const NEEDS = ['ui.ConfirmDialog']
|
||||
|
||||
export async function createAddon(sdk) {
|
||||
requireHost(sdk, NEEDS)
|
||||
setSdk(sdk)
|
||||
const { createDiscord } = await import('./addon.jsx')
|
||||
return createDiscord(sdk)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { DISCORD_LISTS, PERMISSIONS, PERMISSION_INTEGER, badEntries, discordSecret, inviteUrl, isSnowflake, tokenProblem } from '../src/ids.js'
|
||||
import { canSave, connectorPayload, emptyConnector, listEntries, replaceList } from '../../../internal/connector-ui/src/connector.js'
|
||||
|
||||
const TOKEN = 'MTAwMDAwMDAwMDAwMDAwMDAw.GabcDE.' + 'x'.repeat(27)
|
||||
|
||||
test('discord: the token goes in the request only when it was typed, and a bad one never does', () => {
|
||||
assert.equal(discordSecret({ token: ` ${TOKEN} ` }), TOKEN)
|
||||
assert.equal(discordSecret({}), '')
|
||||
assert.equal(tokenProblem({}), '')
|
||||
assert.equal(tokenProblem({ token: 'Bot ' + TOKEN }), 'tokenSpace', 'a pasted prefix has a space in it')
|
||||
assert.equal(tokenProblem({ token: 'short' }), 'tokenShape')
|
||||
assert.equal(discordSecret({ token: 'short' }), '')
|
||||
const form = { ...emptyConnector(), name: 'Bot', agent_id: 'a', token: TOKEN }
|
||||
const body = connectorPayload(form, 'discord', discordSecret(form))
|
||||
assert.equal(body.kind, 'discord')
|
||||
assert.equal(body.token, TOKEN)
|
||||
const update = connectorPayload({ ...emptyConnector(), id: 'b1', name: 'Bot', agent_id: 'a' }, 'discord', '')
|
||||
assert.equal('token' in update, false)
|
||||
assert.equal(canSave(form, false, Boolean(discordSecret(form))), true)
|
||||
assert.equal(canSave({ ...form, token: '' }, false, false), false)
|
||||
})
|
||||
|
||||
test('discord: an ID is 17 to 20 digits, and names, servers typed as words and URLs are not', () => {
|
||||
assert.equal(isSnowflake('123456789012345678'), true)
|
||||
assert.equal(isSnowflake('12345678901234567'), true)
|
||||
assert.equal(isSnowflake('1234'), false)
|
||||
assert.equal(isSnowflake('@ada'), false)
|
||||
assert.equal(isSnowflake('ada#1234'), false)
|
||||
assert.equal(isSnowflake('123456789012345678 '), false)
|
||||
assert.equal(isSnowflake('https://discord.com/channels/1/2'), false)
|
||||
})
|
||||
|
||||
test('discord: entries that are not IDs are named and block saving; the anyone mode checks none', () => {
|
||||
const ok = '123456789012345678'
|
||||
assert.deepEqual(badEntries({ allow: [ok, '@ada', 'general'] }), ['@ada', 'general'])
|
||||
assert.deepEqual(badEntries({ allow: ['@ada'], accept_any: true }), [])
|
||||
assert.deepEqual(badEntries({ allow: [ok] }), [])
|
||||
assert.deepEqual(badEntries({}), [])
|
||||
})
|
||||
|
||||
test('discord: people and channels share one list of bare IDs', () => {
|
||||
assert.equal(DISCORD_LISTS.length, 1)
|
||||
const allow = ['123456789012345678', '223456789012345678']
|
||||
assert.deepEqual(listEntries(allow, DISCORD_LISTS, 0), allow)
|
||||
assert.deepEqual(replaceList(allow, DISCORD_LISTS, 0, ['323456789012345678']), ['323456789012345678'])
|
||||
const body = connectorPayload({ ...emptyConnector(), name: 'B', agent_id: 'a', token: TOKEN, allow: [...allow, allow[0]] }, 'discord', TOKEN)
|
||||
assert.deepEqual(body.allow, allow, 'kept once')
|
||||
})
|
||||
|
||||
test('discord: the invite link asks for the five permissions and for no more', () => {
|
||||
assert.equal(PERMISSION_INTEGER, '274878008320')
|
||||
assert.deepEqual(PERMISSIONS.map((p) => p.bit), [10, 11, 15, 16, 38])
|
||||
const id = '123456789012345678'
|
||||
assert.equal(inviteUrl(id), `https://discord.com/oauth2/authorize?client_id=${id}&scope=bot&permissions=274878008320`)
|
||||
assert.equal(inviteUrl(` ${id} `), inviteUrl(id))
|
||||
assert.equal(inviteUrl(''), '')
|
||||
assert.equal(inviteUrl('abc'), '')
|
||||
assert.equal(inviteUrl('1&permissions=8'), '', 'nothing else goes into the link')
|
||||
const administrator = 8n, manageMessages = 1n << 13n, mentionEveryone = 1n << 17n
|
||||
const bits = BigInt(PERMISSION_INTEGER)
|
||||
for (const bad of [administrator, manageMessages, mentionEveryone]) assert.equal(bits & bad, 0n)
|
||||
})
|
||||
|
||||
test('discord: the words say what the anyone mode does and does not give', () => {
|
||||
const words = JSON.parse(readFileSync(new URL('../src/i18n/discord.en.json', import.meta.url), 'utf8'))
|
||||
for (const want of ['public channels', 'read-only', 'untrusted', 'prompt injection', 'hold no permission']) {
|
||||
assert.ok(words.anyoneWarning.includes(want), want)
|
||||
}
|
||||
assert.ok(words.answer.conversationHelp.includes('!new'))
|
||||
assert.ok(words.setup.step3.includes('Message Content Intent'))
|
||||
assert.ok(words.setup.never.includes('Administrator'))
|
||||
for (const key of ['title', 'intro', 'token', 'agent', 'who', 'onlyListed', 'anyone', 'save', 'test', 'testOk']) assert.ok(words[key], key)
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
// Runs the node unit tests and prints one JSON line per test:
|
||||
// {"case":"unit: <name>","status":"passed"|"failed"|"skipped"}
|
||||
// A test that is skipped or marked todo is reported as skipped, so the case
|
||||
// inventory of scripts/addons/suites.json rejects it. No dependencies.
|
||||
//
|
||||
// Arguments name the test files to run (for example addons.test.mjs); without
|
||||
// any, every *.test.mjs file runs. Each suite of suites.json names its own, so
|
||||
// one suite's cases never appear in another's inventory.
|
||||
import { run } from 'node:test'
|
||||
import { readdirSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const only = process.argv.slice(2)
|
||||
const names = readdirSync(here).filter(f => f.endsWith('.test.mjs')).sort()
|
||||
for (const name of only) {
|
||||
if (!names.includes(name)) {
|
||||
console.log(JSON.stringify({ case: `unit: missing test file ${name}`, status: 'failed' }))
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
const files = names.filter(f => only.length === 0 || only.includes(f)).map(f => join(here, f))
|
||||
let failed = false
|
||||
const stream = run({ files, concurrency: 1 })
|
||||
stream.on('test:pass', ({ name, skip, todo, nesting }) => {
|
||||
if (nesting > 0) return
|
||||
const status = skip || todo ? 'skipped' : 'passed'
|
||||
console.log(JSON.stringify({ case: `unit: ${name}`, status }))
|
||||
})
|
||||
stream.on('test:fail', ({ name, nesting, details }) => {
|
||||
if (nesting > 0) return
|
||||
failed = true
|
||||
console.log(JSON.stringify({ case: `unit: ${name}`, status: 'failed', error: String(details?.error?.message || '').slice(0, 300) }))
|
||||
})
|
||||
stream.on('test:stderr', ({ message }) => process.stderr.write(message))
|
||||
stream.on('end', () => { process.exitCode = failed ? 1 : 0 })
|
||||
stream.resume()
|
||||
@@ -0,0 +1,140 @@
|
||||
import { test, expect } from './coverage-fixtures.js'
|
||||
import { mockChat } from './chat-fixtures.js'
|
||||
import { installStudioAddons } from './studio-addon.js'
|
||||
import { connectorApi } from './connector-fixtures.js'
|
||||
|
||||
// The page of the Discord add-on (the real built bundle, inside the add-on
|
||||
// shell) with the core API stubbed: one bot token, one allowlist of IDs that
|
||||
// refuses names, the anyone mode with its plain warning, and the setup that
|
||||
// builds the invite link with the five permissions.
|
||||
|
||||
const PAGE = '/app/addons/discord/bots'
|
||||
const TOKEN = 'MTAwMDAwMDAwMDAwMDAwMDAw.GabcDE.' + 'x'.repeat(27)
|
||||
const USER = '123456789012345678'
|
||||
const CHANNEL = '223456789012345678'
|
||||
const NEWCOMER = { id: '323456789012345678', name: 'Newcomer', count: 2, allowed: false, first: '2026-10-27T08:00:00Z', last: '2026-10-27T08:05:00Z' }
|
||||
const BINDING = { id: 'b1', name: 'Team bot', kind: 'discord', agent_id: 'a1', enabled: true, accept_any: false, allow: [USER, CHANNEL], has_token: true }
|
||||
const api = (page, initial = []) => connectorApi(page, initial, 'discord', [NEWCOMER])
|
||||
|
||||
test.describe('Discord add-on', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockChat(page)
|
||||
await installStudioAddons(page, { ids: ['discord'] })
|
||||
})
|
||||
|
||||
test('creates a binding with a token and a list of IDs, and checks the token', async ({ page }) => {
|
||||
const a = api(page)
|
||||
await a.install()
|
||||
await page.route(/\/api\/chats\/connectors\/[^/?]+\/test$/, r => r.fulfill({ json: { bot_id: '1', bot_username: 'localai-bot' } }))
|
||||
await page.goto(PAGE)
|
||||
|
||||
await expect(page.getByText('No Discord bots yet.')).toBeVisible()
|
||||
await page.getByRole('button', { name: 'New Discord bot' }).click()
|
||||
await page.getByLabel('Name', { exact: true }).fill('Team bot')
|
||||
await page.getByLabel('Bot token').fill('short')
|
||||
await expect(page.getByTestId('token-problem')).toContainText('too short')
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
await page.getByLabel('Bot token').fill(TOKEN)
|
||||
await expect(page.getByTestId('token-problem')).toHaveCount(0)
|
||||
await page.getByLabel('Agent', { exact: true }).selectOption('a1')
|
||||
await expect(page.getByTestId('discord-rules')).toContainText('only when it is mentioned')
|
||||
const list = page.getByRole('textbox', { name: 'Allowed people and channels +' })
|
||||
await list.fill('@ada')
|
||||
await list.press('Enter')
|
||||
await expect(page.getByTestId('discord-bad-ids')).toContainText('@ada')
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
await page.getByRole('button', { name: '@ada ×' }).click()
|
||||
await expect(page.getByTestId('discord-bad-ids')).toHaveCount(0)
|
||||
for (const id of [USER, CHANNEL]) {
|
||||
await list.fill(id)
|
||||
await list.press('Enter')
|
||||
}
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
await expect(page.getByTestId('connector-row')).toContainText('Team bot')
|
||||
await expect(page.getByTestId('connector-row')).toContainText('Accepts: 2 listed entries')
|
||||
await expect(page.getByText('Connected to localai-bot.')).toBeVisible()
|
||||
const body = a.s.calls.find(c => c.method === 'POST' && c.url === '/api/chats/connectors').body
|
||||
expect(body).toMatchObject({ kind: 'discord', name: 'Team bot', token: TOKEN, agent_id: 'a1', allow: [USER, CHANNEL] })
|
||||
await expect(page.locator('body')).not.toContainText(TOKEN)
|
||||
})
|
||||
|
||||
test('the anyone mode says what it gives, and needs the confirmation', async ({ page }) => {
|
||||
const a = api(page)
|
||||
await a.install()
|
||||
await page.route(/\/api\/chats\/connectors\/[^/?]+\/test$/, r => r.fulfill({ json: { bot_id: '1', bot_username: 'localai-bot' } }))
|
||||
await page.goto(PAGE)
|
||||
await page.getByRole('button', { name: 'New Discord bot' }).click()
|
||||
await page.getByLabel('Name', { exact: true }).fill('Public bot')
|
||||
await page.getByLabel('Bot token').fill(TOKEN)
|
||||
await page.getByLabel('Agent', { exact: true }).selectOption('a1')
|
||||
await page.getByText('Anyone who can message the bot, or mention it in a channel it can read').click()
|
||||
const warning = page.getByTestId('anyone-warning')
|
||||
await expect(warning).toContainText('public channels')
|
||||
await expect(warning).toContainText('hold no permission of their own')
|
||||
await expect(warning).toContainText('prompt injection')
|
||||
await expect(warning).toContainText('read-only tools')
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
await warning.getByRole('checkbox').check()
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
await expect(page.getByTestId('connector-row')).toContainText('Accepts: anyone')
|
||||
const body = a.s.calls.find(c => c.method === 'POST' && c.url === '/api/chats/connectors').body
|
||||
expect(body).toMatchObject({ kind: 'discord', accept_any: true, allow: [] })
|
||||
})
|
||||
|
||||
test('an edit keeps the stored token unless one is typed', async ({ page }) => {
|
||||
const a = api(page, [BINDING])
|
||||
await a.install()
|
||||
await page.goto(PAGE)
|
||||
await page.getByRole('button', { name: 'Edit' }).click()
|
||||
await expect(page.getByLabel('Bot token')).toHaveValue('')
|
||||
await expect(page.getByLabel('Bot token')).toHaveAttribute('placeholder', /Saved/)
|
||||
await expect(page.getByTestId('connector-allow')).toContainText(CHANNEL)
|
||||
await page.getByRole('button', { name: `${CHANNEL} ×` }).click()
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
const put = a.s.calls.find(c => c.method === 'PUT')
|
||||
expect(put.url).toBe('/api/chats/connectors/b1')
|
||||
expect('token' in put.body).toBe(false)
|
||||
expect(put.body.allow).toEqual([USER])
|
||||
})
|
||||
|
||||
test('shows how to make the bot, and builds the invite link with the five permissions', async ({ page }) => {
|
||||
await api(page).install()
|
||||
await page.goto(PAGE)
|
||||
const setup = page.getByTestId('discord-setup')
|
||||
await setup.getByText('How to make the Discord bot').click()
|
||||
await expect(setup).toContainText('Message Content Intent')
|
||||
await expect(setup).toContainText('274878008320')
|
||||
await expect(setup).toContainText('Never give the bot Administrator')
|
||||
await setup.getByTestId('discord-app-id').fill('abc')
|
||||
await expect(setup.getByTestId('discord-invite')).toHaveCount(0)
|
||||
await setup.getByTestId('discord-app-id').fill(USER)
|
||||
await expect(setup.getByTestId('discord-invite')).toHaveText(`https://discord.com/oauth2/authorize?client_id=${USER}&scope=bot&permissions=274878008320`)
|
||||
for (const p of ['View Channels', 'Send Messages', 'Send Messages in Threads', 'Attach Files', 'Read Message History']) {
|
||||
await expect(setup).toContainText(p)
|
||||
}
|
||||
})
|
||||
|
||||
test('offers the thread option and the !new hint', async ({ page }) => {
|
||||
await api(page).install()
|
||||
await page.route('**/api/chats/connectors/kinds*', r => r.fulfill({ json: { capabilities: [], kinds: [{ kind: 'discord', title: 'Discord', files: null,
|
||||
options: [{ id: 'followThreads', title: 'Threads the bot follows', description: 'd', default: 'mentioned', values: [{ id: 'mentioned', title: 'Threads where it was mentioned' }, { id: 'never', title: 'None: always ask with a mention' }] }] }] } }))
|
||||
await page.goto(PAGE)
|
||||
await page.getByRole('button', { name: 'New Discord bot' }).click()
|
||||
await expect(page.getByTestId('connector-option-followThreads')).toHaveValue('mentioned')
|
||||
await expect(page.getByTestId('connector-answer')).toContainText('Send !new in Discord')
|
||||
})
|
||||
|
||||
test('tests the token, shows the sender log and allows a sender', async ({ page }) => {
|
||||
const a = api(page, [BINDING])
|
||||
await a.install()
|
||||
await page.route(/\/api\/chats\/connectors\/[^/?]+\/test$/, r => r.fulfill({ json: { bot_id: '1', bot_username: 'localai-bot' } }))
|
||||
await page.goto(PAGE)
|
||||
await page.getByRole('button', { name: 'Test' }).click()
|
||||
await expect(page.getByText('Connected to localai-bot.')).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Senders' }).click()
|
||||
await expect(page.getByTestId('sender-log')).toContainText(`Newcomer · 2 messages · ${NEWCOMER.id}`)
|
||||
await page.getByRole('button', { name: 'Allow', exact: true }).click()
|
||||
await expect(page.getByTestId('sender-log')).toContainText('Accepted')
|
||||
})
|
||||
})
|
||||
+2
-2
@@ -25,7 +25,7 @@ export async function buildAddonBundles(ids) {
|
||||
|
||||
// The add-ons the specs serve from their build output: Studio, Talk and Voice
|
||||
// through installStudioAddons, Face and Fine-tuning through their own routes.
|
||||
export async function buildStudioAddons(ids = ['studio', 'talk', 'voice', 'face', 'fine-tuning', 'telegram', 'slack', 'github']) {
|
||||
export async function buildStudioAddons(ids = ['studio', 'talk', 'voice', 'face', 'fine-tuning', 'telegram', 'slack', 'github', 'discord']) {
|
||||
await buildAddonBundles(ids)
|
||||
process.env[BUILT] = '1'
|
||||
}
|
||||
@@ -55,7 +55,7 @@ export function packagedApp(id, over = {}) {
|
||||
export async function installStudioAddons(page, { ids = ['studio', 'talk'], ...options } = {}) {
|
||||
const dirs = await Promise.all(ids.map(build))
|
||||
await stubAddonRoutes(page)
|
||||
await page.route(/\/api\/addons\/(studio|talk|voice|telegram|slack|github)\/assets\//, route => {
|
||||
await page.route(/\/api\/addons\/(studio|talk|voice|telegram|slack|github|discord)\/assets\//, route => {
|
||||
const parts = new URL(route.request().url()).pathname.split('/')
|
||||
const id = parts[3]
|
||||
const file = parts.slice(6).join('/')
|
||||
|
||||
@@ -12,6 +12,7 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.16
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.17
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.99.1
|
||||
github.com/bwmarrin/discordgo v0.29.0
|
||||
github.com/charmbracelet/glamour v1.0.0
|
||||
github.com/containerd/containerd v1.7.33
|
||||
github.com/coreos/go-oidc/v3 v3.18.0
|
||||
|
||||
Reference in new issue
Block a user