refactor: add inbound context helpers

This commit is contained in:
Peter Steinberger
2026-01-17 04:04:17 +00:00
parent a2b5b1f0cb
commit 388b2bce01
9 changed files with 224 additions and 0 deletions

View File

@@ -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");
});
});

View File

@@ -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");
}

View File

@@ -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();
});
});

11
src/channels/chat-type.ts Normal file
View File

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

View File

@@ -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");
});
});

View File

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

View File

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

View File

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

View File

@@ -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();
}
}