diff --git a/pkg/addons/contract/connector.go b/pkg/addons/contract/connector.go index 0232931cd7..5e86d642d9 100644 --- a/pkg/addons/contract/connector.go +++ b/pkg/addons/contract/connector.go @@ -23,8 +23,9 @@ import ( // the add-on calls the host operation ConnectorInboundOp. // - Outbound. The host calls "/connector" with the ops "send", "edit", // "typing", "send_file" and "me", "media" to read a file a message -// carries, and "draft" for a kind that declares drafts. The host passes -// the token on every call; the add-on never stores it. +// carries, "draft" for a kind that declares drafts and "history" for a +// kind that declares history. The host passes the token on every call; +// the add-on never stores it. // Delivery says how messages reach the host. const ( @@ -55,6 +56,10 @@ const ( // that is not a message yet. The platform replaces it when the answer // is sent. Only a kind with Drafts serves it. ConnectorOpDraft = "draft" + // ConnectorOpHistory returns the earlier messages of the thread + // ConnectorRequest.Thread of Chat, oldest first. Only a kind with + // History serves it; the host never calls it for another kind. + ConnectorOpHistory = "history" ) // Codes of a ConnectorError. @@ -89,6 +94,17 @@ const ( // MinPreviewMs and MaxPreviewMs bound ConnectorKind.PreviewMs. MinPreviewMs = 250 MaxPreviewMs = 10000 + // MinHistoryAPI is the first manifest API version that knows the History + // ability of a connector kind. + MinHistoryAPI uint64 = 2 + // DefaultHistoryMessages is how many messages the host asks the op + // history for when the kind declares no limit of its own. + DefaultHistoryMessages = 30 + // MaxHistoryMessages bounds ConnectorHistory.MaxMessages and the Limit of + // a history request. + MaxHistoryMessages = 200 + // MaxHistoryMedia is how many file names one history message carries. + MaxHistoryMedia = 8 ) // ConnectorKind is one kind of binding an add-on serves, such as telegram. @@ -114,6 +130,35 @@ type ConnectorKind struct { // of an answer (an edit or a draft) of one binding. Zero: the host's // default, one second. Else MinPreviewMs to MaxPreviewMs. PreviewMs int `json:"previewMs,omitempty"` + // History declares that the add-on serves the op history. A kind without + // it keeps the behaviour of a platform that cannot be asked for old + // messages. It needs manifest api MinHistoryAPI. + History *ConnectorHistory `json:"history,omitempty"` +} + +// ConnectorHistory is the ability to read old messages of a platform. +type ConnectorHistory struct { + // Thread says that the op history returns the earlier messages of a + // thread. It must be true: it is the only ability so far. + Thread bool `json:"thread"` + // MaxMessages is the most messages one call returns. Zero: the host + // asks for DefaultHistoryMessages. At most MaxHistoryMessages. + MaxMessages int `json:"maxMessages,omitempty"` +} + +// ServesThreadHistory reports whether the kind answers the op history for threads. +func (k ConnectorKind) ServesThreadHistory() bool { return k.History != nil && k.History.Thread } + +// HistoryLimit is how many messages the host asks for at most: the limit it +// wants (zero: DefaultHistoryMessages), cut to what the kind declares. +func (k ConnectorKind) HistoryLimit(want int) int { + if want <= 0 || want > MaxHistoryMessages { + want = DefaultHistoryMessages + } + if k.History != nil && k.History.MaxMessages > 0 && k.History.MaxMessages < want { + want = k.History.MaxMessages + } + return want } // ConnectorCapability is the manifest field capabilities.connector. @@ -121,7 +166,7 @@ type ConnectorCapability struct { Kinds []ConnectorKind `json:"kinds"` } -func validateConnector(c *ConnectorCapability, ui *UI) error { +func validateConnector(c *ConnectorCapability, ui *UI, api VersionRange) error { if c == nil { return nil } @@ -143,6 +188,14 @@ func validateConnector(c *ConnectorCapability, ui *UI) error { if k.PreviewMs != 0 && (k.PreviewMs < MinPreviewMs || k.PreviewMs > MaxPreviewMs) { return fmt.Errorf("connector previewMs must be 0 or %d to %d", MinPreviewMs, MaxPreviewMs) } + if h := k.History; h != nil { + if api.Max < MinHistoryAPI { + return fmt.Errorf("connector history needs api %d", MinHistoryAPI) + } + if !h.Thread || h.MaxMessages < 0 || h.MaxMessages > MaxHistoryMessages { + return fmt.Errorf("connector history needs thread and maxMessages 0 to %d", MaxHistoryMessages) + } + } if k.Page != "" { ok := false if ui != nil { @@ -232,6 +285,12 @@ type ConnectorRequest struct { DraftID int64 `json:"draftId,omitempty"` // media: one reference of ConnectorMessage.Media. Media string `json:"media,omitempty"` + // history: Chat and Thread name the thread. Limit is the most messages to + // return (1 to MaxHistoryMessages): the newest ones before Before. Before + // is the ID of the message that the host handles now; it and the later + // messages are not returned. + Limit int `json:"limit,omitempty"` + Before string `json:"before,omitempty"` // send_file: the file. Its name is SafeFileName of itself and its data // is 1 to MaxConnectorFileBytes long. File *ConnectorFile `json:"file,omitempty"` @@ -268,6 +327,29 @@ type ConnectorResponse struct { BotUsername string `json:"botUsername,omitempty"` // media File *ConnectorFile `json:"file,omitempty"` + // history: the messages, oldest first, at most the Limit asked for. + // Truncated says that older messages exist that are not returned. + Messages []ConnectorHistoryMessage `json:"messages,omitempty"` + Truncated bool `json:"truncated,omitempty"` +} + +// ConnectorHistoryMessage is one earlier message of a thread. The text is +// what other people wrote: the host treats it as untrusted content. +type ConnectorHistoryMessage struct { + MessageID string `json:"messageId,omitempty"` + SenderID string `json:"senderId,omitempty"` + SenderName string `json:"senderName,omitempty"` + SenderUsername string `json:"senderUsername,omitempty"` + Text string `json:"text,omitempty"` + // TS is the time of the message, when the platform tells it. + TS time.Time `json:"ts,omitzero"` + // Own is true for a message of the bot itself. + Own bool `json:"own,omitempty"` + // Media is how many files the message carries; MediaNames names them + // when the platform does, at most MaxHistoryMedia. The bytes are never + // part of a history. + Media int `json:"media,omitempty"` + MediaNames []string `json:"mediaNames,omitempty"` } // ConnectorRuntime is a runtime that serves the connector route. An add-on diff --git a/pkg/addons/contract/connector_test.go b/pkg/addons/contract/connector_test.go index 5de4e50b9a..41f70636fc 100644 --- a/pkg/addons/contract/connector_test.go +++ b/pkg/addons/contract/connector_test.go @@ -42,6 +42,39 @@ var _ = Describe("the connector capability", func() { m.Capabilities.Connector.Kinds[0].Page = "" Expect(ValidateManifest(m, host())).To(Succeed()) }) + Describe("the history ability", func() { + kind := func() *ConnectorKind { return &m.Capabilities.Connector.Kinds[0] } + BeforeEach(func() { m.API = VersionRange{Min: 1, Max: 2} }) + + It("accepts a thread history with the manifest api 2", func() { + kind().History = &ConnectorHistory{Thread: true, MaxMessages: 50} + Expect(ValidateManifest(m, host())).To(Succeed()) + Expect(kind().ServesThreadHistory()).To(BeTrue()) + }) + It("refuses it under the manifest api 1", func() { + m.API = VersionRange{Min: 1, Max: 1} + kind().History = &ConnectorHistory{Thread: true} + Expect(ValidateManifest(m, host())).NotTo(Succeed()) + }) + It("refuses an ability that says nothing and a limit out of bounds", func() { + kind().History = &ConnectorHistory{} + Expect(ValidateManifest(m, host())).NotTo(Succeed()) + kind().History = &ConnectorHistory{Thread: true, MaxMessages: MaxHistoryMessages + 1} + Expect(ValidateManifest(m, host())).NotTo(Succeed()) + kind().History = &ConnectorHistory{Thread: true, MaxMessages: -1} + Expect(ValidateManifest(m, host())).NotTo(Succeed()) + }) + It("serves nothing for a kind that declares nothing", func() { + Expect(kind().ServesThreadHistory()).To(BeFalse()) + }) + It("cuts the limit to what the kind declares", func() { + k := ConnectorKind{History: &ConnectorHistory{Thread: true, MaxMessages: 10}} + Expect(k.HistoryLimit(0)).To(Equal(10)) + Expect(k.HistoryLimit(5)).To(Equal(5)) + Expect(k.HistoryLimit(500)).To(Equal(10)) + Expect(ConnectorKind{History: &ConnectorHistory{Thread: true}}.HistoryLimit(0)).To(Equal(DefaultHistoryMessages)) + }) + }) DescribeTable("refuses", func(edit func(*ConnectorCapability)) { edit(m.Capabilities.Connector) diff --git a/pkg/addons/contract/manifest.go b/pkg/addons/contract/manifest.go index e15ea3deaf..0a7431d4c7 100644 --- a/pkg/addons/contract/manifest.go +++ b/pkg/addons/contract/manifest.go @@ -403,7 +403,7 @@ func ValidateManifest(m Manifest, h HostCompatibility) error { if e := validateResourceList(c, m.API); e != nil { return e } - if e := validateConnector(c.Connector, c.UI); e != nil { + if e := validateConnector(c.Connector, c.UI, m.API); e != nil { return e } if e := validateSkillDefinitions(m.ID, c.SkillDefinitions); e != nil {