mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-05 20:13:23 -05:00
feat(connectors): keep two connections of one bot from answering twice
Two enabled connections can hold different token pairs of the same upstream account, such as one Slack app registered twice. Both then receive every message and both answer it. The op "me" may now return accountId, a stable name of the upstream account. Slack returns the workspace and bot user, Telegram the bot ID and Discord the application ID. The host stores it on the binding when a token is saved and, for a binding that has none, when its add-on runs. An add-on that does not return it has no identity and is never compared. When two enabled bindings of one owner and kind share an identity: - the binding carries a duplicate marker with the code duplicate_connection, with a sentence in all eight languages of the connection texts; - saving one of them is a 409 of that type until the request carries confirm_duplicate, and nothing is saved before; - only the oldest enabled binding answers a message that has a platform message ID. The other logs one line and stays silent. The choice reads the stored bindings only, so every frontend makes the same one. Assisted-by: Claude:claude-sonnet-5-5
This commit is contained in:
38 files changed
+869
-50
No files matched your search
@@ -197,13 +197,24 @@ const (
|
||||
// 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"`
|
||||
}
|
||||
app, ok := a.Application(ctx, token)
|
||||
return app.Flags&(flagMessageContent|flagMessageContentLimited) != 0, ok
|
||||
}
|
||||
|
||||
// Application is the part of the application of a bot token that the add-on
|
||||
// reads. ID names the application, which is the same for every token of it.
|
||||
type Application struct {
|
||||
ID string `json:"id"`
|
||||
Flags int64 `json:"flags"`
|
||||
}
|
||||
|
||||
// Application reads the application of the token. A failed call gives ok
|
||||
// false: the answer is not known.
|
||||
func (a *API) Application(ctx context.Context, token string) (app Application, ok bool) {
|
||||
if a.do(ctx, token, http.MethodGet, "/applications/@me", nil, "", &app) != nil {
|
||||
return false, false
|
||||
return Application{}, false
|
||||
}
|
||||
return app.Flags&(flagMessageContent|flagMessageContentLimited) != 0, true
|
||||
return app, true
|
||||
}
|
||||
|
||||
// Channel is the part of a channel the add-on reads.
|
||||
|
||||
@@ -141,10 +141,18 @@ func (p platform) Me(ctx context.Context, token string) (connector.Bot, error) {
|
||||
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)"
|
||||
// The application is the account: a second token of the same application
|
||||
// is the same bot. The bot user is the fallback when it cannot be read.
|
||||
account := u.ID
|
||||
if app, ok := p.r.API.Application(ctx, token); ok {
|
||||
if app.ID != "" {
|
||||
account = app.ID
|
||||
}
|
||||
if app.Flags&(flagMessageContent|flagMessageContentLimited) == 0 {
|
||||
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
|
||||
return connector.Bot{ID: u.ID, Username: name, AccountID: account}, nil
|
||||
}
|
||||
|
||||
// MediaStream streams a file of a message to the host. The reference holds
|
||||
|
||||
@@ -38,6 +38,7 @@ var _ = Describe("the connector route", func() {
|
||||
Expect(out.Error).To(BeNil())
|
||||
Expect(out.BotID).To(Equal(botID))
|
||||
Expect(out.BotUsername).To(Equal("localai-bot"))
|
||||
Expect(out.AccountID).To(Equal(botID), "the application names the account")
|
||||
})
|
||||
|
||||
It("warns in the name when the Message Content intent is off", func() {
|
||||
@@ -52,6 +53,7 @@ var _ = Describe("the connector route", func() {
|
||||
out := call(rt, contract.ConnectorRequest{Op: "me"})
|
||||
Expect(out.Error).To(BeNil())
|
||||
Expect(out.BotUsername).To(Equal("localai-bot"))
|
||||
Expect(out.AccountID).To(Equal(botID), "the bot user stands in for the application")
|
||||
})
|
||||
|
||||
It("reports a refused token as unauthorized, without the token", func() {
|
||||
|
||||
@@ -40,6 +40,10 @@ func (r *RetryAfter) Error() string {
|
||||
type Bot struct {
|
||||
ID string
|
||||
Username string
|
||||
// AccountID is a stable name of the upstream account that the token
|
||||
// belongs to, equal for every token of that account (see
|
||||
// contract.ConnectorResponse.AccountID). Empty when the platform cannot tell.
|
||||
AccountID string
|
||||
}
|
||||
|
||||
// Platform is the API of one messaging platform. The host passes the token in
|
||||
@@ -145,6 +149,9 @@ func ServeWith(ctx context.Context, p Platform, host Host, req contract.Connecto
|
||||
var bot Bot
|
||||
if bot, err = p.Me(ctx, req.Token); err == nil {
|
||||
out.BotID, out.BotUsername = bot.ID, bot.Username
|
||||
if len(bot.AccountID) <= contract.MaxAccountIDBytes {
|
||||
out.AccountID = bot.AccountID
|
||||
}
|
||||
}
|
||||
case contract.ConnectorOpSendFile:
|
||||
ss, ok := p.(StreamSender)
|
||||
|
||||
@@ -21,6 +21,7 @@ type script struct {
|
||||
typed int
|
||||
ups []contract.ConnectorMessage
|
||||
err error
|
||||
account string
|
||||
sendErr []error
|
||||
editErr error
|
||||
}
|
||||
@@ -53,7 +54,7 @@ func (s *script) Edit(_ context.Context, req contract.ConnectorRequest) error {
|
||||
}
|
||||
func (s *script) Typing(context.Context, contract.ConnectorRequest) error { s.typed++; return s.err }
|
||||
func (s *script) Me(context.Context, string) (connector.Bot, error) {
|
||||
return connector.Bot{ID: "1", Username: "bot"}, s.err
|
||||
return connector.Bot{ID: "1", Username: "bot", AccountID: s.account}, s.err
|
||||
}
|
||||
|
||||
// shouting formats.
|
||||
@@ -149,11 +150,21 @@ var _ = Describe("Serve", func() {
|
||||
out, err := connector.Serve(ctx, s, req("me"))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(out.BotUsername).To(Equal("bot"))
|
||||
Expect(out.AccountID).To(BeEmpty(), "a platform that cannot tell leaves it empty")
|
||||
_, err = connector.Serve(ctx, s, req("typing"))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(s.typed).To(Equal(1))
|
||||
})
|
||||
|
||||
It("passes the account of the token, and drops one that is too long", func() {
|
||||
out, err := connector.Serve(ctx, &script{account: "T1/U1"}, req("me"))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(out.AccountID).To(Equal("T1/U1"))
|
||||
out, err = connector.Serve(ctx, &script{account: strings.Repeat("x", contract.MaxAccountIDBytes+1)}, req("me"))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(out.AccountID).To(BeEmpty())
|
||||
})
|
||||
|
||||
DescribeTable("types the errors of a platform",
|
||||
func(err error, code string) {
|
||||
out, rerr := connector.Serve(ctx, &script{err: err}, req("me"))
|
||||
|
||||
@@ -173,7 +173,13 @@ func (p platform) Me(ctx context.Context, token string) (connector.Bot, error) {
|
||||
if id.Team != "" {
|
||||
name += " (" + id.Team + ")"
|
||||
}
|
||||
return connector.Bot{ID: id.UserID, Username: name}, nil
|
||||
// The bot user and the workspace together name the app: every token pair of
|
||||
// the same app installed in the same workspace gives the same pair.
|
||||
account := id.UserID
|
||||
if id.TeamID != "" {
|
||||
account = id.TeamID + "/" + id.UserID
|
||||
}
|
||||
return connector.Bot{ID: id.UserID, Username: name, AccountID: account}, nil
|
||||
}
|
||||
|
||||
var fileID = regexp.MustCompile(`^F[A-Z0-9]{1,31}$`)
|
||||
|
||||
@@ -215,6 +215,7 @@ var _ = Describe("the connector route", func() {
|
||||
out := call(r, contract.ConnectorRequest{Op: "me"})
|
||||
Expect(out.Error).To(BeNil())
|
||||
Expect([]string{out.BotID, out.BotUsername}).To(Equal([]string{"UBOT", "helper (Acme)"}))
|
||||
Expect(out.AccountID).To(Equal("T1/UBOT"), "the workspace and the bot user name the app, whatever the token pair")
|
||||
for _, bad := range []string{botToken, appToken, botToken + " xapp-SECRET-other"} {
|
||||
out = call(r, contract.ConnectorRequest{Op: "me", Token: bad})
|
||||
Expect(out.Error).NotTo(BeNil(), bad)
|
||||
|
||||
@@ -171,5 +171,6 @@ func (p platform) Me(ctx context.Context, token string) (connector.Bot, error) {
|
||||
if err != nil {
|
||||
return connector.Bot{}, err
|
||||
}
|
||||
return connector.Bot{ID: strconv.FormatInt(bot.ID, 10), Username: bot.Username}, nil
|
||||
id := strconv.FormatInt(bot.ID, 10)
|
||||
return connector.Bot{ID: id, Username: bot.Username, AccountID: id}, nil
|
||||
}
|
||||
@@ -72,6 +72,7 @@ var _ = Describe("the connector route", func() {
|
||||
me, err := r.Connector(ctx, contract.ConnectorRequest{Op: "me", Kind: "telegram", Token: secretToken})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect([]string{me.BotID, me.BotUsername}).To(Equal([]string{"99", "helper_bot"}))
|
||||
Expect(me.AccountID).To(Equal("99"))
|
||||
for _, req := range []contract.ConnectorRequest{{Op: "typing", Chat: "5"}, {Op: "edit", Chat: "5", MessageID: "6", Text: "x"}} {
|
||||
req.Kind, req.Token = "telegram", secretToken
|
||||
out, err := r.Connector(ctx, req)
|
||||
|
||||
@@ -154,7 +154,7 @@ func (h *Handlers) AdminListConnectors(c echo.Context) error {
|
||||
}
|
||||
out := schema.ChatsAdminConnectorList{Connectors: make([]schema.ChatsAdminConnector, 0, len(list))}
|
||||
for _, b := range list {
|
||||
out.Connectors = append(out.Connectors, schema.ChatsAdminConnector{Owner: b.Owner, ChatsConnector: connectorOut(b.ConnectorInfo)})
|
||||
out.Connectors = append(out.Connectors, schema.ChatsAdminConnector{Owner: b.Owner, ChatsConnector: connectorOut(b.ConnectorInfo, connectorLang(c))})
|
||||
}
|
||||
return c.JSON(http.StatusOK, out)
|
||||
}
|
||||
@@ -175,7 +175,7 @@ func (h *Handlers) AdminDisableConnector(c echo.Context) error {
|
||||
if err != nil {
|
||||
return failConnector(c, err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, connectorOut(b))
|
||||
return c.JSON(http.StatusOK, connectorOut(b, connectorLang(c)))
|
||||
}
|
||||
|
||||
// AdminDeleteConnector deletes a connector binding of any user.
|
||||
|
||||
@@ -19,7 +19,31 @@ func (h *Handlers) connectorHost(c echo.Context) (*studiosvc.Connectors, bool) {
|
||||
return feature(c, h.svc.Connectors(), func(c echo.Context) error { return failFor(c, studiosvc.ErrConnectorsOff) })
|
||||
}
|
||||
|
||||
func connectorOut(i studiosvc.ConnectorInfo) schema.ChatsConnector {
|
||||
// connectorLang is the language of the texts for the owner in a request.
|
||||
func connectorLang(c echo.Context) string {
|
||||
return connectortext.Negotiate(c.QueryParam("lang"), c.Request().Header.Get("Accept-Language"))
|
||||
}
|
||||
|
||||
// duplicateOut says that a connection is the same bot as others, in lang.
|
||||
func duplicateOut(i studiosvc.ConnectorInfo, lang string) *schema.ChatsConnectorDuplicate {
|
||||
if len(i.SameBotAs) == 0 {
|
||||
return nil
|
||||
}
|
||||
others := make([]schema.ChatsConnectorDuplicateRef, len(i.SameBotAs))
|
||||
for n, o := range i.SameBotAs {
|
||||
others[n] = schema.ChatsConnectorDuplicateRef{ID: o.ID, Name: o.Name}
|
||||
}
|
||||
return &schema.ChatsConnectorDuplicate{Code: studiosvc.CodeDuplicateConnection, Message: duplicateText(lang, others[0].Name), Others: others}
|
||||
}
|
||||
|
||||
func duplicateText(lang, other string) string {
|
||||
if t, ok := connectortext.Duplicate(lang, other); ok {
|
||||
return t
|
||||
}
|
||||
return studiosvc.DuplicateMessage(other)
|
||||
}
|
||||
|
||||
func connectorOut(i studiosvc.ConnectorInfo, lang string) schema.ChatsConnector {
|
||||
allow := i.Allow
|
||||
if allow == nil {
|
||||
allow = []string{}
|
||||
@@ -27,7 +51,7 @@ func connectorOut(i studiosvc.ConnectorInfo) schema.ChatsConnector {
|
||||
return schema.ChatsConnector{Caps: capsOut(i.Caps), Grants: grantsOut(i.Grants), Paired: pairedOut(i.Paired), ID: i.ID, Name: i.Name, Kind: i.Kind, Addon: i.AddonID, Active: i.Active, Reason: i.Reason, AgentID: i.AgentID, CredentialsOnly: i.CredentialsOnly(), Enabled: i.Enabled, AcceptAny: i.AcceptAny,
|
||||
Allow: allow, NotifyChat: i.NotifyChat, HasToken: i.HasToken, Created: i.Created,
|
||||
ReplyMode: i.ReplyMode(), Conversation: i.ConversationMode(), WindowMinutes: i.WindowMinutes, SendFiles: i.SendsFiles(), ReplyLanguage: i.ReplyLang(), UILanguage: i.UILanguage, Options: i.Options,
|
||||
Files: filesOut(i.Files), FileReceiveMaxBytes: i.FileReceiveMax, FileSendMaxBytes: i.FileSendMax, Resources: connectors.ResourceKeys(i.Resources), Channels: channelsOut(i.Channels), WriteMode: i.Writes(), Approval: approvalOut(i.Approval)}
|
||||
Files: filesOut(i.Files), FileReceiveMaxBytes: i.FileReceiveMax, FileSendMaxBytes: i.FileSendMax, Resources: connectors.ResourceKeys(i.Resources), Channels: channelsOut(i.Channels), WriteMode: i.Writes(), Approval: approvalOut(i.Approval), Duplicate: duplicateOut(i, lang)}
|
||||
}
|
||||
|
||||
func approvalOut(a connectors.Approval) schema.ChatsConnectorApproval {
|
||||
@@ -139,7 +163,7 @@ func connectorIn(req schema.ChatsConnectorWrite) studiosvc.ConnectorInput {
|
||||
return studiosvc.ConnectorInput{Kind: req.Kind, Name: req.Name, Token: req.Token, AgentID: req.AgentID, Enabled: req.Enabled,
|
||||
AcceptAny: req.AcceptAny, Allow: req.Allow, NotifyChat: req.NotifyChat,
|
||||
Reply: req.ReplyMode, Conversation: req.Conversation, WindowMinutes: req.WindowMinutes, SendFiles: req.SendFiles, ReplyLanguage: req.ReplyLanguage, UILanguage: req.UILanguage, Options: req.Options,
|
||||
FileReceiveMax: req.FileReceiveMaxBytes, FileSendMax: req.FileSendMaxBytes, Caps: req.Caps, Grants: grantsIn(req.Grants), Resources: req.Resources, ConfirmAnyResource: req.ConfirmAnyResource, ConfirmWiderToken: req.ConfirmWiderToken, Channels: channelsIn(req.Channels), WriteMode: req.WriteMode, Approval: approvalIn(req.Approval)}
|
||||
FileReceiveMax: req.FileReceiveMaxBytes, FileSendMax: req.FileSendMaxBytes, Caps: req.Caps, Grants: grantsIn(req.Grants), Resources: req.Resources, ConfirmAnyResource: req.ConfirmAnyResource, ConfirmWiderToken: req.ConfirmWiderToken, ConfirmDuplicate: req.ConfirmDuplicate, Channels: channelsIn(req.Channels), WriteMode: req.WriteMode, Approval: approvalIn(req.Approval)}
|
||||
}
|
||||
|
||||
var connectorRules = []errRule{
|
||||
@@ -152,6 +176,14 @@ var connectorRules = []errRule{
|
||||
}
|
||||
|
||||
func failConnector(c echo.Context, err error) error {
|
||||
var dup *studiosvc.DuplicateError
|
||||
if errors.As(err, &dup) {
|
||||
// Nothing was saved. The owner reads the sentence in their language and
|
||||
// may save again with confirm_duplicate.
|
||||
// param is the name of the connection it repeats, for a client that words the question itself.
|
||||
other := dup.Other.Name
|
||||
return c.JSON(http.StatusConflict, schema.ErrorResponse{Error: &schema.APIError{Message: duplicateText(connectorLang(c), other), Code: http.StatusConflict, Type: typeDuplicateConnection, Param: &other}})
|
||||
}
|
||||
return mapErr(c, err, connectorRules, func(c echo.Context, err error) error {
|
||||
var ce *contract.ConnectorError
|
||||
if errors.As(err, &ce) && ce.Code == contract.ConnectorUnauthorized {
|
||||
@@ -181,7 +213,7 @@ func (h *Handlers) ListConnectors(c echo.Context) error {
|
||||
}
|
||||
out := schema.ChatsConnectorList{Connectors: make([]schema.ChatsConnector, 0, len(list))}
|
||||
for _, i := range list {
|
||||
out.Connectors = append(out.Connectors, connectorOut(i))
|
||||
out.Connectors = append(out.Connectors, connectorOut(i, connectorLang(c)))
|
||||
}
|
||||
return c.JSON(http.StatusOK, out)
|
||||
}
|
||||
@@ -224,6 +256,7 @@ func (h *Handlers) ConnectorKinds(c echo.Context) error {
|
||||
// @Param request body schema.ChatsConnectorWrite true "the binding"
|
||||
// @Success 201 {object} schema.ChatsConnector "The binding"
|
||||
// @Failure 400 {object} schema.ErrorResponse "The request is not valid"
|
||||
// @Failure 409 {object} schema.ErrorResponse "The binding is enabled and is the same bot as another enabled binding of yours (type duplicate_connection, param the name of the other); send confirm_duplicate set to \"answer twice\" to save it anyway"
|
||||
// @Router /api/chats/connectors [post]
|
||||
func (h *Handlers) CreateConnector(c echo.Context) error {
|
||||
host, ok := h.connectorHost(c)
|
||||
@@ -241,7 +274,7 @@ func (h *Handlers) CreateConnector(c echo.Context) error {
|
||||
if err != nil {
|
||||
return failConnector(c, err)
|
||||
}
|
||||
return c.JSON(http.StatusCreated, connectorOut(i))
|
||||
return c.JSON(http.StatusCreated, connectorOut(i, connectorLang(c)))
|
||||
}
|
||||
|
||||
// GetConnector reads a binding.
|
||||
@@ -259,7 +292,7 @@ func (h *Handlers) GetConnector(c echo.Context) error {
|
||||
if err != nil {
|
||||
return failConnector(c, err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, connectorOut(i))
|
||||
return c.JSON(http.StatusOK, connectorOut(i, connectorLang(c)))
|
||||
}
|
||||
|
||||
// UpdateConnector replaces a binding.
|
||||
@@ -270,6 +303,7 @@ func (h *Handlers) GetConnector(c echo.Context) error {
|
||||
// @Param request body schema.ChatsConnectorWrite true "the binding"
|
||||
// @Success 200 {object} schema.ChatsConnector "The binding"
|
||||
// @Failure 400 {object} schema.ErrorResponse "The request is not valid"
|
||||
// @Failure 409 {object} schema.ErrorResponse "The binding would be the same bot as another enabled binding of yours (type duplicate_connection); see the create endpoint"
|
||||
// @Router /api/chats/connectors/{id} [put]
|
||||
func (h *Handlers) UpdateConnector(c echo.Context) error {
|
||||
host, ok := h.connectorHost(c)
|
||||
@@ -294,7 +328,7 @@ func (h *Handlers) UpdateConnector(c echo.Context) error {
|
||||
if err != nil {
|
||||
return failConnector(c, err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, connectorOut(i))
|
||||
return c.JSON(http.StatusOK, connectorOut(i, connectorLang(c)))
|
||||
}
|
||||
|
||||
// DeleteConnector deletes a binding.
|
||||
@@ -324,7 +358,7 @@ func (h *Handlers) setConnectorEnabled(c echo.Context, on bool) error {
|
||||
if err != nil {
|
||||
return failConnector(c, err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, connectorOut(i))
|
||||
return c.JSON(http.StatusOK, connectorOut(i, connectorLang(c)))
|
||||
}
|
||||
|
||||
// EnableConnector switches a binding on.
|
||||
@@ -402,7 +436,7 @@ func (h *Handlers) AllowConnectorSender(c echo.Context) error {
|
||||
if err != nil {
|
||||
return failConnector(c, err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, connectorOut(i))
|
||||
return c.JSON(http.StatusOK, connectorOut(i, connectorLang(c)))
|
||||
}
|
||||
|
||||
// StartConnectorPairing makes a pairing code for a binding.
|
||||
@@ -517,7 +551,7 @@ func (h *Handlers) RemoveConnectorPaired(c echo.Context) error {
|
||||
if err != nil {
|
||||
return failConnector(c, err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, connectorOut(i))
|
||||
return c.JSON(http.StatusOK, connectorOut(i, connectorLang(c)))
|
||||
}
|
||||
|
||||
// capabilityOut adds the title and description in the negotiated language
|
||||
|
||||
@@ -24,6 +24,9 @@ const (
|
||||
typeNotifyOff = "notifications_off"
|
||||
typeTriggersOff = "triggers_off"
|
||||
typeConnectorOff = "connectors_off"
|
||||
// typeDuplicateConnection: the connection to save is the same bot as
|
||||
// another enabled one; send confirm_duplicate to save it anyway.
|
||||
typeDuplicateConnection = "duplicate_connection"
|
||||
// typePageNotAllowed: a shared page called its bridge without your consent
|
||||
// to the version it runs (or after you revoked it).
|
||||
typePageNotAllowed = "page_not_allowed"
|
||||
|
||||
@@ -1366,6 +1366,10 @@ type ChatsConnector struct {
|
||||
// Files are the limits of the files the bot reads and sends, with the
|
||||
// limit that you set for this bot.
|
||||
Files ChatsConnectorFiles `json:"files"`
|
||||
// Duplicate is present when another enabled connection of yours is the
|
||||
// same bot (the add-on reported the same upstream account for both) and
|
||||
// this one is enabled too: both then answer the same message.
|
||||
Duplicate *ChatsConnectorDuplicate `json:"duplicate,omitempty"`
|
||||
// FileReceiveMaxBytes and FileSendMaxBytes are your limits for this bot;
|
||||
// zero means you set none and the limit of the kind applies.
|
||||
FileReceiveMaxBytes int64 `json:"file_receive_max_bytes"`
|
||||
@@ -1757,6 +1761,11 @@ type ChatsConnectorWrite struct {
|
||||
// request that chooses the option a token guard of the kind names (for
|
||||
// example a classic access token). The token is refused without it.
|
||||
ConfirmWiderToken string `json:"confirm_wider_token,omitempty"`
|
||||
// ConfirmDuplicate must be the exact text "answer twice" to save an
|
||||
// enabled connection that is the same bot as another enabled connection of
|
||||
// yours. Without it the request is a 409 of type duplicate_connection, and
|
||||
// nothing is saved.
|
||||
ConfirmDuplicate string `json:"confirm_duplicate,omitempty"`
|
||||
// Channels are the chats of the platform where the agent may write
|
||||
// besides the conversation, at most 10: {id, label, approved}. A channel
|
||||
// that is not approved needs a paired sender for each message. Absent
|
||||
@@ -1774,6 +1783,24 @@ type ChatsConnectorWrite struct {
|
||||
Approval *ChatsConnectorApproval `json:"approval,omitempty"`
|
||||
}
|
||||
|
||||
// ChatsConnectorDuplicate says that a connection is the same bot as others.
|
||||
type ChatsConnectorDuplicate struct {
|
||||
// Code is duplicate_connection.
|
||||
Code string `json:"code"`
|
||||
// Message is the plain sentence for the owner, in the language of the
|
||||
// request (lang, else Accept-Language) with English as the fallback.
|
||||
Message string `json:"message"`
|
||||
// Others are the other enabled connections of the same bot, oldest first.
|
||||
// The oldest enabled one is the one that answers.
|
||||
Others []ChatsConnectorDuplicateRef `json:"others"`
|
||||
}
|
||||
|
||||
// ChatsConnectorDuplicateRef names another connection of the same bot.
|
||||
type ChatsConnectorDuplicateRef struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// ChatsConnectorChannel is a chat of the platform where the agent may write.
|
||||
type ChatsConnectorChannel struct {
|
||||
ID string `json:"id"`
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
package studio
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mudler/LocalAI/core/services/studio/connectors"
|
||||
"github.com/mudler/LocalAI/pkg/addons/contract"
|
||||
)
|
||||
|
||||
// Two connections can hold different token pairs of the same upstream
|
||||
// account, such as one Slack app registered twice. Both then see every message
|
||||
// and both answer it. This file finds that case from the identity of the
|
||||
// upstream account that the add-on reports (contract.ConnectorResponse.AccountID)
|
||||
// and keeps the younger connection silent.
|
||||
//
|
||||
// State mode: shared, and stateless. The identity is stored on the binding and
|
||||
// the choice of the connection that answers is a function of the stored
|
||||
// bindings alone (the oldest enabled one), so every frontend reaches the same
|
||||
// answer for the same message without a lock or a claim. The only memory kept
|
||||
// in a process is the time of the last attempt to learn an identity, which
|
||||
// throttles calls and is allowed to differ between frontends.
|
||||
|
||||
// DuplicateConfirmation is what a request carries to save an enabled
|
||||
// connection that is the same bot as another enabled one of its owner.
|
||||
const DuplicateConfirmation = "answer twice"
|
||||
|
||||
// accountRetry is how long a frontend waits before it asks an add-on again for
|
||||
// the identity of a binding whose add-on did not tell it.
|
||||
const accountRetry = 10 * time.Minute
|
||||
|
||||
// DuplicateRef names the other connection of a bot that is connected twice.
|
||||
type DuplicateRef struct{ ID, Name string }
|
||||
|
||||
// ErrDuplicateConnection means the connection to save is the same bot as
|
||||
// another enabled connection of its owner, and the request did not confirm.
|
||||
var ErrDuplicateConnection = errors.New("studio: this bot is already connected")
|
||||
|
||||
// DuplicateError is ErrDuplicateConnection with the connection it repeats.
|
||||
type DuplicateError struct{ Other DuplicateRef }
|
||||
|
||||
func (e *DuplicateError) Error() string { return DuplicateMessage(e.Other.Name) }
|
||||
func (e *DuplicateError) Is(t error) bool { return t == ErrDuplicateConnection }
|
||||
|
||||
// DuplicateMessage is the English sentence for the code CodeDuplicateConnection.
|
||||
func DuplicateMessage(other string) string {
|
||||
return strings.ReplaceAll(blockTable[CodeDuplicateConnection].Message, "{other}", other)
|
||||
}
|
||||
|
||||
// sameBot reports whether a and b are two enabled connections that answer for
|
||||
// the same upstream account: one owner, one add-on and kind, one known identity.
|
||||
func sameBot(a, b connectors.Binding) bool {
|
||||
return a.ID != b.ID && a.AccountID != "" && a.AccountID == b.AccountID && a.Owner == b.Owner &&
|
||||
a.AddonID == b.AddonID && a.Kind == b.Kind &&
|
||||
a.Enabled && b.Enabled && !a.CredentialsOnly() && !b.CredentialsOnly()
|
||||
}
|
||||
|
||||
// duplicatesOf lists the bindings of all that are the same bot as b, oldest first.
|
||||
func duplicatesOf(all []connectors.Binding, b connectors.Binding) []connectors.Binding {
|
||||
var out []connectors.Binding
|
||||
for _, o := range all {
|
||||
if sameBot(o, b) {
|
||||
out = append(out, o)
|
||||
}
|
||||
}
|
||||
slices.SortFunc(out, olderFirst)
|
||||
return out
|
||||
}
|
||||
|
||||
func olderFirst(a, b connectors.Binding) int {
|
||||
if c := a.Created.Compare(b.Created); c != 0 {
|
||||
return c
|
||||
}
|
||||
return strings.Compare(a.ID, b.ID)
|
||||
}
|
||||
|
||||
func refsOf(bs []connectors.Binding) []DuplicateRef {
|
||||
if len(bs) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]DuplicateRef, len(bs))
|
||||
for i, o := range bs {
|
||||
out[i] = DuplicateRef{ID: o.ID, Name: o.Name}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// markDuplicates fills SameBotAs of each info from the bindings of the owner.
|
||||
func markDuplicates(infos []ConnectorInfo, all []connectors.Binding) {
|
||||
for i := range infos {
|
||||
infos[i].SameBotAs = refsOf(duplicatesOf(all, infos[i].Binding))
|
||||
}
|
||||
}
|
||||
|
||||
// accountOf asks the add-on, with the op me, for the identity of the account
|
||||
// that a token belongs to. It is empty when the add-on is not running, does
|
||||
// not tell, or the call fails: the identity is a help, never a reason to
|
||||
// refuse a token.
|
||||
func (c *Connectors) accountOf(ctx context.Context, b connectors.Binding, token string) string {
|
||||
kinds, err := c.o.Transport.Kinds(ctx)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if _, ok := kindFor(kinds, b); !ok {
|
||||
return ""
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||
defer cancel()
|
||||
resp, err := c.o.Transport.Call(ctx, verificationScope(b), contract.ConnectorRequest{Op: contract.ConnectorOpMe, Kind: b.Kind, Token: token})
|
||||
if err != nil || resp.Error != nil {
|
||||
return ""
|
||||
}
|
||||
id := strings.TrimSpace(resp.AccountID)
|
||||
if len(id) > contract.MaxAccountIDBytes {
|
||||
return ""
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// learnAccount gives a binding that has no identity the one its add-on
|
||||
// reports, and stores it. It asks at most once in accountRetry per binding in
|
||||
// this process, so an add-on that does not tell is not called for every message.
|
||||
func (c *Connectors) learnAccount(ctx context.Context, b connectors.Binding, token string) connectors.Binding {
|
||||
if b.AccountID != "" || token == "" {
|
||||
return b
|
||||
}
|
||||
now := c.o.Clock()
|
||||
c.amu2.Lock()
|
||||
if last, ok := c.accountTried[b.ID]; ok && now.Sub(last) < accountRetry {
|
||||
c.amu2.Unlock()
|
||||
return b
|
||||
}
|
||||
if c.accountTried == nil {
|
||||
c.accountTried = map[string]time.Time{}
|
||||
}
|
||||
c.accountTried[b.ID] = now
|
||||
c.amu2.Unlock()
|
||||
id := c.accountOf(ctx, b, token)
|
||||
if id == "" {
|
||||
return b
|
||||
}
|
||||
if err := c.o.Store.SetAccountID(ctx, b.ID, id); err != nil {
|
||||
c.log.Warn("studio: could not store the account of a connection", "binding", b.ID, "error", err)
|
||||
return b
|
||||
}
|
||||
b.AccountID = id
|
||||
// A connection that was silent for want of an identity may now be a duplicate.
|
||||
c.Notify()
|
||||
return b
|
||||
}
|
||||
|
||||
// checkDuplicate refuses to save an enabled connection that is the same bot as
|
||||
// another enabled one unless the request confirms. old is nil for a new binding;
|
||||
// a connection that was already a duplicate of the same bots is not asked again.
|
||||
func checkDuplicate(old *connectors.Binding, b connectors.Binding, mine []connectors.Binding, in ConnectorInput) error {
|
||||
dups := duplicatesOf(mine, b)
|
||||
if len(dups) == 0 || in.ConfirmDuplicate == DuplicateConfirmation {
|
||||
return nil
|
||||
}
|
||||
if old != nil {
|
||||
known := map[string]bool{}
|
||||
for _, o := range duplicatesOf(mine, *old) {
|
||||
known[o.ID] = true
|
||||
}
|
||||
if !slices.ContainsFunc(dups, func(o connectors.Binding) bool { return !known[o.ID] }) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return &DuplicateError{Other: refsOf(dups)[0]}
|
||||
}
|
||||
|
||||
// olderTwin returns the enabled connection of the same bot that must answer in
|
||||
// place of b, or false when b answers. The oldest enabled connection answers:
|
||||
// the rule reads the stored bindings only, so every frontend picks the same one.
|
||||
// It applies to a message that has a platform ID, because that ID is what says
|
||||
// the two connections were handed the same message. A store that fails leaves
|
||||
// the message to be answered.
|
||||
func (c *Connectors) olderTwin(ctx context.Context, b connectors.Binding, m contract.ConnectorMessage) (connectors.Binding, bool) {
|
||||
if b.AccountID == "" || m.MessageID == "" {
|
||||
return connectors.Binding{}, false
|
||||
}
|
||||
mine, err := c.o.Store.ListBindings(ctx, b.Owner)
|
||||
if err != nil {
|
||||
return connectors.Binding{}, false
|
||||
}
|
||||
dups := duplicatesOf(mine, b)
|
||||
if len(dups) == 0 || olderFirst(dups[0], b) > 0 {
|
||||
return connectors.Binding{}, false
|
||||
}
|
||||
return dups[0], true
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package studio
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mudler/LocalAI/core/services/studio/connectors"
|
||||
"github.com/mudler/LocalAI/pkg/addons/contract"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// dupAddon is a fake connector add-on: it answers the op me with an account
|
||||
// by token, and keeps count of the calls.
|
||||
type dupAddon struct {
|
||||
mu sync.Mutex
|
||||
accounts map[string]string // by token; a token that is absent is refused
|
||||
silent bool // answers me without an account, like an older add-on
|
||||
meCalls int
|
||||
}
|
||||
|
||||
func (a *dupAddon) Kinds(context.Context) ([]ConnectorKind, error) {
|
||||
return []ConnectorKind{{AddonID: "one", ConnectorKind: contract.ConnectorKind{ID: "chat", Title: "Chat", Delivery: contract.DeliveryPush}}}, nil
|
||||
}
|
||||
|
||||
func (a *dupAddon) Call(_ context.Context, _ ConnectorCallScope, req contract.ConnectorRequest) (contract.ConnectorResponse, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if req.Op != contract.ConnectorOpMe {
|
||||
return contract.ConnectorResponse{}, nil
|
||||
}
|
||||
a.meCalls++
|
||||
acct, ok := a.accounts[req.Token]
|
||||
if !ok {
|
||||
return contract.ConnectorResponse{Error: &contract.ConnectorError{Code: contract.ConnectorUnauthorized}}, nil
|
||||
}
|
||||
if a.silent {
|
||||
return contract.ConnectorResponse{BotID: "bot"}, nil
|
||||
}
|
||||
return contract.ConnectorResponse{BotID: "bot", AccountID: acct}, nil
|
||||
}
|
||||
|
||||
func (a *dupAddon) calls() int { a.mu.Lock(); defer a.mu.Unlock(); return a.meCalls }
|
||||
|
||||
// dupClock is an injected clock.
|
||||
type dupClock struct {
|
||||
mu sync.Mutex
|
||||
t time.Time
|
||||
}
|
||||
|
||||
func (c *dupClock) Now() time.Time { c.mu.Lock(); defer c.mu.Unlock(); return c.t }
|
||||
func (c *dupClock) Advance(d time.Duration) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.t = c.t.Add(d)
|
||||
}
|
||||
|
||||
var _ = Describe("two connections of one bot", func() {
|
||||
ctx := context.Background()
|
||||
t0 := time.Date(2026, 11, 5, 9, 0, 0, 0, time.UTC)
|
||||
mk := func(id, owner, account string, age time.Duration, on bool) connectors.Binding {
|
||||
return connectors.Binding{ID: id, Owner: owner, Name: "Bot " + id, Kind: "chat", AddonID: "one", AgentID: "a1", Enabled: on, AccountID: account, Created: t0.Add(age)}
|
||||
}
|
||||
|
||||
DescribeTable("finds the connections that are the same bot",
|
||||
func(a, b connectors.Binding, same bool) {
|
||||
Expect(sameBot(a, b)).To(Equal(same))
|
||||
Expect(sameBot(b, a)).To(Equal(same), "the relation is symmetric")
|
||||
if same {
|
||||
Expect(duplicatesOf([]connectors.Binding{a, b}, b)).To(HaveLen(1))
|
||||
} else {
|
||||
Expect(duplicatesOf([]connectors.Binding{a, b}, b)).To(BeEmpty())
|
||||
}
|
||||
},
|
||||
Entry("same identity, both on", mk("a", "u1", "T1/U1", 0, true), mk("b", "u1", "T1/U1", time.Hour, true), true),
|
||||
Entry("different identity", mk("a", "u1", "T1/U1", 0, true), mk("b", "u1", "T1/U2", time.Hour, true), false),
|
||||
Entry("same bot in another workspace", mk("a", "u1", "T1/U1", 0, true), mk("b", "u1", "T2/U1", time.Hour, true), false),
|
||||
Entry("identity unknown on one side", mk("a", "u1", "T1/U1", 0, true), mk("b", "u1", "", time.Hour, true), false),
|
||||
Entry("identity unknown on both sides", mk("a", "u1", "", 0, true), mk("b", "u1", "", time.Hour, true), false),
|
||||
Entry("one of them is off", mk("a", "u1", "T1/U1", 0, true), mk("b", "u1", "T1/U1", time.Hour, false), false),
|
||||
Entry("both are off", mk("a", "u1", "T1/U1", 0, false), mk("b", "u1", "T1/U1", time.Hour, false), false),
|
||||
Entry("different owner", mk("a", "u1", "T1/U1", 0, true), mk("b", "u2", "T1/U1", time.Hour, true), false),
|
||||
Entry("same account id on another kind", mk("a", "u1", "7", 0, true), func() connectors.Binding {
|
||||
b := mk("b", "u1", "7", time.Hour, true)
|
||||
b.Kind = "other"
|
||||
return b
|
||||
}(), false),
|
||||
Entry("same account id on another add-on", mk("a", "u1", "7", 0, true), func() connectors.Binding {
|
||||
b := mk("b", "u1", "7", time.Hour, true)
|
||||
b.AddonID = "two"
|
||||
return b
|
||||
}(), false),
|
||||
Entry("a credentials-only binding never answers", mk("a", "u1", "T1/U1", 0, true), func() connectors.Binding {
|
||||
b := mk("b", "u1", "T1/U1", time.Hour, true)
|
||||
b.AgentID = ""
|
||||
return b
|
||||
}(), false),
|
||||
)
|
||||
|
||||
It("lists the other connections oldest first, and never the connection itself", func() {
|
||||
all := []connectors.Binding{mk("c", "u1", "X", 2*time.Hour, true), mk("a", "u1", "X", 0, true), mk("b", "u1", "X", time.Hour, true)}
|
||||
Expect(refsOf(duplicatesOf(all, all[0]))).To(Equal([]DuplicateRef{{ID: "a", Name: "Bot a"}, {ID: "b", Name: "Bot b"}}))
|
||||
Expect(duplicatesOf(all[:1], all[0])).To(BeEmpty())
|
||||
})
|
||||
|
||||
Describe("the answer to one message", func() {
|
||||
var (
|
||||
addon *dupAddon
|
||||
clock *dupClock
|
||||
st connectors.Store
|
||||
fe []*Connectors // two frontends over one store
|
||||
logs *strings.Builder
|
||||
)
|
||||
// put stores a binding as a frontend would have saved it.
|
||||
put := func(b connectors.Binding, token string) {
|
||||
s, err := fe[0].sealerFor(ctx)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
b.Token, err = s.Seal(connectors.AAD(b.ID, b.Owner), []byte(token))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(st.PutBinding(ctx, b)).To(Succeed())
|
||||
}
|
||||
// message is what both add-ons receive for one post of the platform.
|
||||
message := func(id string) contract.ConnectorMessage {
|
||||
return contract.ConnectorMessage{Chat: "C1", Thread: "t1", Mention: true, MessageID: id, SenderID: "U9", SenderName: "Sam", Text: "hello"}
|
||||
}
|
||||
// seen says whether the binding took the message up to the sender log.
|
||||
seen := func(id string) bool {
|
||||
s, err := st.Senders(ctx, id, 0)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
return len(s) > 0
|
||||
}
|
||||
BeforeEach(func() {
|
||||
addon = &dupAddon{accounts: map[string]string{"old": "T1/U1", "new": "T1/U1", "other": "T1/U2"}}
|
||||
clock = &dupClock{t: t0.Add(24 * time.Hour)}
|
||||
st, _ = connectors.NewJSON("")
|
||||
logs = &strings.Builder{}
|
||||
fe = nil
|
||||
for range 2 {
|
||||
svc, _ := newSkillService(&skillBroker{})
|
||||
c, err := NewConnectors(svc, ConnectorOptions{Store: st, Transport: addon, Clock: clock.Now, Logger: slog.New(slog.NewTextHandler(logs, nil))})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
DeferCleanup(c.Close)
|
||||
fe = append(fe, c)
|
||||
}
|
||||
})
|
||||
|
||||
It("is taken by the oldest enabled connection only, on every frontend", func() {
|
||||
put(mk("old", "u1", "T1/U1", 0, true), "old")
|
||||
put(mk("new", "u1", "T1/U1", time.Hour, true), "new")
|
||||
for i, c := range fe {
|
||||
id := "m" + string(rune('1'+i))
|
||||
Expect(c.Ingest(ctx, "one", contract.ConnectorInbound{BindingID: "new", ConnectorMessage: message(id)})).To(Succeed())
|
||||
Expect(c.Ingest(ctx, "one", contract.ConnectorInbound{BindingID: "old", ConnectorMessage: message(id)})).To(Succeed())
|
||||
}
|
||||
Expect(seen("old")).To(BeTrue(), "the older connection takes the message")
|
||||
Expect(seen("new")).To(BeFalse(), "the younger one stays silent, whichever frontend got the message")
|
||||
Expect(strings.Count(logs.String(), "stays silent")).To(Equal(2), "one line for each message")
|
||||
})
|
||||
|
||||
It("lets the younger connection answer when the older one is switched off", func() {
|
||||
old := mk("old", "u1", "T1/U1", 0, false)
|
||||
put(old, "old")
|
||||
put(mk("new", "u1", "T1/U1", time.Hour, true), "new")
|
||||
Expect(fe[1].Ingest(ctx, "one", contract.ConnectorInbound{BindingID: "new", ConnectorMessage: message("m1")})).To(Succeed())
|
||||
Expect(seen("new")).To(BeTrue())
|
||||
})
|
||||
|
||||
It("leaves a connection of another identity, of another owner and of an unknown identity alone", func() {
|
||||
put(mk("old", "u1", "T1/U1", 0, true), "old")
|
||||
put(mk("diff", "u1", "T1/U2", time.Hour, true), "other")
|
||||
put(mk("alien", "u2", "T1/U1", time.Hour, true), "new")
|
||||
put(mk("blank", "u1", "", time.Hour, true), "") // no token to ask with: stays unknown
|
||||
for _, id := range []string{"old", "diff", "alien", "blank"} {
|
||||
Expect(fe[0].Ingest(ctx, "one", contract.ConnectorInbound{BindingID: id, ConnectorMessage: message("m1")})).To(Succeed())
|
||||
}
|
||||
for _, id := range []string{"old", "diff", "alien"} {
|
||||
Expect(seen(id)).To(BeTrue(), id)
|
||||
}
|
||||
})
|
||||
|
||||
It("tells the oldest connection by the clock of the store, whatever the order of creation in the list", func() {
|
||||
// The youngest ID sorts first: the age decides, not the name.
|
||||
put(mk("a-young", "u1", "T1/U1", time.Hour, true), "new")
|
||||
put(mk("z-old", "u1", "T1/U1", 0, true), "old")
|
||||
Expect(fe[0].Ingest(ctx, "one", contract.ConnectorInbound{BindingID: "a-young", ConnectorMessage: message("m1")})).To(Succeed())
|
||||
Expect(fe[1].Ingest(ctx, "one", contract.ConnectorInbound{BindingID: "z-old", ConnectorMessage: message("m1")})).To(Succeed())
|
||||
Expect(seen("z-old")).To(BeTrue())
|
||||
Expect(seen("a-young")).To(BeFalse())
|
||||
})
|
||||
|
||||
It("takes a message that has no platform ID as it always did", func() {
|
||||
put(mk("old", "u1", "T1/U1", 0, true), "old")
|
||||
put(mk("new", "u1", "T1/U1", time.Hour, true), "new")
|
||||
m := message("")
|
||||
Expect(fe[0].Ingest(ctx, "one", contract.ConnectorInbound{BindingID: "new", ConnectorMessage: m})).To(Succeed())
|
||||
Expect(seen("new")).To(BeTrue(), "without the ID the two connections cannot be told to hold the same message")
|
||||
})
|
||||
|
||||
It("learns the account of a stored connection once, and asks an add-on that does not tell only now and then", func() {
|
||||
put(mk("old", "u1", "", 0, true), "old")
|
||||
Expect(fe[0].Ingest(ctx, "one", contract.ConnectorInbound{BindingID: "old", ConnectorMessage: message("m1")})).To(Succeed())
|
||||
b, _ := st.GetBinding(ctx, "old")
|
||||
Expect(b.AccountID).To(Equal("T1/U1"))
|
||||
Expect(addon.calls()).To(Equal(1))
|
||||
Expect(fe[0].Ingest(ctx, "one", contract.ConnectorInbound{BindingID: "old", ConnectorMessage: message("m2")})).To(Succeed())
|
||||
Expect(addon.calls()).To(Equal(1), "a known account is not asked again")
|
||||
|
||||
addon.silent = true
|
||||
put(mk("older", "u1", "", 0, true), "old")
|
||||
for i := range 3 {
|
||||
Expect(fe[0].Ingest(ctx, "one", contract.ConnectorInbound{BindingID: "older", ConnectorMessage: message("s" + string(rune('0'+i)))})).To(Succeed())
|
||||
}
|
||||
Expect(addon.calls()).To(Equal(2), "an add-on that tells nothing is asked once inside the retry time")
|
||||
clock.Advance(accountRetry + time.Second)
|
||||
Expect(fe[0].Ingest(ctx, "one", contract.ConnectorInbound{BindingID: "older", ConnectorMessage: message("s9")})).To(Succeed())
|
||||
Expect(addon.calls()).To(Equal(3), "and again after it")
|
||||
b, _ = st.GetBinding(ctx, "older")
|
||||
Expect(b.AccountID).To(BeEmpty(), "no identity is made up for an add-on that does not tell")
|
||||
})
|
||||
})
|
||||
|
||||
DescribeTable("asks for a confirmation when a save makes a connection a duplicate",
|
||||
func(old *connectors.Binding, b connectors.Binding, mine []connectors.Binding, in ConnectorInput, wantOther string) {
|
||||
err := checkDuplicate(old, b, mine, in)
|
||||
if wantOther == "" {
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
return
|
||||
}
|
||||
var d *DuplicateError
|
||||
Expect(err).To(MatchError(ErrDuplicateConnection))
|
||||
Expect(err).To(BeAssignableToTypeOf(d))
|
||||
Expect(err.(*DuplicateError).Other.Name).To(Equal(wantOther))
|
||||
Expect(err.Error()).To(Equal("This bot is also connected as " + wantOther + ". Both connections will answer the same message."))
|
||||
},
|
||||
Entry("a new twin", nil, mk("n", "u1", "X", time.Hour, true), []connectors.Binding{mk("a", "u1", "X", 0, true)}, ConnectorInput{}, "Bot a"),
|
||||
Entry("a new twin, confirmed", nil, mk("n", "u1", "X", time.Hour, true), []connectors.Binding{mk("a", "u1", "X", 0, true)}, ConnectorInput{ConfirmDuplicate: DuplicateConfirmation}, ""),
|
||||
Entry("a new twin that is off", nil, mk("n", "u1", "X", time.Hour, false), []connectors.Binding{mk("a", "u1", "X", 0, true)}, ConnectorInput{}, ""),
|
||||
Entry("another bot", nil, mk("n", "u1", "Y", time.Hour, true), []connectors.Binding{mk("a", "u1", "X", 0, true)}, ConnectorInput{}, ""),
|
||||
Entry("an unknown identity", nil, mk("n", "u1", "", time.Hour, true), []connectors.Binding{mk("a", "u1", "", 0, true)}, ConnectorInput{}, ""),
|
||||
Entry("a connection that was a twin already is not asked again", func() *connectors.Binding { b := mk("n", "u1", "X", time.Hour, true); return &b }(),
|
||||
mk("n", "u1", "X", time.Hour, true), []connectors.Binding{mk("a", "u1", "X", 0, true), mk("n", "u1", "X", time.Hour, true)}, ConnectorInput{}, ""),
|
||||
Entry("a switched-off connection that is saved on", func() *connectors.Binding { b := mk("n", "u1", "X", time.Hour, false); return &b }(),
|
||||
mk("n", "u1", "X", time.Hour, true), []connectors.Binding{mk("a", "u1", "X", 0, true), mk("n", "u1", "X", time.Hour, false)}, ConnectorInput{}, "Bot a"),
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
package studio_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/mudler/LocalAI/core/services/studio"
|
||||
"github.com/mudler/LocalAI/core/services/studio/connectors"
|
||||
"github.com/mudler/LocalAI/pkg/addons/contract"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// The same Telegram bot registered twice: the case of the same Slack app
|
||||
// registered with two token pairs, on the add-on that the scripted bot serves.
|
||||
var _ = Describe("a bot connected twice", func() {
|
||||
var r *connRig
|
||||
ctx := context.Background()
|
||||
msg := func(id string) contract.ConnectorMessage {
|
||||
return contract.ConnectorMessage{Chat: "11", Direct: true, MessageID: id, SenderID: "11", SenderName: "S11", Text: "hello"}
|
||||
}
|
||||
BeforeEach(func() {
|
||||
r = newConnRig(newShared(), connStore(), newScriptedBot(), connSay("one answer"),
|
||||
func(context.Context) bool { return false }) // no polling loop: the specs hand the messages over themselves
|
||||
})
|
||||
|
||||
It("asks before it saves the second connection, names the first, and marks both", func() {
|
||||
first := r.bind(studio.ConnectorInput{Name: "Support bot", Allow: []string{"11"}})
|
||||
in := studio.ConnectorInput{Kind: "telegram", Name: "Same bot again", Token: "777:other", AgentID: r.agentID(), Enabled: true, Allow: []string{"11"}, Reply: connectors.ReplyOff}
|
||||
_, err := r.conns.CreateConnector(ctx, alice, in)
|
||||
Expect(err).To(MatchError(studio.ErrDuplicateConnection))
|
||||
Expect(err.Error()).To(Equal("This bot is also connected as Support bot. Both connections will answer the same message."))
|
||||
list, _ := r.conns.ListConnectors(ctx, alice)
|
||||
Expect(list).To(HaveLen(1), "nothing was saved")
|
||||
|
||||
in.ConfirmDuplicate = studio.DuplicateConfirmation
|
||||
second, err := r.conns.CreateConnector(ctx, alice, in)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(second.SameBotAs).To(Equal([]studio.DuplicateRef{{ID: first.ID, Name: "Support bot"}}))
|
||||
list, _ = r.conns.ListConnectors(ctx, alice)
|
||||
Expect(list).To(HaveLen(2))
|
||||
for _, i := range list {
|
||||
Expect(i.SameBotAs).To(HaveLen(1), i.Name)
|
||||
}
|
||||
|
||||
// Turning one off clears the warning on the other.
|
||||
off, err := r.conns.SetConnectorEnabled(ctx, alice, second.ID, false)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(off.SameBotAs).To(BeEmpty())
|
||||
got, _ := r.conns.GetConnector(ctx, alice, first.ID)
|
||||
Expect(got.SameBotAs).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("answers one message once, from the older connection, and from the other when the older is off", func() {
|
||||
first := r.bind(studio.ConnectorInput{Name: "Support bot", Allow: []string{"11"}})
|
||||
r.sh.clock.Advance(time.Minute) // a later creation time
|
||||
second := r.bind(studio.ConnectorInput{Name: "Same bot again", Token: "777:other", Allow: []string{"11"}})
|
||||
Expect(second.Created.After(first.Created)).To(BeTrue())
|
||||
|
||||
Expect(r.conns.Ingest(ctx, first.AddonID, contract.ConnectorInbound{BindingID: second.ID, ConnectorMessage: msg("m1")})).To(Succeed())
|
||||
Expect(r.conns.Ingest(ctx, first.AddonID, contract.ConnectorInbound{BindingID: first.ID, ConnectorMessage: msg("m1")})).To(Succeed())
|
||||
Eventually(func() int { return len(r.bot.answers()) }, "10s").Should(Equal(1))
|
||||
Consistently(func() int { return len(r.bot.answers()) }, "300ms", "50ms").Should(Equal(1), "the younger connection stays silent")
|
||||
chats, _ := r.svc.List(ctx, alice, 20)
|
||||
Expect(chats).To(HaveLen(1))
|
||||
Expect(chats[0].OriginID).To(Equal(first.ID))
|
||||
|
||||
_, err := r.conns.SetConnectorEnabled(ctx, alice, first.ID, false)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(r.conns.Ingest(ctx, first.AddonID, contract.ConnectorInbound{BindingID: second.ID, ConnectorMessage: msg("m2")})).To(Succeed())
|
||||
Eventually(func() int { return len(r.bot.answers()) }, "10s").Should(Equal(2))
|
||||
})
|
||||
})
|
||||
@@ -55,6 +55,11 @@ const (
|
||||
// CodeKnowledgeNoEmbedding: the model that indexes Knowledge is not
|
||||
// installed on the server.
|
||||
CodeKnowledgeNoEmbedding = "knowledge_no_embedding"
|
||||
// CodeDuplicateConnection: another enabled connection of the owner is the
|
||||
// same bot, so both answer the same message. It is not a refusal of a
|
||||
// capability: the sentence names the other connection with {other}, and
|
||||
// DuplicateMessage fills it.
|
||||
CodeDuplicateConnection = "duplicate_connection"
|
||||
)
|
||||
|
||||
// BlockCodes lists every code, in the order of the chain.
|
||||
@@ -62,7 +67,7 @@ func BlockCodes() []string {
|
||||
return []string{CodeNoAgent, CodeUnknownCapability, CodeServerSettingOff, CodeToolsSwitchOff, CodeAgentPermissionOff,
|
||||
CodeAgentUnavailable, CodeConnectionPermissionOff, CodeSenderNotListed, CodeSenderNeedsPairing,
|
||||
CodeChannelNotTrusted, CodeRoleNotGranted, CodeKindNotRunning, CodeContextRule,
|
||||
CodeKnowledgeNone, CodeKnowledgeNoEmbedding}
|
||||
CodeKnowledgeNone, CodeKnowledgeNoEmbedding, CodeDuplicateConnection}
|
||||
}
|
||||
|
||||
// Who holds the link of the chain that refused.
|
||||
@@ -173,6 +178,9 @@ var blockTable = map[string]blockInfo{
|
||||
CodeKnowledgeNoEmbedding: {ByServer,
|
||||
"The embedding model for Knowledge is not installed, so the bot cannot {action}. Install it in Models.",
|
||||
"The server cannot index new texts right now, so the bot cannot {action}."},
|
||||
CodeDuplicateConnection: {ByConnection,
|
||||
"This bot is also connected as {other}. Both connections will answer the same message.",
|
||||
"This bot is connected twice, so a second copy may answer too."},
|
||||
CodeContextRule: {ByConnection,
|
||||
"The bot cannot {action} in this place.",
|
||||
"This bot cannot {action} in this place."},
|
||||
|
||||
@@ -47,7 +47,7 @@ var _ = Describe("the texts of a refusal", func() {
|
||||
It("renders a whole, plain sentence for every code and capability", func() {
|
||||
for _, code := range BlockCodes() {
|
||||
for _, name := range names {
|
||||
for kind, text := range map[string]string{"message": blockMessage(code, name), "say": BlockSay(code, name)} {
|
||||
for kind, text := range map[string]string{"message": sampleOther(blockMessage(code, name)), "say": BlockSay(code, name)} {
|
||||
where := code + "/" + name + "/" + kind + ": " + text
|
||||
Expect(text).NotTo(BeEmpty(), where)
|
||||
Expect(text).To(HaveSuffix("."), where)
|
||||
@@ -130,3 +130,6 @@ var _ = Describe("the texts of a refusal", func() {
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// sampleOther fills {other} of a sentence with a name, so the sentence is read as a person reads it.
|
||||
func sampleOther(text string) string { return strings.ReplaceAll(text, "{other}", "Alpha") }
|
||||
@@ -132,6 +132,9 @@ type Connectors struct {
|
||||
smu sync.Mutex
|
||||
senders map[string]*senderState
|
||||
|
||||
amu2 sync.Mutex
|
||||
accountTried map[string]time.Time // by binding: when this process last asked its add-on for its account
|
||||
|
||||
emu sync.Mutex
|
||||
nextEdit map[string]time.Time // by binding: when its next edit may go
|
||||
|
||||
@@ -362,6 +365,8 @@ poll:
|
||||
c.disable(b, "its bot token cannot be read")
|
||||
return
|
||||
}
|
||||
// A binding whose add-on told no account yet is asked again every so often.
|
||||
b = c.learnAccount(ctx, b, token)
|
||||
offset, _, err := c.o.Store.Offset(ctx, id)
|
||||
if err != nil {
|
||||
c.log.Warn("studio: could not read a connector offset", "binding", id, "error", err)
|
||||
@@ -581,6 +586,13 @@ func (c *Connectors) Ingest(ctx context.Context, addonID string, in contract.Con
|
||||
c.disable(b, "its bot token cannot be read")
|
||||
return connectors.ErrForbidden
|
||||
}
|
||||
// A push platform has no loop that learns the account, so it is learned here.
|
||||
b = c.learnAccount(ctx, b, token)
|
||||
// Two connections of one bot get the same message: the oldest answers.
|
||||
if older, twin := c.olderTwin(ctx, b, m); twin {
|
||||
c.log.Info("studio: a connection stays silent because an older connection of the same bot answers", "binding", b.ID, "answering", older.ID, "kind", b.Kind, "message", m.MessageID)
|
||||
return nil
|
||||
}
|
||||
c.handle(ctx, b, token, m)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -353,6 +353,19 @@ func Run(newStore NewStore) {
|
||||
got, _ = s.GetBinding(ctx, "has")
|
||||
Expect(got.Caps).To(Equal([]string{"memory.write"}))
|
||||
})
|
||||
It("records the upstream account of a binding without touching its other fields", func() {
|
||||
s := newStore()
|
||||
b := binding("acct", "u1", "Acct")
|
||||
Expect(s.PutBinding(ctx, b)).To(Succeed())
|
||||
Expect(s.SetAccountID(ctx, "acct", "T1/U1")).To(Succeed())
|
||||
got, err := s.GetBinding(ctx, "acct")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(got.AccountID).To(Equal("T1/U1"))
|
||||
Expect(got.Name).To(Equal("Acct"))
|
||||
Expect(s.SetAccountID(ctx, "missing", "x")).To(Succeed(), "a missing binding is not an error")
|
||||
all, _ := s.AllBindings(ctx)
|
||||
Expect(all).To(HaveLen(1))
|
||||
})
|
||||
It("lets one caller claim a migration, once, until a stale claim is taken over", func() {
|
||||
s := newStore()
|
||||
lease := 10 * time.Minute
|
||||
|
||||
@@ -51,12 +51,14 @@ type bindingRow struct {
|
||||
FileSendMax int64
|
||||
// Caps, Grants and Paired are JSON, added by AutoMigrate; a row stored
|
||||
// before them has none.
|
||||
Caps string
|
||||
Grants string
|
||||
Paired string
|
||||
Token []byte
|
||||
Created time.Time
|
||||
Updated time.Time
|
||||
Caps string
|
||||
Grants string
|
||||
Paired string
|
||||
// AccountID is added by AutoMigrate; a row stored before it has none.
|
||||
AccountID string `gorm:"size:128"`
|
||||
Token []byte
|
||||
Created time.Time
|
||||
Updated time.Time
|
||||
}
|
||||
|
||||
func (bindingRow) TableName() string { return "studio_connectors" }
|
||||
@@ -129,7 +131,7 @@ func NewGORM(db *gorm.DB) (Store, error) {
|
||||
|
||||
func (r bindingRow) binding() (Binding, error) {
|
||||
b := Binding{ID: r.ID, Owner: r.Owner, Kind: r.Kind, AddonID: r.AddonID, Name: r.Name, AgentID: r.AgentID, Enabled: r.Enabled, AcceptAny: r.AcceptAny,
|
||||
NotifyChat: r.NotifyChat, Reply: r.Reply, Conversation: r.Conversation, WindowMinutes: r.WindowMinutes, FilesOff: r.FilesOff, ReplyLanguage: r.ReplyLanguage, UILanguage: r.UILanguage, WriteMode: r.WriteMode,
|
||||
NotifyChat: r.NotifyChat, Reply: r.Reply, Conversation: r.Conversation, WindowMinutes: r.WindowMinutes, FilesOff: r.FilesOff, ReplyLanguage: r.ReplyLanguage, UILanguage: r.UILanguage, WriteMode: r.WriteMode, AccountID: r.AccountID,
|
||||
FileReceiveMax: r.FileReceiveMax, FileSendMax: r.FileSendMax,
|
||||
Token: append([]byte(nil), r.Token...), Created: r.Created.UTC(), Updated: r.Updated.UTC()}
|
||||
if r.Allow != "" {
|
||||
@@ -176,7 +178,7 @@ func (s *gormStore) PutBinding(ctx context.Context, b Binding) error {
|
||||
}
|
||||
return s.db.WithContext(ctx).Save(&bindingRow{Approval: enc(b.Approval, b.Approval.IsZero()), WriteMode: b.WriteMode, Resources: enc(b.Resources, len(b.Resources) == 0), Channels: enc(b.Channels, len(b.Channels) == 0), Caps: enc(b.Caps, len(b.Caps) == 0), Grants: enc(b.Grants, len(b.Grants) == 0), Paired: enc(b.Paired, len(b.Paired) == 0), ID: b.ID, Owner: b.Owner, Kind: b.Kind, AddonID: b.AddonID, Name: b.Name, AgentID: b.AgentID, Enabled: b.Enabled,
|
||||
AcceptAny: b.AcceptAny, Allow: allow, NotifyChat: b.NotifyChat, Reply: b.Reply, Conversation: b.Conversation, WindowMinutes: b.WindowMinutes,
|
||||
FilesOff: b.FilesOff, ReplyLanguage: b.ReplyLanguage, UILanguage: b.UILanguage, Options: options, FileReceiveMax: b.FileReceiveMax, FileSendMax: b.FileSendMax,
|
||||
FilesOff: b.FilesOff, ReplyLanguage: b.ReplyLanguage, UILanguage: b.UILanguage, AccountID: b.AccountID, Options: options, FileReceiveMax: b.FileReceiveMax, FileSendMax: b.FileSendMax,
|
||||
Token: b.Token, Created: b.Created, Updated: b.Updated}).Error
|
||||
}
|
||||
|
||||
@@ -358,6 +360,10 @@ func (s *gormStore) SetCapsIfEmpty(ctx context.Context, id string, caps []string
|
||||
return res.RowsAffected == 1, res.Error
|
||||
}
|
||||
|
||||
func (s *gormStore) SetAccountID(ctx context.Context, id, accountID string) error {
|
||||
return s.db.WithContext(ctx).Model(&bindingRow{}).Where("id = ?", id).Update("account_id", accountID).Error
|
||||
}
|
||||
|
||||
func (s *gormStore) ClaimMigration(ctx context.Context, name string, now time.Time, lease time.Duration) (bool, error) {
|
||||
db := s.db.WithContext(ctx)
|
||||
res := db.Clauses(clause.OnConflict{DoNothing: true}).Create(&migrationRow{Name: name, Started: now.UTC()})
|
||||
|
||||
@@ -306,6 +306,23 @@ func (s *jsonStore) SetCapsIfEmpty(_ context.Context, id string, caps []string)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *jsonStore) SetAccountID(_ context.Context, id, accountID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
b, ok := s.d.Bindings[id]
|
||||
if !ok || b.AccountID == accountID {
|
||||
return nil
|
||||
}
|
||||
old := b
|
||||
b.AccountID = accountID
|
||||
s.d.Bindings[id] = b
|
||||
if err := s.save(); err != nil {
|
||||
s.d.Bindings[id] = old
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *jsonStore) ClaimMigration(_ context.Context, name string, now time.Time, lease time.Duration) (bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
@@ -202,6 +202,12 @@ type Binding struct {
|
||||
// from a chat of the binding, and the limits of the modes that create one
|
||||
// without asking. The zero value asks.
|
||||
Approval Approval
|
||||
// AccountID is the stable name of the upstream account that the token of
|
||||
// the binding belongs to, as its add-on told it (see
|
||||
// contract.ConnectorResponse.AccountID). Empty until the host has learned
|
||||
// it, and for an add-on that does not tell. Two enabled bindings of one
|
||||
// owner and kind with the same AccountID are the same bot.
|
||||
AccountID string
|
||||
// Token is the bot token. In a Store it is sealed bytes; the Store returns
|
||||
// what it was given.
|
||||
Token []byte
|
||||
@@ -315,6 +321,11 @@ type Store interface {
|
||||
// same time. It reports whether it wrote. A missing binding is false.
|
||||
SetCapsIfEmpty(ctx context.Context, id string, caps []string) (bool, error)
|
||||
|
||||
// SetAccountID records the upstream account of a binding as one step, so it
|
||||
// never overwrites a setting that an owner saved at the same time. A missing
|
||||
// binding is not an error.
|
||||
SetAccountID(ctx context.Context, id, accountID string) error
|
||||
|
||||
// ClaimMigration lets one caller, on any frontend, run the named one-time
|
||||
// migration. It is true for the first caller, and for a later one only when
|
||||
// an earlier claim was never finished and is older than lease. A finished
|
||||
|
||||
@@ -464,6 +464,7 @@ var _ = Describe("connectors live", func() {
|
||||
Expect(err).NotTo(HaveOccurred(), "an update that keeps the token does not ask the platform")
|
||||
in.Token = "777:other"
|
||||
bot.setFail("getMe", nil)
|
||||
in.ConfirmDuplicate = studio.DuplicateConfirmation
|
||||
info, err := r.conns.CreateConnector(ctx, alice, in)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect([]any{info.ReplyMode(), info.ConversationMode(), info.SendsFiles()}).To(Equal([]any{connectors.ReplyProgress, connectors.ConversationSender, true}), "the defaults")
|
||||
|
||||
@@ -350,6 +350,8 @@ func (c *connRig) bind(in studio.ConnectorInput) connectors.Binding {
|
||||
in.AgentID = c.agent.ID
|
||||
}
|
||||
in.Enabled = true
|
||||
// Many specs bind the same fake bot twice on purpose.
|
||||
in.ConfirmDuplicate = studio.DuplicateConfirmation
|
||||
if in.Reply == "" {
|
||||
// The specs written before the reply modes read the answer as the
|
||||
// one message the bot sends; the live modes have their own specs.
|
||||
|
||||
@@ -80,6 +80,9 @@ type ConnectorInput struct {
|
||||
// WiderTokenConfirmation in the request that chooses the option which
|
||||
// allows a guarded token (see contract.TokenGuard). Neither is stored.
|
||||
ConfirmAnyResource, ConfirmWiderToken string
|
||||
// ConfirmDuplicate must be DuplicateConfirmation to save an enabled
|
||||
// connection that is the same bot as another enabled one. Not stored.
|
||||
ConfirmDuplicate string
|
||||
}
|
||||
|
||||
// apply sets the fields of the input that an owner writes on a binding.
|
||||
@@ -182,6 +185,10 @@ type ConnectorInfo struct {
|
||||
Reason string
|
||||
// Files are the file limits in effect for the binding.
|
||||
Files FileLimits
|
||||
// SameBotAs lists the other enabled connections of the owner that are the
|
||||
// same bot as this one, oldest first. It is empty unless this binding is
|
||||
// enabled too. Both then answer the same message.
|
||||
SameBotAs []DuplicateRef
|
||||
}
|
||||
|
||||
func info(b connectors.Binding) ConnectorInfo {
|
||||
@@ -404,6 +411,7 @@ func (c *Connectors) CreateConnector(ctx context.Context, user string, in Connec
|
||||
if err := c.verifyToken(ctx, b, strings.TrimSpace(in.Token)); err != nil {
|
||||
return ConnectorInfo{}, err
|
||||
}
|
||||
b.AccountID = c.accountOf(ctx, b, strings.TrimSpace(in.Token))
|
||||
c.wmu.Lock()
|
||||
defer c.wmu.Unlock()
|
||||
mine, err := c.o.Store.ListBindings(ctx, owner)
|
||||
@@ -416,6 +424,9 @@ func (c *Connectors) CreateConnector(ctx context.Context, user string, in Connec
|
||||
if err := checkConnectionName(b, mine); err != nil {
|
||||
return ConnectorInfo{}, err
|
||||
}
|
||||
if err := checkDuplicate(nil, b, mine, in); err != nil {
|
||||
return ConnectorInfo{}, err
|
||||
}
|
||||
sealer, err := c.sealerFor(ctx)
|
||||
if err != nil {
|
||||
return ConnectorInfo{}, err
|
||||
@@ -429,7 +440,7 @@ func (c *Connectors) CreateConnector(ctx context.Context, user string, in Connec
|
||||
return ConnectorInfo{}, err
|
||||
}
|
||||
c.Notify()
|
||||
return c.withState(ctx, info(b)), nil
|
||||
return c.withDuplicates(ctx, c.withState(ctx, info(b))), nil
|
||||
}
|
||||
|
||||
// checkConnectionName refuses a name that another credential-only binding of
|
||||
@@ -458,6 +469,7 @@ func checkConnectionName(b connectors.Binding, mine []connectors.Binding) error
|
||||
func (c *Connectors) UpdateConnector(ctx context.Context, user, id string, in ConnectorInput) (ConnectorInfo, error) {
|
||||
// A new token is checked before the write lock: the check asks the
|
||||
// platform. The kind and the add-on of a binding never change.
|
||||
newAccount := ""
|
||||
if t := strings.TrimSpace(in.Token); t != "" {
|
||||
cur, err := c.owned(ctx, user, id)
|
||||
if err != nil {
|
||||
@@ -466,6 +478,7 @@ func (c *Connectors) UpdateConnector(ctx context.Context, user, id string, in Co
|
||||
if err := c.verifyToken(ctx, cur, t); err != nil {
|
||||
return ConnectorInfo{}, err
|
||||
}
|
||||
newAccount = c.accountOf(ctx, cur, t)
|
||||
}
|
||||
c.wmu.Lock()
|
||||
defer c.wmu.Unlock()
|
||||
@@ -475,6 +488,10 @@ func (c *Connectors) UpdateConnector(ctx context.Context, user, id string, in Co
|
||||
}
|
||||
b := old
|
||||
in.apply(&b)
|
||||
if strings.TrimSpace(in.Token) != "" {
|
||||
// A new token may belong to another account: the identity is the one just read.
|
||||
b.AccountID = newAccount
|
||||
}
|
||||
if err := connectors.ValidateKind(b, c.agentOptional(ctx, b)); err != nil {
|
||||
return ConnectorInfo{}, err
|
||||
}
|
||||
@@ -496,15 +513,18 @@ func (c *Connectors) UpdateConnector(ctx context.Context, user, id string, in Co
|
||||
if err := c.checkAgentIfAny(ctx, old.Owner, in.AgentID); err != nil {
|
||||
return ConnectorInfo{}, err
|
||||
}
|
||||
mine, err := c.o.Store.ListBindings(ctx, old.Owner)
|
||||
if err != nil {
|
||||
return ConnectorInfo{}, err
|
||||
}
|
||||
if b.Name != old.Name {
|
||||
mine, err := c.o.Store.ListBindings(ctx, old.Owner)
|
||||
if err != nil {
|
||||
return ConnectorInfo{}, err
|
||||
}
|
||||
if err := checkConnectionName(b, mine); err != nil {
|
||||
return ConnectorInfo{}, err
|
||||
}
|
||||
}
|
||||
if err := checkDuplicate(&old, b, mine, in); err != nil {
|
||||
return ConnectorInfo{}, err
|
||||
}
|
||||
if strings.TrimSpace(in.Token) != "" {
|
||||
sealer, err := c.sealerFor(ctx)
|
||||
if err != nil {
|
||||
@@ -519,7 +539,7 @@ func (c *Connectors) UpdateConnector(ctx context.Context, user, id string, in Co
|
||||
return ConnectorInfo{}, err
|
||||
}
|
||||
c.Notify()
|
||||
return c.withState(ctx, info(b)), nil
|
||||
return c.withDuplicates(ctx, c.withState(ctx, info(b))), nil
|
||||
}
|
||||
|
||||
// SetConnectorEnabled switches a binding on or off.
|
||||
@@ -538,7 +558,7 @@ func (c *Connectors) SetConnectorEnabled(ctx context.Context, user, id string, o
|
||||
return ConnectorInfo{}, err
|
||||
}
|
||||
c.Notify()
|
||||
return c.withState(ctx, info(b)), nil
|
||||
return c.withDuplicates(ctx, c.withState(ctx, info(b))), nil
|
||||
}
|
||||
|
||||
// GetConnector returns a binding of the user, without its token.
|
||||
@@ -547,7 +567,18 @@ func (c *Connectors) GetConnector(ctx context.Context, user, id string) (Connect
|
||||
if err != nil {
|
||||
return ConnectorInfo{}, err
|
||||
}
|
||||
return c.withState(ctx, info(b)), nil
|
||||
return c.withDuplicates(ctx, c.withState(ctx, info(b))), nil
|
||||
}
|
||||
|
||||
// withDuplicates fills SameBotAs of one binding. A store that fails leaves it empty.
|
||||
func (c *Connectors) withDuplicates(ctx context.Context, i ConnectorInfo) ConnectorInfo {
|
||||
if i.AccountID == "" || !i.Enabled {
|
||||
return i
|
||||
}
|
||||
if all, err := c.o.Store.ListBindings(ctx, i.Owner); err == nil {
|
||||
i.SameBotAs = refsOf(duplicatesOf(all, i.Binding))
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
// ListConnectors returns the user's bindings, without their tokens.
|
||||
@@ -560,6 +591,7 @@ func (c *Connectors) ListConnectors(ctx context.Context, user string) ([]Connect
|
||||
for i, b := range all {
|
||||
out[i] = c.withState(ctx, info(b))
|
||||
}
|
||||
markDuplicates(out, all)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -93,6 +93,17 @@ func Block(lang, code, capability string) (string, bool) {
|
||||
return strings.ReplaceAll(tpl, "{action}", action), true
|
||||
}
|
||||
|
||||
// Duplicate returns the sentence of the code duplicate_connection in a
|
||||
// language, with the name of the other connection filled in. It reports false
|
||||
// when the language lacks the text; the caller then keeps the English sentence.
|
||||
func Duplicate(lang, other string) (string, bool) {
|
||||
tpl, ok := lookup(lang, BlockKey("duplicate_connection"))
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return strings.ReplaceAll(tpl, "{other}", other), true
|
||||
}
|
||||
|
||||
// KeysOf lists the keys of a language, for the tests that guard the files.
|
||||
func KeysOf(lang string) []string {
|
||||
loadOnce.Do(load)
|
||||
|
||||
@@ -32,7 +32,8 @@ func TestEveryCodeIsInEveryLanguage(t *testing.T) {
|
||||
t.Errorf("%s: block code %q has no text", lang, code)
|
||||
continue
|
||||
}
|
||||
if strings.Contains(got, "{") {
|
||||
// {other} names a connection and is filled by Duplicate.
|
||||
if strings.Contains(strings.ReplaceAll(got, "{other}", ""), "{") {
|
||||
t.Errorf("%s: block code %q leaves a placeholder: %q", lang, code, got)
|
||||
}
|
||||
}
|
||||
@@ -62,6 +63,27 @@ func TestPlaceholdersMatchEnglish(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The sentence about two connections of one bot names the other connection in
|
||||
// every language, so the owner reads which one it is.
|
||||
func TestDuplicateNamesTheOtherConnection(t *testing.T) {
|
||||
english := studio.DuplicateMessage("Work bot")
|
||||
if !strings.Contains(english, "Work bot") || strings.Contains(english, "{") {
|
||||
t.Fatalf("English: %q", english)
|
||||
}
|
||||
for _, lang := range connectortext.Languages {
|
||||
got, ok := connectortext.Duplicate(lang, "Work bot")
|
||||
if !ok || !strings.Contains(got, "Work bot") || strings.Contains(got, "{") {
|
||||
t.Errorf("%s: %q", lang, got)
|
||||
}
|
||||
if got == english {
|
||||
t.Errorf("%s: not translated", lang)
|
||||
}
|
||||
}
|
||||
if _, ok := connectortext.Duplicate("xx", "a"); ok {
|
||||
t.Error("an unknown language must have no text")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoLocaleHasAnExtraKey(t *testing.T) {
|
||||
// Locale keys outside the closed lists would be dead text.
|
||||
known := map[string]bool{"action.unknown": true}
|
||||
|
||||
@@ -53,5 +53,6 @@
|
||||
"block.kind_not_running": "Die App dieser Verbindung läuft nicht, daher können Rollen nicht geprüft werden. Starte die App.",
|
||||
"block.context_rule": "Der Bot kann an diesem Ort nicht {action}.",
|
||||
"block.knowledge_none": "In der Liste des Agenten steht keine Sammlung, daher kann der Bot nirgends {action}. Füge dem Agenten eine Sammlung hinzu.",
|
||||
"block.knowledge_no_embedding": "Das Embedding-Modell für das Wissen ist nicht installiert, daher kann der Bot nicht {action}. Installiere es unter Modelle."
|
||||
"block.knowledge_no_embedding": "Das Embedding-Modell für das Wissen ist nicht installiert, daher kann der Bot nicht {action}. Installiere es unter Modelle.",
|
||||
"block.duplicate_connection": "Dieser Bot ist auch als {other} verbunden. Beide Verbindungen beantworten dieselbe Nachricht."
|
||||
}
|
||||
@@ -53,5 +53,6 @@
|
||||
"block.kind_not_running": "La app de esta conexión no se está ejecutando, así que no se pueden comprobar los roles. Inicia la app.",
|
||||
"block.context_rule": "El bot no puede {action} en este lugar.",
|
||||
"block.knowledge_none": "Ninguna colección está en la lista del agente, así que el bot no tiene dónde {action}. Añade una colección al agente.",
|
||||
"block.knowledge_no_embedding": "El modelo de embeddings del Conocimiento no está instalado, así que el bot no puede {action}. Instálalo en Modelos."
|
||||
"block.knowledge_no_embedding": "El modelo de embeddings del Conocimiento no está instalado, así que el bot no puede {action}. Instálalo en Modelos.",
|
||||
"block.duplicate_connection": "Este bot también está conectado como {other}. Ambas conexiones responderán al mismo mensaje."
|
||||
}
|
||||
@@ -53,5 +53,6 @@
|
||||
"block.kind_not_running": "L'application de cette connexion ne tourne pas, donc les rôles ne peuvent pas être vérifiés. Démarrez l'application.",
|
||||
"block.context_rule": "Le bot ne peut pas {action} à cet endroit.",
|
||||
"block.knowledge_none": "Aucune collection n'est dans la liste de l'agent, donc le bot n'a nulle part où {action}. Ajoutez une collection à l'agent.",
|
||||
"block.knowledge_no_embedding": "Le modèle d'embedding des Connaissances n'est pas installé, donc le bot ne peut pas {action}. Installez-le dans Modèles."
|
||||
"block.knowledge_no_embedding": "Le modèle d'embedding des Connaissances n'est pas installé, donc le bot ne peut pas {action}. Installez-le dans Modèles.",
|
||||
"block.duplicate_connection": "Ce bot est aussi connecté sous le nom {other}. Les deux connexions répondront au même message."
|
||||
}
|
||||
@@ -53,5 +53,6 @@
|
||||
"block.kind_not_running": "Aplikasi untuk koneksi ini tidak berjalan, jadi peran tidak dapat diperiksa. Jalankan aplikasinya.",
|
||||
"block.context_rule": "Bot tidak dapat {action} di tempat ini.",
|
||||
"block.knowledge_none": "Tidak ada koleksi dalam daftar agen, jadi bot tidak punya tempat untuk {action}. Tambahkan koleksi ke agen.",
|
||||
"block.knowledge_no_embedding": "Model embedding untuk Pengetahuan belum dipasang, jadi bot tidak dapat {action}. Pasang di Model."
|
||||
"block.knowledge_no_embedding": "Model embedding untuk Pengetahuan belum dipasang, jadi bot tidak dapat {action}. Pasang di Model.",
|
||||
"block.duplicate_connection": "Bot ini juga terhubung sebagai {other}. Kedua koneksi akan menjawab pesan yang sama."
|
||||
}
|
||||
@@ -53,5 +53,6 @@
|
||||
"block.kind_not_running": "L'app di questa connessione non è in esecuzione, quindi non si possono controllare i ruoli. Avvia l'app.",
|
||||
"block.context_rule": "Il bot non può {action} in questo posto.",
|
||||
"block.knowledge_none": "Nessuna raccolta è nell'elenco dell'agente, quindi il bot non ha dove {action}. Aggiungi una raccolta all'agente.",
|
||||
"block.knowledge_no_embedding": "Il modello di embedding per la Conoscenza non è installato, quindi il bot non può {action}. Installalo in Modelli."
|
||||
"block.knowledge_no_embedding": "Il modello di embedding per la Conoscenza non è installato, quindi il bot non può {action}. Installalo in Modelli.",
|
||||
"block.duplicate_connection": "Questo bot è collegato anche come {other}. Entrambe le connessioni risponderanno allo stesso messaggio."
|
||||
}
|
||||
@@ -53,5 +53,6 @@
|
||||
"block.kind_not_running": "이 연결의 앱이 실행 중이 아니어서 역할을 확인할 수 없습니다. 앱을 시작해 주세요.",
|
||||
"block.context_rule": "봇은 이 위치에서 다음 작업을 할 수 없습니다: {action}.",
|
||||
"block.knowledge_none": "에이전트의 목록에 컬렉션이 없어서 봇이 다음 작업을 할 곳이 없습니다: {action}. 에이전트에 컬렉션을 추가해 주세요.",
|
||||
"block.knowledge_no_embedding": "지식용 임베딩 모델이 설치되어 있지 않아 봇이 다음 작업을 할 수 없습니다: {action}. 모델에서 설치해 주세요."
|
||||
"block.knowledge_no_embedding": "지식용 임베딩 모델이 설치되어 있지 않아 봇이 다음 작업을 할 수 없습니다: {action}. 모델에서 설치해 주세요.",
|
||||
"block.duplicate_connection": "이 봇은 {other}(으)로도 연결되어 있습니다. 두 연결이 같은 메시지에 모두 답합니다."
|
||||
}
|
||||
@@ -53,5 +53,6 @@
|
||||
"block.kind_not_running": "O app desta conexão não está em execução, então os papéis não podem ser verificados. Inicie o app.",
|
||||
"block.context_rule": "O bot não pode {action} neste lugar.",
|
||||
"block.knowledge_none": "Nenhuma coleção está na lista do agente, então o bot não tem onde {action}. Adicione uma coleção ao agente.",
|
||||
"block.knowledge_no_embedding": "O modelo de embeddings do Conhecimento não está instalado, então o bot não pode {action}. Instale-o em Modelos."
|
||||
"block.knowledge_no_embedding": "O modelo de embeddings do Conhecimento não está instalado, então o bot não pode {action}. Instale-o em Modelos.",
|
||||
"block.duplicate_connection": "Este bot também está conectado como {other}. As duas conexões vão responder à mesma mensagem."
|
||||
}
|
||||
@@ -53,5 +53,6 @@
|
||||
"block.kind_not_running": "此连接的应用未运行,因此无法检查角色。请启动该应用。",
|
||||
"block.context_rule": "机器人不能在此处{action}。",
|
||||
"block.knowledge_none": "代理的列表中没有任何集合,因此机器人无处可{action}。请为代理添加一个集合。",
|
||||
"block.knowledge_no_embedding": "知识库的嵌入模型尚未安装,因此机器人无法{action}。请在“模型”中安装。"
|
||||
"block.knowledge_no_embedding": "知识库的嵌入模型尚未安装,因此机器人无法{action}。请在“模型”中安装。",
|
||||
"block.duplicate_connection": "这个机器人也以 {other} 的名称连接。两个连接都会回复同一条消息。"
|
||||
}
|
||||
@@ -1220,6 +1220,35 @@ allows one reader; a new leader in a cluster waits and retries for about a minut
|
||||
before it gives up), when the agent is deleted or has no model, or when the owner no longer
|
||||
exists. Fix the cause and switch it on.
|
||||
|
||||
## One bot connected twice
|
||||
|
||||
You can register the same bot twice with two token pairs, for example one Slack app
|
||||
with two sets of tokens. Both bindings then see every message, and both would
|
||||
answer it. The messaging add-ons (Slack, Telegram and Discord) tell the host which
|
||||
upstream account a token belongs to: the workspace and bot user for Slack, the bot
|
||||
for Telegram, the application for Discord. The host keeps it with the binding, and it
|
||||
learns it when you save a token and, for a binding that has none yet, when its add-on
|
||||
runs. An add-on that does not tell has no identity, and its bindings are never compared.
|
||||
|
||||
When two enabled bindings of one owner and one kind share an identity:
|
||||
|
||||
- Both connection pages and the list show "This bot is also connected as *name*. Both
|
||||
connections will answer the same message.", a link to the other connection and a
|
||||
"Turn this one off" button. The API shows it as `duplicate` on the binding, with the
|
||||
code `duplicate_connection`.
|
||||
- Saving a binding that is enabled and a twin of another enabled binding asks first. The
|
||||
API answers `409` with the type `duplicate_connection`, and `param` holds the name of the
|
||||
other binding. Send `confirm_duplicate` set to `answer twice` to save it anyway. Nothing is
|
||||
saved without it. Switching a binding on with the switch does not ask; the warning shows at
|
||||
once.
|
||||
- At run time only the oldest enabled binding answers a message. The younger one logs one
|
||||
line and stays silent, so you never get two replies, even if you ignore the warning. This
|
||||
applies to a message that has a platform message ID, because that ID shows that the two
|
||||
bindings were handed the same message. Switch the older one off and the younger one answers.
|
||||
|
||||
The choice of the binding that answers is a function of the stored bindings (age, then ID),
|
||||
so every frontend makes the same one without a lock. See also "Multiple frontends" above.
|
||||
|
||||
## Storage and clusters
|
||||
|
||||
Bindings, the read position in Telegram's updates, the sender log and the chat of
|
||||
@@ -1231,6 +1260,7 @@ restart). The JSON store is for one process.
|
||||
|---|---|---|
|
||||
| Bindings, offsets, sender log, sender chats, the key | Shared | Rows in the database. |
|
||||
| Which frontend reads the bot | Single runner | The frontend that holds the advisory lock (`KeyChatsConnectors`). When it stops, another takes the lock and continues from the stored offset. Add-ons run on the process that owns the add-ons, and the loops run only where the add-on answers. |
|
||||
| The upstream account of a binding, and which of two twin bindings answers | Shared | The account is stored with the binding. The oldest enabled binding of the same account answers; every frontend derives that from the stored bindings, so no claim is needed. A frontend remembers in memory only when it last asked an add-on for an account that the add-on did not tell, to ask again after 10 minutes. |
|
||||
| Where the next message is read from | Shared | The offset is stored with the binding. Core passes it to the add-on's poll call and stores the offset that the answer names. |
|
||||
| The reply being sent for a turn | Per instance | The frontend that took the message sends the answer, edits its working message or sends its draft, and sends its files. If it dies, the answer is lost; the message is not repeated. |
|
||||
| The preview budget of a binding | Per instance | The time of the next edit or draft is kept in memory by the frontend that sends the replies. Replies of one binding run on the leader, so one budget covers them. |
|
||||
@@ -1263,6 +1293,13 @@ chat files or add tools for this. When the platform refuses a file, return a
|
||||
permission): the host puts it after "I could not send <name>" in the message to the person.
|
||||
Keep the message free of tokens and internal addresses.
|
||||
|
||||
### For add-on authors: the account of a token
|
||||
|
||||
The op `me` may return `accountId`: a stable name of the upstream account, equal for every
|
||||
token of that account and at most 128 bytes (Slack returns the workspace and bot user, Telegram
|
||||
the bot ID, Discord the application ID). The host compares it within one kind. Leave it empty
|
||||
when the platform cannot tell. The SDK's `connector.Bot` has an `AccountID` field for it.
|
||||
|
||||
### Other connectors
|
||||
|
||||
Telegram, Slack and MCP servers are built. Discord, IRC, Matrix, email, GitHub and Twitter
|
||||
|
||||
@@ -86,6 +86,8 @@ const (
|
||||
MaxConnectorMedia = 8
|
||||
MaxPollSeconds = 25
|
||||
MaxConnectorSender = 64 // the width of the sender column of the stores
|
||||
// MaxAccountIDBytes bounds ConnectorResponse.AccountID.
|
||||
MaxAccountIDBytes = 128
|
||||
// MaxConnectorFileName is the longest file name, in bytes, that the op
|
||||
// send_file takes.
|
||||
MaxConnectorFileName = 128
|
||||
@@ -476,6 +478,13 @@ type ConnectorResponse struct {
|
||||
// me
|
||||
BotID string `json:"botId,omitempty"`
|
||||
BotUsername string `json:"botUsername,omitempty"`
|
||||
// AccountID answers the op me: a stable name of the upstream account that
|
||||
// the token belongs to, the same for every token pair of that account (for
|
||||
// example the workspace and the bot user together). The host compares it,
|
||||
// within one kind, to find two connections that are the same bot. It is
|
||||
// optional: an add-on that cannot tell leaves it empty, and its connections
|
||||
// are never compared. At most MaxAccountIDBytes.
|
||||
AccountID string `json:"accountId,omitempty"`
|
||||
// Received answers the op media: the file was written by handle. The host
|
||||
// adds Size and SHA256 from its record.
|
||||
Received *ConnectorReceived `json:"received,omitempty"`
|
||||
|
||||
Reference in new issue
Block a user