Merge 92bbfe1b083b39cbbf978170f8c422147695f2e4 into 5e417b44e1540f528d2ae63e3e20229a902d1db2

This commit is contained in:
VivoKrei 2026-03-20 19:38:37 -07:00 committed by GitHub
commit b4c16d4ec3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 477 additions and 2 deletions

View File

@ -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,

View 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",
]);
});
});

View 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",
]);
});
});

View File

@ -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,
};

View File

@ -151,6 +151,7 @@ export type NextcloudTalkInboundMessage = {
senderName: string;
text: string;
mediaType: string;
mediaUrls?: string[];
timestamp: number;
isGroupChat: boolean;
};