refactor: add inbound context helpers
This commit is contained in:
19
src/auto-reply/reply/inbound-text.test.ts
Normal file
19
src/auto-reply/reply/inbound-text.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
||||
7
src/auto-reply/reply/inbound-text.ts
Normal file
7
src/auto-reply/reply/inbound-text.ts
Normal 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");
|
||||
}
|
||||
|
||||
20
src/channels/chat-type.test.ts
Normal file
20
src/channels/chat-type.test.ts
Normal 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
11
src/channels/chat-type.ts
Normal 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;
|
||||
}
|
||||
|
||||
32
src/channels/conversation-label.test.ts
Normal file
32
src/channels/conversation-label.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
||||
46
src/channels/conversation-label.ts
Normal file
46
src/channels/conversation-label.ts
Normal 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}`;
|
||||
}
|
||||
|
||||
30
src/channels/sender-identity.test.ts
Normal file
30
src/channels/sender-identity.test.ts
Normal 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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
38
src/channels/sender-identity.ts
Normal file
38
src/channels/sender-identity.ts
Normal 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;
|
||||
}
|
||||
|
||||
21
test/helpers/inbound-contract.ts
Normal file
21
test/helpers/inbound-contract.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user