Skip to content
← Public packages

@kentcdodds/groupme

GroupMe REST API helpers for listing groups, reading messages, ranking activity, and preparing relevance-filtered digests for Kody agents.

src/domain.ts

402 lines · 12.3 KB · TypeScript
import { request } from './request.ts'
import type {
	GroupMeDigestInput,
	GroupMeEnvelope,
	GroupMeGroup,
	GroupMeListGroupsOptions,
	GroupMeListMessagesOptions,
	GroupMeListMostActiveGroupsOptions,
	GroupMeMessage,
	GroupMeMessagesInRangeOptions,
	GroupMeMessagesInRangeResult,
	GroupMeNormalizedMessage,
	GroupMePrepareDigestOptions,
	GroupMeRelevanceFilterResult,
	GroupMeRelevanceProfile,
	GroupMeUser,
} from './types.ts'

/**
 * Reads the authenticated GroupMe user (`GET /users/me`).
 */
export async function getMe() {
	const response = await request<GroupMeUser>({
		path: '/users/me',
		throwOnError: true,
	})
	const envelope = response.data as GroupMeEnvelope<GroupMeUser>
	if (!envelope?.response) throw new Error('GroupMe /users/me returned no response payload.')
	return envelope.response
}

/**
 * Lists the authenticated user's groups (`GET /groups`).
 */
export async function listGroups(options: GroupMeListGroupsOptions = {}) {
	const response = await request<GroupMeGroup[]>({
		path: '/groups',
		query: {
			omit: options.omitMemberships === false ? undefined : 'memberships',
			limit: options.limit ?? 100,
			page: options.page,
		},
		throwOnError: true,
	})
	const envelope = response.data as GroupMeEnvelope<GroupMeGroup[]>
	return envelope.response ?? []
}

/**
 * Finds a group by case-insensitive exact name first, then substring match.
 */
export async function findGroupByName(name: string, options: GroupMeListGroupsOptions = {}) {
	const normalized = name.trim().toLowerCase()
	if (!normalized) throw new Error('groupName is required.')

	const groups = await listGroups(options)
	const exact = groups.find((group) => group.name.trim().toLowerCase() === normalized)
	if (exact) return exact

	const partial = groups.find((group) => group.name.trim().toLowerCase().includes(normalized))
	if (!partial) throw new Error(`No GroupMe group matched name "${name}".`)
	return partial
}

/**
 * Lists one page of messages for a group (`GET /groups/:group_id/messages`).
 */
export async function listMessages(options: GroupMeListMessagesOptions) {
	if (!options.groupId) throw new Error('groupId is required.')

	const response = await request<{ messages: GroupMeMessage[] }>({
		path: `/groups/${options.groupId}/messages`,
		query: {
			limit: options.limit ?? 100,
			before_id: options.beforeId,
			after_id: options.afterId,
			since_id: options.sinceId,
		},
		throwOnError: true,
	})
	const envelope = response.data as GroupMeEnvelope<{ messages: GroupMeMessage[] }>
	return envelope.response?.messages ?? []
}

/**
 * Scans backward through message pages until the requested unix-second window is covered.
 */
export async function getMessagesInRange(
	options: GroupMeMessagesInRangeOptions,
): Promise<GroupMeMessagesInRangeResult> {
	if (!options.groupId) throw new Error('groupId is required.')

	const timeZone = options.timeZone ?? 'America/Denver'
	const startUnix = resolveRangeBoundary(options.start, timeZone, 'start')
	const endUnix = resolveRangeBoundary(options.end, timeZone, 'end')
	if (endUnix <= startUnix) {
		throw new Error('Range end must be after range start.')
	}

	const collected: GroupMeMessage[] = []
	let beforeId: string | undefined
	let pagesFetched = 0
	const maxPages = options.maxPages ?? 50

	while (pagesFetched < maxPages) {
		const batch = await listMessages({
			groupId: options.groupId,
			limit: 100,
			beforeId,
		})
		pagesFetched += 1
		if (!batch.length) break

		for (const message of batch) {
			if (message.created_at >= startUnix && message.created_at < endUnix) {
				collected.push(message)
			}
		}

		const oldest = batch[batch.length - 1]
		if (oldest.created_at < startUnix) break
		beforeId = oldest.id
	}

	collected.sort((left, right) => left.created_at - right.created_at)

	return {
		groupId: options.groupId,
		timeZone,
		startUnix,
		endUnix,
		messageCount: collected.length,
		pagesFetched,
		messages: collected.map(normalizeMessage),
	}
}

/**
 * Ranks groups by `messages.last_message_created_at` descending.
 */
export async function listMostActiveGroups(
	options: GroupMeListMostActiveGroupsOptions = {},
) {
	const groups = await listGroups({ omitMemberships: options.omitMemberships })
	return groups
		.map((group) => {
			const lastMessageAt = group.messages?.last_message_created_at ?? 0
			return {
				id: group.id,
				name: group.name,
				lastMessageAt,
				lastMessageAtIso: new Date(lastMessageAt * 1000).toISOString(),
				lastMessagePreview: group.messages?.preview?.text ?? '',
				lastMessageSender: group.messages?.preview?.nickname ?? '',
				messageCount: group.messages?.count ?? 0,
			}
		})
		.sort((left, right) => right.lastMessageAt - left.lastMessageAt)
		.slice(0, options.limit ?? 3)
}

/**
 * Applies a configurable relevance profile to normalized messages.
 *
 * When the profile has no positive criteria (keywords, senderNames, mentions,
 * patterns, attachmentTypes), every message passing the `excludeSystem` and
 * `minTextLength` base filters is kept, so an empty profile means
 * "all substantive messages" rather than "no messages".
 */
export function filterRelevantMessages(
	messages: GroupMeNormalizedMessage[],
	profile: GroupMeRelevanceProfile = {},
): GroupMeRelevanceFilterResult {
	const excludeSystem = profile.excludeSystem !== false
	const minTextLength = profile.minTextLength ?? 4
	const keywords = (profile.keywords ?? []).map((value) => value.toLowerCase())
	const senderNames = (profile.senderNames ?? []).map((value) => value.toLowerCase())
	const mentions = (profile.mentions ?? []).map((value) => value.toLowerCase())
	const attachmentTypes = new Set(
		(profile.attachmentTypes ?? []).map((value) => value.toLowerCase()),
	)
	const patterns = (profile.patterns ?? []).map((value) => new RegExp(value, 'i'))
	const hasPositiveCriteria =
		keywords.length > 0 ||
		senderNames.length > 0 ||
		mentions.length > 0 ||
		attachmentTypes.size > 0 ||
		patterns.length > 0

	const relevant = messages.filter((message) => {
		if (excludeSystem && message.system) return false

		const text = message.text.trim()
		if (text.length < minTextLength && message.attachmentTypes.length === 0) return false
		if (!hasPositiveCriteria) return true

		const lowerText = text.toLowerCase()
		const lowerSender = message.senderName.toLowerCase()
		if (senderNames.some((name) => lowerSender.includes(name))) return true
		if (keywords.some((keyword) => lowerText.includes(keyword))) return true
		if (mentions.some((mention) => lowerText.includes(mention))) return true
		if (message.attachmentTypes.some((type) => attachmentTypes.has(type.toLowerCase()))) {
			return true
		}
		if (patterns.some((pattern) => pattern.test(text))) return true

		return false
	})

	return {
		inputCount: messages.length,
		relevantCount: relevant.length,
		skippedCount: messages.length - relevant.length,
		messages: relevant,
	}
}

/** Formats unix range boundaries back to `YYYY-MM-DD` strings in a timezone. */
export function formatDateInTimeZone(unixSeconds: number, timeZone: string) {
	const parts = new Intl.DateTimeFormat('en-CA', {
		timeZone,
		year: 'numeric',
		month: '2-digit',
		day: '2-digit',
	}).formatToParts(new Date(unixSeconds * 1000))
	const year = parts.find((part) => part.type === 'year')?.value
	const month = parts.find((part) => part.type === 'month')?.value
	const day = parts.find((part) => part.type === 'day')?.value
	return `${year}-${month}-${day}`
}

/**
 * Reads one group by id (`GET /groups/:group_id`).
 */
export async function getGroup(groupId: string) {
	if (!groupId) throw new Error('groupId is required.')
	const response = await request<GroupMeGroup>({
		path: `/groups/${groupId}`,
		throwOnError: true,
	})
	const envelope = response.data as GroupMeEnvelope<GroupMeGroup>
	if (!envelope?.response) throw new Error(`GroupMe group "${groupId}" was not found.`)
	return envelope.response
}

/**
 * Fetches a date-bounded message window and returns structured digest input.
 *
 * Without a `relevance` profile the digest keeps all substantive messages
 * (system messages and very short texts are still dropped by default).
 */
export async function prepareDigest(
	options: GroupMePrepareDigestOptions,
): Promise<GroupMeDigestInput> {
	if (!options.groupId && !options.groupName) {
		throw new Error('Provide groupId or groupName.')
	}
	const group = options.groupId
		? await getGroup(options.groupId)
		: await findGroupByName(options.groupName ?? '')

	const range = await getMessagesInRange({
		groupId: group.id,
		start: options.start,
		end: options.end,
		timeZone: options.timeZone,
		maxPages: options.maxPages,
	})
	const relevance = options.relevance ?? {}
	const filtered = filterRelevantMessages(range.messages, relevance)

	return {
		group: { id: group.id, name: group.name },
		window: {
			timeZone: range.timeZone,
			startUnix: range.startUnix,
			endUnix: range.endUnix,
			startDate: formatDateInTimeZone(range.startUnix, range.timeZone),
			endDate: formatDateInTimeZone(range.endUnix - 1, range.timeZone),
		},
		stats: {
			totalMessages: range.messageCount,
			relevantMessages: filtered.relevantCount,
			skippedMessages: filtered.skippedCount,
			pagesFetched: range.pagesFetched,
		},
		relevance,
		messages: filtered.messages,
	}
}

/**
 * Verifies saved GroupMe auth by calling `/users/me` and listing groups.
 */
export async function runSmokeTest() {
	const me = await getMe()
	const groups = await listGroups({ omitMemberships: true, limit: 5 })
	return {
		ok: true,
		user: { id: me.id, name: me.name },
		groupCountSampled: groups.length,
		topGroups: groups
			.sort(
				(left, right) =>
					(right.messages?.last_message_created_at ?? 0) -
					(left.messages?.last_message_created_at ?? 0),
			)
			.slice(0, 3)
			.map((group) => ({
				id: group.id,
				name: group.name,
				lastMessageAt: group.messages?.last_message_created_at ?? 0,
			})),
	}
}

/**
 * Converts a `YYYY-MM-DD` calendar date in an IANA timezone to unix seconds at local midnight.
 */
export function dateStringToUnixSeconds(date: string, timeZone: string) {
	const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date)
	if (!match) throw new Error(`Expected YYYY-MM-DD date, received "${date}".`)

	const year = Number(match[1])
	const month = Number(match[2])
	const day = Number(match[3])
	const utcGuess = Date.UTC(year, month - 1, day, 0, 0, 0)
	const formatter = new Intl.DateTimeFormat('en-US', {
		timeZone,
		year: 'numeric',
		month: '2-digit',
		day: '2-digit',
		hour: '2-digit',
		minute: '2-digit',
		second: '2-digit',
		hourCycle: 'h23',
	})

	const parts = Object.fromEntries(
		formatter.formatToParts(new Date(utcGuess)).map((part) => [part.type, part.value]),
	)
	const asUtc = Date.UTC(
		Number(parts.year),
		Number(parts.month) - 1,
		Number(parts.day),
		Number(parts.hour),
		Number(parts.minute),
		Number(parts.second),
	)

	return Math.floor((utcGuess - (asUtc - utcGuess)) / 1000)
}

/** Resolves a range boundary that may already be unix seconds or a date string. */
export function resolveRangeBoundary(
	value: string | number,
	timeZone: string,
	mode: 'start' | 'end',
) {
	if (typeof value === 'number') return value
	if (/^\d+$/.test(value)) return Number(value)
	if (mode === 'start') return dateStringToUnixSeconds(value, timeZone)
	const start = dateStringToUnixSeconds(value, timeZone)
	return start + 24 * 60 * 60
}

/** Normalizes a raw GroupMe message for digest helpers. */
export function normalizeMessage(message: GroupMeMessage): GroupMeNormalizedMessage {
	const attachmentTypes = (message.attachments ?? [])
		.map((attachment) => attachment.type)
		.filter(Boolean)
	const attachmentSummary = summarizeAttachments(message.attachments ?? [])

	return {
		id: message.id,
		groupId: message.group_id,
		createdAt: message.created_at,
		createdAtIso: new Date(message.created_at * 1000).toISOString(),
		senderName: message.name,
		text: message.text ?? '',
		system: Boolean(message.system),
		attachmentTypes,
		attachmentSummary,
	}
}

/** Builds a short human-readable attachment summary. */
export function summarizeAttachments(
	attachments: Array<{ type?: string; url?: string; name?: string }>,
) {
	if (!attachments.length) return ''
	return attachments
		.map((attachment) => {
			if (attachment.type === 'image') return 'image'
			if (attachment.type === 'video') return 'video'
			if (attachment.type === 'location') return `location:${attachment.name ?? 'pin'}`
			if (attachment.type === 'reply') return 'reply'
			if (attachment.type === 'mentions') return 'mentions'
			return attachment.type ?? 'attachment'
		})
		.join(', ')
}