Skip to content

Commit 4e2bf40

Browse files
authored
Merge branch 'master' into feat/escalation-expiration
2 parents e078680 + ee2f184 commit 4e2bf40

6 files changed

Lines changed: 227 additions & 62 deletions

File tree

‎packages/server/src/api/controllers/webhook/ms-teams.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,7 @@ const createTeamsMessageHandler = ({
445445
export async function MSTeamsWebhook(
446446
ctx: Ctx<unknown, unknown, { instance: string; agentId: string }>
447447
) {
448+
let serviceUrl: string | undefined
448449
await runChatWebhook({
449450
ctx,
450451
providerName: "Teams",
@@ -457,7 +458,7 @@ export async function MSTeamsWebhook(
457458
) {
458459
throw new HTTPError("Missing Microsoft Teams service URL", 400)
459460
}
460-
validateMSTeamsServiceUrl(body.serviceUrl)
461+
serviceUrl = validateMSTeamsServiceUrl(body.serviceUrl)
461462
},
462463
createWebhookHandler: async ({ workspaceId, agentId }) => {
463464
const {
@@ -488,6 +489,8 @@ export async function MSTeamsWebhook(
488489
appPassword: integration.appPassword,
489490
appTenantId: integration.tenantId,
490491
appType: "SingleTenant",
492+
// Card posts use the adapter URL; DM streams use the activity URL.
493+
apiUrl: serviceUrl,
491494
}),
492495
},
493496
state: await getTeamsState(),

‎packages/server/src/api/controllers/webhook/runChatWebhook.ts‎

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,11 @@ export const runChatWebhook = async ({
3131
return
3232
}
3333

34-
let handleWebhook: (request: Request) => Promise<Response>
34+
const rawBody = await readRawBody(ctx.req)
3535
try {
36-
handleWebhook = await createWebhookHandler({
37-
workspaceId: prodWorkspaceId,
38-
agentId: ctx.params.agentId,
39-
})
36+
if (ctx.get("authorization")) {
37+
validateBody?.(tryParseJson(rawBody))
38+
}
4039
} catch (error) {
4140
if (error instanceof HTTPError) {
4241
ctx.status = error.status
@@ -46,11 +45,12 @@ export const runChatWebhook = async ({
4645
throw error
4746
}
4847

49-
const rawBody = await readRawBody(ctx.req)
48+
let handleWebhook: (request: Request) => Promise<Response>
5049
try {
51-
if (ctx.get("authorization")) {
52-
validateBody?.(tryParseJson(rawBody))
53-
}
50+
handleWebhook = await createWebhookHandler({
51+
workspaceId: prodWorkspaceId,
52+
agentId: ctx.params.agentId,
53+
})
5454
} catch (error) {
5555
if (error instanceof HTTPError) {
5656
ctx.status = error.status

‎packages/server/src/api/routes/tests/ai/agentTeams.spec.ts‎

Lines changed: 81 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ import os from "os"
6868
import path from "path"
6969

7070
import extract from "extract-zip"
71+
import { createTeamsAdapter } from "@chat-adapter/teams"
7172
import { context, docIds } from "@budibase/backend-core"
7273
import { generator } from "@budibase/backend-core/tests"
7374
import { ChatCommands } from "@budibase/shared-core"
@@ -87,6 +88,7 @@ import { webhookChat } from "../../../controllers/ai/chatConversations"
8788
const { getMockChatOptions, resetMockChatState, setMockPostEphemeralResult } =
8889
jest.requireActual("chat") as ChatMockModule
8990
const mockedWebhookChat = webhookChat as jest.MockedFunction<typeof webhookChat>
91+
const mockedCreateTeamsAdapter = jest.mocked(createTeamsAdapter)
9092
const mockedGetFileUrlForAgent = jest.mocked(sdk.ai.rag.getFileUrlForAgent)
9193
const TEAMS_APP_ID = generator.guid()
9294

@@ -110,6 +112,7 @@ describe("agent teams integration provisioning", () => {
110112
"test-config"
111113
)
112114
mockedWebhookChat.mockClear()
115+
mockedCreateTeamsAdapter.mockClear()
113116
mockedGetFileUrlForAgent.mockReset()
114117
resetMockChatState()
115118
})
@@ -524,6 +527,7 @@ describe("agent teams integration provisioning", () => {
524527

525528
expect(response.body.error).toEqual("Invalid Microsoft Teams service URL")
526529
expect(mockedWebhookChat).not.toHaveBeenCalled()
530+
expect(mockedCreateTeamsAdapter).not.toHaveBeenCalled()
527531
})
528532

529533
it(`returns a private link prompt for ${ChatCommands.LINK} and /${ChatCommands.LINK} commands`, async () => {
@@ -800,56 +804,86 @@ describe("agent teams integration provisioning", () => {
800804
expect(mockedWebhookChat).toHaveBeenCalledTimes(1)
801805
})
802806

803-
it("appends downloadable RAG source links to Teams personal replies", async () => {
804-
mockedGetFileUrlForAgent.mockResolvedValue(
805-
"/files/signed/prod-budi-app-assets/source.pdf"
806-
)
807-
mockedWebhookChat.mockResolvedValueOnce({
808-
messages: [
809-
{
810-
id: "assistant-1",
811-
role: "assistant",
812-
parts: [{ type: "text", text: "Answer with sources" }],
813-
},
814-
] as any,
815-
assistantText: "Answer with sources",
816-
ragSources: [
817-
{
818-
sourceId: "source-1",
819-
fileId: "file-1",
820-
filename: "Source [One]\n@Draft.pdf",
821-
},
822-
],
823-
title: "Mock conversation",
824-
})
807+
it.each([false, true])(
808+
"sends personal reply sources through the activity service URL (streaming: %s)",
809+
async streaming => {
810+
const serviceUrl = new URL(
811+
"/emea/",
812+
DEFAULT_MSTEAMS_SERVICE_URL
813+
).toString()
814+
mockedGetFileUrlForAgent.mockResolvedValue(
815+
"/files/signed/prod-budi-app-assets/source.pdf"
816+
)
817+
mockedWebhookChat.mockImplementationOnce(
818+
async ({ onAssistantStream }) => {
819+
if (streaming) {
820+
async function* sourceAnswerStream() {
821+
yield "Answer with "
822+
yield "sources"
823+
}
824+
await onAssistantStream!(sourceAnswerStream())
825+
}
826+
return {
827+
messages: [
828+
{
829+
id: "assistant-1",
830+
role: "assistant",
831+
parts: [{ type: "text", text: "Answer with sources" }],
832+
},
833+
],
834+
assistantText: "Answer with sources",
835+
allowKnowledgeSourceDownload: true,
836+
ragSources: [
837+
{
838+
sourceId: "source-1",
839+
fileId: "file-1",
840+
filename: "Source [One]\n@Draft.pdf",
841+
},
842+
],
843+
title: "Mock conversation",
844+
}
845+
}
846+
)
825847

826-
const { agent, linkExternalUser } = await setupProvisionedTeamsAgent()
827-
const path = `/api/webhooks/ms-teams/${config.getProdWorkspaceId()}/${agent._id}`
828-
await linkExternalUser("user-1")
848+
const { agent, linkExternalUser } = await setupProvisionedTeamsAgent()
849+
const path = `/api/webhooks/ms-teams/${config.getProdWorkspaceId()}/${agent._id}`
850+
await linkExternalUser("user-1")
829851

830-
const response = await postTeamsMessage({
831-
path,
832-
body: {
833-
id: "activity-rag-personal",
834-
type: "message",
835-
text: "hello teams",
836-
from: { id: "user-1", name: "Teams User" },
837-
conversation: { id: "conversation-1", conversationType: "personal" },
838-
channelData: { tenant: { id: "tenant-1" } },
839-
},
840-
})
852+
const response = await postTeamsMessage({
853+
path,
854+
body: {
855+
id: "activity-rag-personal",
856+
serviceUrl,
857+
type: "message",
858+
text: "hello teams",
859+
from: { id: "user-1", name: "Teams User" },
860+
conversation: {
861+
id: "conversation-1",
862+
conversationType: "personal",
863+
},
864+
channelData: { tenant: { id: "tenant-1" } },
865+
},
866+
})
841867

842-
expect(response.body.messages).toContain("Answer with sources")
843-
const cardMessage = response.body.messages.find((message: string) =>
844-
message.includes("Source One Draft.pdf")
845-
)
846-
expect(cardMessage).toContain('"title":"Sources"')
847-
expect(cardMessage).toContain(
848-
"http://localhost:10000/files/signed/prod-budi-app-assets/source.pdf"
849-
)
850-
expect(mockedWebhookChat).toHaveBeenCalledTimes(1)
851-
expect(mockedGetFileUrlForAgent).toHaveBeenCalledWith(agent._id, "file-1")
852-
})
868+
expect(response.body.messages).toHaveLength(2)
869+
expect(response.body.messages[0]).toEqual("Answer with sources")
870+
expect(mockedCreateTeamsAdapter).toHaveBeenLastCalledWith(
871+
expect.objectContaining({ apiUrl: serviceUrl })
872+
)
873+
const cardMessage = response.body.messages.find((message: string) =>
874+
message.includes("Source One Draft.pdf")
875+
)
876+
expect(cardMessage).toContain('"title":"Sources"')
877+
expect(cardMessage).toContain(
878+
"http://localhost:10000/files/signed/prod-budi-app-assets/source.pdf"
879+
)
880+
expect(mockedWebhookChat).toHaveBeenCalledTimes(1)
881+
expect(mockedGetFileUrlForAgent).toHaveBeenCalledWith(
882+
agent._id,
883+
"file-1"
884+
)
885+
}
886+
)
853887

854888
it("does not append RAG source links to Teams channel replies", async () => {
855889
mockedWebhookChat.mockResolvedValueOnce({

‎packages/server/src/integrations/mongodb.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,17 @@ export class MongoIntegration implements IntegrationBase {
386386
}
387387

388388
async connect() {
389+
const { tlsCAFile, tlsCertificateKeyFile, tlsCRLFile } = this.client.options
390+
if (
391+
!environment.SELF_HOSTED &&
392+
(tlsCAFile !== undefined ||
393+
tlsCertificateKeyFile !== undefined ||
394+
tlsCRLFile !== undefined)
395+
) {
396+
throw new Error(
397+
"MongoDB TLS file options are only supported on self-hosted installations"
398+
)
399+
}
389400
return this.client.connect()
390401
}
391402

‎packages/server/src/integrations/tests/mongodb.spec.ts‎

Lines changed: 119 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
1-
import { buildMongoClientOptions, MongoDBConfig } from "../mongodb"
1+
import { promises as fs } from "fs"
2+
import { MongoClient } from "mongodb"
3+
import {
4+
buildMongoClientOptions,
5+
MongoIntegration,
6+
type MongoDBConfig,
7+
} from "../mongodb"
28
import { withEnv } from "../../environment"
39

410
describe("MongoDB Integration", () => {
511
const baseConfig: MongoDBConfig = {
6-
connectionString: "mongodb://localhost:27017",
12+
connectionString: "mongodb://example.com:27017",
713
db: "test",
814
tlsCertificateKeyFile: "/etc/passwd",
915
tlsCAFile: "/etc/shadow",
@@ -25,4 +31,115 @@ describe("MongoDB Integration", () => {
2531
})
2632
})
2733
})
34+
35+
describe("connection string TLS file options", () => {
36+
const fileOptions = ["tlsCAFile", "tlsCertificateKeyFile", "tlsCRLFile"]
37+
const policyError =
38+
"MongoDB TLS file options are only supported on self-hosted installations"
39+
40+
it.each([
41+
{ name: "mongodb with TLS", scheme: "mongodb", tls: "tls=true&" },
42+
{ name: "mongodb with SSL alias", scheme: "mongodb", tls: "ssl=true&" },
43+
{ name: "mongodb+srv with TLS", scheme: "mongodb+srv", tls: "tls=true&" },
44+
{ name: "mongodb+srv with implicit TLS", scheme: "mongodb+srv", tls: "" },
45+
])(
46+
"rejects normalized $name URI file options on cloud",
47+
async ({ scheme, tls }) => {
48+
await withEnv({ SELF_HOSTED: undefined }, async () => {
49+
for (const option of fileOptions) {
50+
const encoded = [...option]
51+
.map(
52+
character =>
53+
`%${character.charCodeAt(0).toString(16).padStart(2, "0")}`
54+
)
55+
.join("")
56+
for (const key of [option, option.toUpperCase(), encoded]) {
57+
const integration = new MongoIntegration({
58+
...baseConfig,
59+
connectionString: `${scheme}://example.com/test?${tls}${key}=/file.pem`,
60+
})
61+
await expect(integration.connect()).rejects.toThrow(policyError)
62+
}
63+
}
64+
})
65+
}
66+
)
67+
68+
it("returns the usual verification failure without reading a file or connecting", async () => {
69+
const readFile = jest.spyOn(fs, "readFile")
70+
const connect = jest.spyOn(MongoClient.prototype, "connect")
71+
try {
72+
await withEnv({ SELF_HOSTED: undefined }, async () => {
73+
const integration = new MongoIntegration({
74+
...baseConfig,
75+
connectionString:
76+
"mongodb://example.com/?tls=true&tlsCertificateKeyFile=/file.pem",
77+
})
78+
await expect(integration.testConnection()).resolves.toEqual({
79+
connected: false,
80+
error: policyError,
81+
})
82+
})
83+
expect(readFile).not.toHaveBeenCalled()
84+
expect(connect).not.toHaveBeenCalled()
85+
} finally {
86+
readFile.mockRestore()
87+
connect.mockRestore()
88+
}
89+
})
90+
91+
it("preserves ordinary cloud URI settings", async () => {
92+
await withEnv({ SELF_HOSTED: undefined }, async () => {
93+
const integration = new MongoIntegration({
94+
...baseConfig,
95+
connectionString:
96+
"mongodb://example.com/test?tls=true&replicaSet=rs0&appName=tlsCAFile%3D%2Ffile.pem&readPreference=secondary",
97+
})
98+
99+
expect(integration["client"].options).toMatchObject({
100+
tls: true,
101+
dbName: "test",
102+
replicaSet: "rs0",
103+
appName: "tlsCAFile=/file.pem",
104+
readPreference: { mode: "secondary" },
105+
})
106+
expect(integration["client"].options.tlsCAFile).toBeUndefined()
107+
expect(
108+
integration["client"].options.tlsCertificateKeyFile
109+
).toBeUndefined()
110+
expect(integration["client"].options.tlsCRLFile).toBeUndefined()
111+
112+
const connect = jest
113+
.spyOn(integration["client"], "connect")
114+
.mockResolvedValue(integration["client"])
115+
await integration.connect()
116+
expect(connect).toHaveBeenCalledTimes(1)
117+
})
118+
})
119+
120+
it("preserves self-hosted URI TLS file options", async () => {
121+
await withEnv({ SELF_HOSTED: "true" }, async () => {
122+
const integration = new MongoIntegration({
123+
...baseConfig,
124+
tlsCAFile: "",
125+
tlsCertificateKeyFile: "",
126+
connectionString:
127+
"mongodb://example.com/?tls=true&TLSCAFILE=/ca.pem&%74lsCertificateKeyFile=/key.pem&TlScRlFiLe=/crl.pem",
128+
})
129+
130+
expect(integration["client"].options).toMatchObject({
131+
tls: true,
132+
tlsCAFile: "/ca.pem",
133+
tlsCertificateKeyFile: "/key.pem",
134+
tlsCRLFile: "/crl.pem",
135+
})
136+
137+
const connect = jest
138+
.spyOn(integration["client"], "connect")
139+
.mockResolvedValue(integration["client"])
140+
await integration.connect()
141+
expect(connect).toHaveBeenCalledTimes(1)
142+
})
143+
})
144+
})
28145
})

‎yarn.lock‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21868,9 +21868,9 @@ undici@^7.19.0, undici@^7.28.0:
2186821868
integrity sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==
2186921869

2187021870
undici@^8.5.0, undici@^8.9.0:
21871-
version "8.10.0"
21872-
resolved "https://registry.yarnpkg.com/undici/-/undici-8.10.0.tgz#67ed7c4087f0f40fba7bef3a46f2be80572f2473"
21873-
integrity sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==
21871+
version "8.10.2"
21872+
resolved "https://registry.yarnpkg.com/undici/-/undici-8.10.2.tgz#960bcb0b43c267f86910ea7ca408ada843a44bc7"
21873+
integrity sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==
2187421874

2187521875
unicode-canonical-property-names-ecmascript@^2.0.0:
2187621876
version "2.0.1"

0 commit comments

Comments
 (0)