From 388b2bce01f9e904eba5411dcd14894f67d4887d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 17 Jan 2026 04:04:17 +0000 Subject: [PATCH] refactor: add inbound context helpers --- src/auto-reply/reply/inbound-text.test.ts | 19 ++++++++++ src/auto-reply/reply/inbound-text.ts | 7 ++++ src/channels/chat-type.test.ts | 20 ++++++++++ src/channels/chat-type.ts | 11 ++++++ src/channels/conversation-label.test.ts | 32 ++++++++++++++++ src/channels/conversation-label.ts | 46 +++++++++++++++++++++++ src/channels/sender-identity.test.ts | 30 +++++++++++++++ src/channels/sender-identity.ts | 38 +++++++++++++++++++ test/helpers/inbound-contract.ts | 21 +++++++++++ 9 files changed, 224 insertions(+) create mode 100644 src/auto-reply/reply/inbound-text.test.ts create mode 100644 src/auto-reply/reply/inbound-text.ts create mode 100644 src/channels/chat-type.test.ts create mode 100644 src/channels/chat-type.ts create mode 100644 src/channels/conversation-label.test.ts create mode 100644 src/channels/conversation-label.ts create mode 100644 src/channels/sender-identity.test.ts create mode 100644 src/channels/sender-identity.ts create mode 100644 test/helpers/inbound-contract.ts diff --git a/src/auto-reply/reply/inbound-text.test.ts b/src/auto-reply/reply/inbound-text.test.ts new file mode 100644 index 000000000..22dec4c76 --- /dev/null +++ b/src/auto-reply/reply/inbound-text.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; + +import { normalizeInboundTextNewlines } from "./inbound-text.js"; + +describe("normalizeInboundTextNewlines", () => { + it("keeps real newlines", () => { + expect(normalizeInboundTextNewlines("a\nb")).toBe("a\nb"); + }); + + it("normalizes CRLF/CR to LF", () => { + expect(normalizeInboundTextNewlines("a\r\nb")).toBe("a\nb"); + expect(normalizeInboundTextNewlines("a\rb")).toBe("a\nb"); + }); + + it("decodes literal \\\\n to newlines when no real newlines exist", () => { + expect(normalizeInboundTextNewlines("a\\nb")).toBe("a\nb"); + }); +}); + diff --git a/src/auto-reply/reply/inbound-text.ts b/src/auto-reply/reply/inbound-text.ts new file mode 100644 index 000000000..d87b1eb9e --- /dev/null +++ b/src/auto-reply/reply/inbound-text.ts @@ -0,0 +1,7 @@ +export function normalizeInboundTextNewlines(input: string): string { + const text = input.replaceAll("\r\n", "\n").replaceAll("\r", "\n"); + if (text.includes("\n")) return text; + if (!text.includes("\\n")) return text; + return text.replaceAll("\\n", "\n"); +} + diff --git a/src/channels/chat-type.test.ts b/src/channels/chat-type.test.ts new file mode 100644 index 000000000..dff0d1f7e --- /dev/null +++ b/src/channels/chat-type.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; + +import { normalizeChatType } from "./chat-type.js"; + +describe("normalizeChatType", () => { + it("normalizes common inputs", () => { + expect(normalizeChatType("direct")).toBe("direct"); + expect(normalizeChatType("dm")).toBe("direct"); + expect(normalizeChatType("group")).toBe("group"); + expect(normalizeChatType("channel")).toBe("channel"); + expect(normalizeChatType("room")).toBe("channel"); + }); + + it("returns undefined for empty/unknown values", () => { + expect(normalizeChatType(undefined)).toBeUndefined(); + expect(normalizeChatType("")).toBeUndefined(); + expect(normalizeChatType("nope")).toBeUndefined(); + }); +}); + diff --git a/src/channels/chat-type.ts b/src/channels/chat-type.ts new file mode 100644 index 000000000..9e13a0ae5 --- /dev/null +++ b/src/channels/chat-type.ts @@ -0,0 +1,11 @@ +export type NormalizedChatType = "direct" | "group" | "channel"; + +export function normalizeChatType(raw?: string): NormalizedChatType | undefined { + const value = raw?.trim().toLowerCase(); + if (!value) return undefined; + if (value === "direct" || value === "dm") return "direct"; + if (value === "group") return "group"; + if (value === "channel" || value === "room") return "channel"; + return undefined; +} + diff --git a/src/channels/conversation-label.test.ts b/src/channels/conversation-label.test.ts new file mode 100644 index 000000000..768484439 --- /dev/null +++ b/src/channels/conversation-label.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; + +import type { MsgContext } from "../auto-reply/templating.js"; +import { resolveConversationLabel } from "./conversation-label.js"; + +describe("resolveConversationLabel", () => { + it("prefers ConversationLabel when present", () => { + const ctx: MsgContext = { ConversationLabel: "Pinned Label", ChatType: "group" }; + expect(resolveConversationLabel(ctx)).toBe("Pinned Label"); + }); + + it("uses SenderName for direct chats when available", () => { + const ctx: MsgContext = { ChatType: "direct", SenderName: "Ada", From: "telegram:99" }; + expect(resolveConversationLabel(ctx)).toBe("Ada"); + }); + + it("derives Telegram-like group labels with numeric id suffix", () => { + const ctx: MsgContext = { ChatType: "group", GroupSubject: "Ops", From: "telegram:group:42" }; + expect(resolveConversationLabel(ctx)).toBe("Ops id:42"); + }); + + it("does not append ids for #rooms/channels", () => { + const ctx: MsgContext = { ChatType: "channel", GroupSubject: "#general", From: "slack:channel:C123" }; + expect(resolveConversationLabel(ctx)).toBe("#general"); + }); + + it("appends ids for WhatsApp-like group ids when a subject exists", () => { + const ctx: MsgContext = { ChatType: "group", GroupSubject: "Family", From: "whatsapp:group:123@g.us" }; + expect(resolveConversationLabel(ctx)).toBe("Family id:123@g.us"); + }); +}); + diff --git a/src/channels/conversation-label.ts b/src/channels/conversation-label.ts new file mode 100644 index 000000000..54d8b887e --- /dev/null +++ b/src/channels/conversation-label.ts @@ -0,0 +1,46 @@ +import type { MsgContext } from "../auto-reply/templating.js"; +import { normalizeChatType } from "./chat-type.js"; + +function extractConversationId(from?: string): string | undefined { + const trimmed = from?.trim(); + if (!trimmed) return undefined; + const parts = trimmed.split(":").filter(Boolean); + return parts.length > 0 ? parts[parts.length - 1] : trimmed; +} + +function shouldAppendId(id: string): boolean { + if (/^[0-9]+$/.test(id)) return true; + if (id.includes("@g.us")) return true; + return false; +} + +export function resolveConversationLabel(ctx: MsgContext): string | undefined { + const explicit = ctx.ConversationLabel?.trim(); + if (explicit) return explicit; + + const threadLabel = ctx.ThreadLabel?.trim(); + if (threadLabel) return threadLabel; + + const chatType = normalizeChatType(ctx.ChatType); + if (chatType === "direct") { + return ctx.SenderName?.trim() || ctx.From?.trim() || undefined; + } + + const base = + ctx.GroupRoom?.trim() || + ctx.GroupSubject?.trim() || + ctx.GroupSpace?.trim() || + ctx.From?.trim() || + ""; + if (!base) return undefined; + + const id = extractConversationId(ctx.From); + if (!id) return base; + if (!shouldAppendId(id)) return base; + if (base === id) return base; + if (base.includes(id)) return base; + if (base.toLowerCase().includes(" id:")) return base; + if (base.startsWith("#") || base.startsWith("@")) return base; + return `${base} id:${id}`; +} + diff --git a/src/channels/sender-identity.test.ts b/src/channels/sender-identity.test.ts new file mode 100644 index 000000000..4a18b70b5 --- /dev/null +++ b/src/channels/sender-identity.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; + +import type { MsgContext } from "../auto-reply/templating.js"; +import { validateSenderIdentity } from "./sender-identity.js"; + +describe("validateSenderIdentity", () => { + it("allows direct messages without sender fields", () => { + const ctx: MsgContext = { ChatType: "direct" }; + expect(validateSenderIdentity(ctx)).toEqual([]); + }); + + it("requires some sender identity for non-direct chats", () => { + const ctx: MsgContext = { ChatType: "group" }; + expect(validateSenderIdentity(ctx)).toContain("missing sender identity (SenderId/SenderName/SenderUsername/SenderE164)"); + }); + + it("validates SenderE164 and SenderUsername shape", () => { + const ctx: MsgContext = { + ChatType: "group", + SenderE164: "123", + SenderUsername: "@ada lovelace", + }; + expect(validateSenderIdentity(ctx)).toEqual([ + "invalid SenderE164: 123", + 'SenderUsername should not include "@": @ada lovelace', + "SenderUsername should not include whitespace: @ada lovelace", + ]); + }); +}); + diff --git a/src/channels/sender-identity.ts b/src/channels/sender-identity.ts new file mode 100644 index 000000000..227391b44 --- /dev/null +++ b/src/channels/sender-identity.ts @@ -0,0 +1,38 @@ +import type { MsgContext } from "../auto-reply/templating.js"; +import { normalizeChatType } from "./chat-type.js"; + +export function validateSenderIdentity(ctx: MsgContext): string[] { + const issues: string[] = []; + + const chatType = normalizeChatType(ctx.ChatType); + const isDirect = chatType === "direct"; + + const senderId = ctx.SenderId?.trim() || ""; + const senderName = ctx.SenderName?.trim() || ""; + const senderUsername = ctx.SenderUsername?.trim() || ""; + const senderE164 = ctx.SenderE164?.trim() || ""; + + if (!isDirect) { + if (!senderId && !senderName && !senderUsername && !senderE164) { + issues.push("missing sender identity (SenderId/SenderName/SenderUsername/SenderE164)"); + } + } + + if (senderE164) { + if (!/^\+\d{3,}$/.test(senderE164)) { + issues.push(`invalid SenderE164: ${senderE164}`); + } + } + + if (senderUsername) { + if (senderUsername.includes("@")) issues.push(`SenderUsername should not include "@": ${senderUsername}`); + if (/\s/.test(senderUsername)) issues.push(`SenderUsername should not include whitespace: ${senderUsername}`); + } + + if (ctx.SenderId != null && !senderId) { + issues.push("SenderId is set but empty"); + } + + return issues; +} + diff --git a/test/helpers/inbound-contract.ts b/test/helpers/inbound-contract.ts new file mode 100644 index 000000000..a231e39f1 --- /dev/null +++ b/test/helpers/inbound-contract.ts @@ -0,0 +1,21 @@ +import { expect } from "vitest"; + +import { normalizeChatType } from "../../src/channels/chat-type.js"; +import { resolveConversationLabel } from "../../src/channels/conversation-label.js"; +import { validateSenderIdentity } from "../../src/channels/sender-identity.js"; +import type { MsgContext } from "../../src/auto-reply/templating.js"; + +export function expectInboundContextContract(ctx: MsgContext) { + expect(validateSenderIdentity(ctx)).toEqual([]); + + expect(ctx.Body).toBeTypeOf("string"); + expect(ctx.BodyForAgent).toBeTypeOf("string"); + expect(ctx.BodyForCommands).toBeTypeOf("string"); + + const chatType = normalizeChatType(ctx.ChatType); + if (chatType && chatType !== "direct") { + const label = ctx.ConversationLabel?.trim() || resolveConversationLabel(ctx); + expect(label).toBeTruthy(); + } +} +