Merge 92bbfe1b083b39cbbf978170f8c422147695f2e4 into 5e417b44e1540f528d2ae63e3e20229a902d1db2
This commit is contained in:
commit
b4c16d4ec3
@ -64,7 +64,8 @@ export async function handleNextcloudTalkInbound(params: {
|
||||
});
|
||||
|
||||
const rawBody = message.text?.trim() ?? "";
|
||||
if (!rawBody) {
|
||||
const hasMedia = (message.mediaUrls?.length ?? 0) > 0;
|
||||
if (!rawBody && !hasMedia) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -273,6 +274,7 @@ export async function handleNextcloudTalkInbound(params: {
|
||||
Provider: CHANNEL_ID,
|
||||
Surface: CHANNEL_ID,
|
||||
WasMentioned: isGroup ? wasMentioned : undefined,
|
||||
MediaUrls: message.mediaUrls,
|
||||
MessageSid: message.messageId,
|
||||
Timestamp: message.timestamp,
|
||||
OriginatingChannel: CHANNEL_ID,
|
||||
|
||||
232
extensions/nextcloud-talk/src/monitor.attachment.test.ts
Normal file
232
extensions/nextcloud-talk/src/monitor.attachment.test.ts
Normal file
@ -0,0 +1,232 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { startWebhookServer } from "./monitor.test-harness.js";
|
||||
import { generateNextcloudTalkSignature } from "./signature.js";
|
||||
import type { NextcloudTalkInboundMessage } from "./types.js";
|
||||
|
||||
const SECRET = "nextcloud-secret"; // pragma: allowlist secret
|
||||
|
||||
function createSignedRequest(objectOverrides: Record<string, unknown>) {
|
||||
const payload = {
|
||||
type: "Create",
|
||||
actor: { type: "Person", id: "alice", name: "Alice" },
|
||||
object: {
|
||||
type: "Note",
|
||||
id: "msg-1",
|
||||
name: "",
|
||||
content: "",
|
||||
mediaType: "text/plain",
|
||||
...objectOverrides,
|
||||
},
|
||||
target: { type: "Collection", id: "room-1", name: "Room 1" },
|
||||
};
|
||||
const body = JSON.stringify(payload);
|
||||
const { random, signature } = generateNextcloudTalkSignature({ body, secret: SECRET });
|
||||
return {
|
||||
body,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-nextcloud-talk-random": random,
|
||||
"x-nextcloud-talk-signature": signature,
|
||||
"x-nextcloud-talk-backend": "https://nextcloud.example",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("nextcloud-talk webhook attachment parsing", () => {
|
||||
it("parses file share rich object into displayText and mediaUrls", async () => {
|
||||
const received: NextcloudTalkInboundMessage[] = [];
|
||||
const harness = await startWebhookServer({
|
||||
path: "/attachment-test",
|
||||
onMessage: async (msg) => {
|
||||
received.push(msg);
|
||||
},
|
||||
});
|
||||
|
||||
const richContent = JSON.stringify({
|
||||
message: "{file}",
|
||||
parameters: {
|
||||
file: {
|
||||
type: "file",
|
||||
id: "12345",
|
||||
name: "IMG_123.jpg",
|
||||
path: "Talk/IMG_123.jpg",
|
||||
link: "https://cloud.example.com/f/12345",
|
||||
mimetype: "image/jpeg",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { body, headers } = createSignedRequest({ content: richContent });
|
||||
const res = await fetch(harness.webhookUrl, { method: "POST", headers, body });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(received).toHaveLength(1);
|
||||
expect(received[0].text).toBe("IMG_123.jpg");
|
||||
expect(received[0].mediaUrls).toEqual(["https://cloud.example.com/f/12345"]);
|
||||
});
|
||||
|
||||
it("returns undefined mediaUrls for regular text messages", async () => {
|
||||
const received: NextcloudTalkInboundMessage[] = [];
|
||||
const harness = await startWebhookServer({
|
||||
path: "/attachment-text",
|
||||
onMessage: async (msg) => {
|
||||
received.push(msg);
|
||||
},
|
||||
});
|
||||
|
||||
const { body, headers } = createSignedRequest({
|
||||
name: "hello world",
|
||||
content: "hello world",
|
||||
});
|
||||
const res = await fetch(harness.webhookUrl, { method: "POST", headers, body });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(received).toHaveLength(1);
|
||||
expect(received[0].text).toBe("hello world");
|
||||
expect(received[0].mediaUrls).toBeUndefined();
|
||||
});
|
||||
|
||||
it("falls back to raw content for malformed JSON in object.content", async () => {
|
||||
const received: NextcloudTalkInboundMessage[] = [];
|
||||
const harness = await startWebhookServer({
|
||||
path: "/attachment-malformed",
|
||||
onMessage: async (msg) => {
|
||||
received.push(msg);
|
||||
},
|
||||
});
|
||||
|
||||
const { body, headers } = createSignedRequest({ content: "not json {{{" });
|
||||
const res = await fetch(harness.webhookUrl, { method: "POST", headers, body });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(received).toHaveLength(1);
|
||||
expect(received[0].text).toBe("not json {{{");
|
||||
expect(received[0].mediaUrls).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses object.name as fallback when object.content is empty", async () => {
|
||||
const received: NextcloudTalkInboundMessage[] = [];
|
||||
const harness = await startWebhookServer({
|
||||
path: "/attachment-name-fallback",
|
||||
onMessage: async (msg) => {
|
||||
received.push(msg);
|
||||
},
|
||||
});
|
||||
|
||||
const { body, headers } = createSignedRequest({
|
||||
content: "",
|
||||
name: "fallback text",
|
||||
});
|
||||
const res = await fetch(harness.webhookUrl, { method: "POST", headers, body });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(received).toHaveLength(1);
|
||||
expect(received[0].text).toBe("fallback text");
|
||||
expect(received[0].mediaUrls).toBeUndefined();
|
||||
});
|
||||
|
||||
it("resolves displayText for non-file rich objects (mentions) without populating mediaUrls", async () => {
|
||||
const received: NextcloudTalkInboundMessage[] = [];
|
||||
const harness = await startWebhookServer({
|
||||
path: "/attachment-mention",
|
||||
onMessage: async (msg) => {
|
||||
received.push(msg);
|
||||
},
|
||||
});
|
||||
|
||||
const mentionContent = JSON.stringify({
|
||||
message: "{mention} joined the room",
|
||||
parameters: {
|
||||
mention: {
|
||||
type: "user",
|
||||
id: "alice",
|
||||
name: "Alice",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { body, headers } = createSignedRequest({ content: mentionContent });
|
||||
const res = await fetch(harness.webhookUrl, { method: "POST", headers, body });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(received).toHaveLength(1);
|
||||
expect(received[0].text).toBe("Alice joined the room");
|
||||
expect(received[0].mediaUrls).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects non-http(s) URLs in file parameters", async () => {
|
||||
const received: NextcloudTalkInboundMessage[] = [];
|
||||
const harness = await startWebhookServer({
|
||||
path: "/attachment-bad-url",
|
||||
onMessage: async (msg) => {
|
||||
received.push(msg);
|
||||
},
|
||||
});
|
||||
|
||||
const maliciousContent = JSON.stringify({
|
||||
message: "{file}",
|
||||
parameters: {
|
||||
file: {
|
||||
type: "file",
|
||||
id: "1",
|
||||
name: "passwd",
|
||||
path: "Talk/passwd",
|
||||
link: "file:///etc/passwd",
|
||||
mimetype: "text/plain",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { body, headers } = createSignedRequest({ content: maliciousContent });
|
||||
const res = await fetch(harness.webhookUrl, { method: "POST", headers, body });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(received).toHaveLength(1);
|
||||
// displayText still resolves but malicious URL is not in mediaUrls
|
||||
expect(received[0].text).toBe("passwd");
|
||||
expect(received[0].mediaUrls).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles multiple file attachments in a single message", async () => {
|
||||
const received: NextcloudTalkInboundMessage[] = [];
|
||||
const harness = await startWebhookServer({
|
||||
path: "/attachment-multi",
|
||||
onMessage: async (msg) => {
|
||||
received.push(msg);
|
||||
},
|
||||
});
|
||||
|
||||
const multiContent = JSON.stringify({
|
||||
message: "{file1} and {file2}",
|
||||
parameters: {
|
||||
file1: {
|
||||
type: "file",
|
||||
id: "1",
|
||||
name: "photo1.jpg",
|
||||
path: "Talk/photo1.jpg",
|
||||
link: "https://cloud.example.com/f/1",
|
||||
mimetype: "image/jpeg",
|
||||
},
|
||||
file2: {
|
||||
type: "file",
|
||||
id: "2",
|
||||
name: "photo2.jpg",
|
||||
path: "Talk/photo2.jpg",
|
||||
link: "https://cloud.example.com/f/2",
|
||||
mimetype: "image/jpeg",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { body, headers } = createSignedRequest({ content: multiContent });
|
||||
const res = await fetch(harness.webhookUrl, { method: "POST", headers, body });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(received).toHaveLength(1);
|
||||
expect(received[0].text).toBe("photo1.jpg and photo2.jpg");
|
||||
expect(received[0].mediaUrls).toEqual([
|
||||
"https://cloud.example.com/f/1",
|
||||
"https://cloud.example.com/f/2",
|
||||
]);
|
||||
});
|
||||
});
|
||||
168
extensions/nextcloud-talk/src/monitor.live-integration.test.ts
Normal file
168
extensions/nextcloud-talk/src/monitor.live-integration.test.ts
Normal file
@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Live integration test: fire real signed NC Talk webhook payloads at the
|
||||
* patched server and assert the full parsing pipeline works end-to-end.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { startWebhookServer } from "./monitor.test-harness.js";
|
||||
import { generateNextcloudTalkSignature } from "./signature.js";
|
||||
import type { NextcloudTalkInboundMessage } from "./types.js";
|
||||
|
||||
const SECRET = "test-secret-live"; // pragma: allowlist secret
|
||||
|
||||
function signed(objectOverrides: Record<string, unknown>) {
|
||||
const payload = {
|
||||
type: "Create",
|
||||
actor: { type: "Person", id: "testuser", name: "Alice" },
|
||||
object: {
|
||||
type: "Note",
|
||||
id: "msg-1",
|
||||
name: "",
|
||||
content: "",
|
||||
mediaType: "text/plain",
|
||||
...objectOverrides,
|
||||
},
|
||||
target: { type: "Collection", id: "room-abc", name: "Test Room" },
|
||||
};
|
||||
const body = JSON.stringify(payload);
|
||||
const { random, signature } = generateNextcloudTalkSignature({ body, secret: SECRET });
|
||||
return {
|
||||
body,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-nextcloud-talk-random": random,
|
||||
"x-nextcloud-talk-signature": signature,
|
||||
"x-nextcloud-talk-backend": "https://nextcloud.example",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("live integration: NC Talk attachment parsing pipeline", () => {
|
||||
it("file share: text=filename, mediaUrls=[https link]", async () => {
|
||||
const msgs: NextcloudTalkInboundMessage[] = [];
|
||||
const h = await startWebhookServer({
|
||||
path: "/live-1",
|
||||
secret: SECRET,
|
||||
onMessage: async (m) => {
|
||||
msgs.push(m);
|
||||
},
|
||||
});
|
||||
const content = JSON.stringify({
|
||||
message: "{file}",
|
||||
parameters: {
|
||||
file: {
|
||||
type: "file",
|
||||
id: "42",
|
||||
name: "chart.png",
|
||||
path: "Talk/chart.png",
|
||||
link: "https://cloud.example.com/f/42",
|
||||
mimetype: "image/png",
|
||||
},
|
||||
},
|
||||
});
|
||||
const { body, headers } = signed({ content });
|
||||
await fetch(h.webhookUrl, { method: "POST", headers, body });
|
||||
expect(msgs[0].text).toBe("chart.png");
|
||||
expect(msgs[0].mediaUrls).toEqual(["https://cloud.example.com/f/42"]);
|
||||
});
|
||||
|
||||
it("mention: text resolved, mediaUrls=undefined", async () => {
|
||||
const msgs: NextcloudTalkInboundMessage[] = [];
|
||||
const h = await startWebhookServer({
|
||||
path: "/live-2",
|
||||
secret: SECRET,
|
||||
onMessage: async (m) => {
|
||||
msgs.push(m);
|
||||
},
|
||||
});
|
||||
const content = JSON.stringify({
|
||||
message: "{mention} sent a message",
|
||||
parameters: { mention: { type: "user", id: "testuser", name: "Alice" } },
|
||||
});
|
||||
const { body, headers } = signed({ content });
|
||||
await fetch(h.webhookUrl, { method: "POST", headers, body });
|
||||
expect(msgs[0].text).toBe("Alice sent a message");
|
||||
expect(msgs[0].mediaUrls).toBeUndefined();
|
||||
});
|
||||
|
||||
it("plain text: no regression, mediaUrls=undefined", async () => {
|
||||
const msgs: NextcloudTalkInboundMessage[] = [];
|
||||
const h = await startWebhookServer({
|
||||
path: "/live-3",
|
||||
secret: SECRET,
|
||||
onMessage: async (m) => {
|
||||
msgs.push(m);
|
||||
},
|
||||
});
|
||||
const { body, headers } = signed({ content: "Hello from Alice", name: "Hello from Alice" });
|
||||
await fetch(h.webhookUrl, { method: "POST", headers, body });
|
||||
expect(msgs[0].text).toBe("Hello from Alice");
|
||||
expect(msgs[0].mediaUrls).toBeUndefined();
|
||||
});
|
||||
|
||||
it("malicious file:// URL: text resolved, mediaUrls=undefined", async () => {
|
||||
const msgs: NextcloudTalkInboundMessage[] = [];
|
||||
const h = await startWebhookServer({
|
||||
path: "/live-4",
|
||||
secret: SECRET,
|
||||
onMessage: async (m) => {
|
||||
msgs.push(m);
|
||||
},
|
||||
});
|
||||
const content = JSON.stringify({
|
||||
message: "{file}",
|
||||
parameters: {
|
||||
file: {
|
||||
type: "file",
|
||||
id: "1",
|
||||
name: "passwd",
|
||||
path: "Talk/passwd",
|
||||
link: "file:///etc/passwd",
|
||||
mimetype: "text/plain",
|
||||
},
|
||||
},
|
||||
});
|
||||
const { body, headers } = signed({ content });
|
||||
await fetch(h.webhookUrl, { method: "POST", headers, body });
|
||||
expect(msgs[0].text).toBe("passwd");
|
||||
expect(msgs[0].mediaUrls).toBeUndefined();
|
||||
});
|
||||
|
||||
it("multi-file: both links in mediaUrls, text has both names", async () => {
|
||||
const msgs: NextcloudTalkInboundMessage[] = [];
|
||||
const h = await startWebhookServer({
|
||||
path: "/live-5",
|
||||
secret: SECRET,
|
||||
onMessage: async (m) => {
|
||||
msgs.push(m);
|
||||
},
|
||||
});
|
||||
const content = JSON.stringify({
|
||||
message: "{f1} and {f2}",
|
||||
parameters: {
|
||||
f1: {
|
||||
type: "file",
|
||||
id: "1",
|
||||
name: "photo1.jpg",
|
||||
path: "Talk/photo1.jpg",
|
||||
link: "https://cloud.example.com/f/1",
|
||||
mimetype: "image/jpeg",
|
||||
},
|
||||
f2: {
|
||||
type: "file",
|
||||
id: "2",
|
||||
name: "photo2.jpg",
|
||||
path: "Talk/photo2.jpg",
|
||||
link: "https://cloud.example.com/f/2",
|
||||
mimetype: "image/jpeg",
|
||||
},
|
||||
},
|
||||
});
|
||||
const { body, headers } = signed({ content });
|
||||
await fetch(h.webhookUrl, { method: "POST", headers, body });
|
||||
expect(msgs[0].text).toBe("photo1.jpg and photo2.jpg");
|
||||
expect(msgs[0].mediaUrls).toEqual([
|
||||
"https://cloud.example.com/f/1",
|
||||
"https://cloud.example.com/f/2",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@ -149,11 +149,82 @@ function decodeWebhookCreateMessage(params: {
|
||||
return { kind: "message", message: payloadToInboundMessage(payload) };
|
||||
}
|
||||
|
||||
interface RichObjectFileParam {
|
||||
type: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
path?: string;
|
||||
link?: string;
|
||||
mimetype?: string;
|
||||
}
|
||||
|
||||
interface RichObjectContent {
|
||||
message: string;
|
||||
parameters: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function parseRichObjectContent(
|
||||
content: string,
|
||||
): { displayText: string; mediaUrls?: string[] } | null {
|
||||
let parsed: RichObjectContent;
|
||||
try {
|
||||
const raw = JSON.parse(content) as unknown;
|
||||
if (
|
||||
typeof raw !== "object" ||
|
||||
raw === null ||
|
||||
typeof (raw as Record<string, unknown>).message !== "string" ||
|
||||
typeof (raw as Record<string, unknown>).parameters !== "object" ||
|
||||
(raw as Record<string, unknown>).parameters === null
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
parsed = raw as RichObjectContent;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mediaUrls: string[] = [];
|
||||
let displayText = parsed.message;
|
||||
let hadPlaceholders = false;
|
||||
|
||||
for (const [key, value] of Object.entries(parsed.parameters)) {
|
||||
const param = value as RichObjectFileParam;
|
||||
// Only accept http(s) URLs to prevent file:// or javascript: injection
|
||||
if (
|
||||
param?.type === "file" &&
|
||||
typeof param.link === "string" &&
|
||||
/^https?:\/\//i.test(param.link)
|
||||
) {
|
||||
mediaUrls.push(param.link);
|
||||
}
|
||||
const placeholder = `{${key}}`;
|
||||
if (displayText.includes(placeholder)) {
|
||||
hadPlaceholders = true;
|
||||
const fileName = typeof param?.name === "string" && param.name ? param.name : key;
|
||||
// Use replaceAll to handle duplicate placeholders
|
||||
displayText = displayText.replaceAll(placeholder, fileName);
|
||||
}
|
||||
}
|
||||
|
||||
// Return if we have files OR if we resolved any placeholders (non-file rich objects
|
||||
// like mentions, deck cards, polls — don't discard their resolved displayText)
|
||||
if (mediaUrls.length === 0 && !hadPlaceholders) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
displayText: displayText.trim(),
|
||||
mediaUrls: mediaUrls.length > 0 ? mediaUrls : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function payloadToInboundMessage(
|
||||
payload: NextcloudTalkWebhookPayload,
|
||||
): NextcloudTalkInboundMessage {
|
||||
// Payload doesn't indicate DM vs room; mark as group and let inbound handler refine.
|
||||
const isGroupChat = true;
|
||||
const rawContent = payload.object.content || payload.object.name || "";
|
||||
const richObject = rawContent ? parseRichObjectContent(rawContent) : null;
|
||||
|
||||
return {
|
||||
messageId: String(payload.object.id),
|
||||
@ -161,8 +232,9 @@ function payloadToInboundMessage(
|
||||
roomName: payload.target.name,
|
||||
senderId: payload.actor.id,
|
||||
senderName: payload.actor.name ?? "",
|
||||
text: payload.object.content || payload.object.name || "",
|
||||
text: richObject?.displayText ?? rawContent,
|
||||
mediaType: payload.object.mediaType || "text/plain",
|
||||
mediaUrls: richObject?.mediaUrls,
|
||||
timestamp: Date.now(),
|
||||
isGroupChat,
|
||||
};
|
||||
|
||||
@ -151,6 +151,7 @@ export type NextcloudTalkInboundMessage = {
|
||||
senderName: string;
|
||||
text: string;
|
||||
mediaType: string;
|
||||
mediaUrls?: string[];
|
||||
timestamp: number;
|
||||
isGroupChat: boolean;
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user