← 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 · TypeScriptimport { 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(', ')
}