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