Skip to content

src/discord-attachments.ts

108 lines · 3.3 KB · TypeScript
Raw
export type DiscordImage = {
	url: string
	filename: string
	contentType: string
}

const IMAGE_EXT = /\.(png|jpe?g|gif|webp|avif|bmp)$/i

function isImageName(filename: string, contentType: string, url: string): boolean {
	if (contentType.toLowerCase().startsWith('image/')) return true
	return IMAGE_EXT.test(filename) || IMAGE_EXT.test(url.split('?')[0] || '')
}

function pushImage(
	images: DiscordImage[],
	seen: Set<string>,
	row: Record<string, unknown>,
): void {
	const url =
		(typeof row.url === 'string' && row.url.trim()) ||
		(typeof row.proxy_url === 'string' && row.proxy_url.trim()) ||
		''
	if (!url || seen.has(url)) return
	const filename =
		(typeof row.filename === 'string' && row.filename.trim()) ||
		url.split('/').pop()?.split('?')[0] ||
		'image'
	const contentType =
		(typeof row.content_type === 'string' && row.content_type.trim()) ||
		(typeof row.contentType === 'string' && row.contentType.trim()) ||
		''
	if (!isImageName(filename, contentType, url)) return
	seen.add(url)
	images.push({
		url,
		filename,
		contentType: contentType || 'image/png',
	})
}

/**
 * Pull image attachments off a raw Discord MESSAGE_CREATE (or wrapped
 * subscription payload). `normalize-message-created` drops attachments, so
 * grok-bot has to read the raw event.
 */
export function collectDiscordImages(raw: unknown, depth = 0): DiscordImage[] {
	const images: DiscordImage[] = []
	const seen = new Set<string>()
	visit(raw, 0)
	return images

	function visit(value: unknown, level: number): void {
		if (level > 6 || value == null) return
		if (Array.isArray(value)) {
			for (const item of value) visit(item, level + 1)
			return
		}
		if (typeof value !== 'object') return
		const row = value as Record<string, unknown>
		if (Array.isArray(row.attachments)) {
			for (const att of row.attachments) {
				if (att && typeof att === 'object') pushImage(images, seen, att as Record<string, unknown>)
			}
		}
		if (row.image && typeof row.image === 'object') {
			pushImage(images, seen, row.image as Record<string, unknown>)
		}
		if (row.thumbnail && typeof row.thumbnail === 'object') {
			pushImage(images, seen, row.thumbnail as Record<string, unknown>)
		}
		if (Array.isArray(row.embeds)) visit(row.embeds, level + 1)
		if (row.payload) visit(row.payload, level + 1)
		if (row.event && row.event !== value) visit(row.event, level + 1)
		if (row.d) visit(row.d, level + 1)
		if (row.message && row.message !== value) visit(row.message, level + 1)
	}
}

export function buildImageWakePrompt(input: {
	botName: string
	content: string
	images: Array<DiscordImage>
}): string {
	const lines: string[] = []
	const isImogen = input.botName.trim().toLowerCase() === 'imogen'
	if (isImogen) {
		lines.push(
			'Write one brief copyable alt block for the attached image. Most important part only. Nothing else. Reply in this Discord channel; stay quiet in Grok Bot.',
		)
		if (input.content) {
			lines.push('')
			lines.push(`Kent said: ${input.content}`)
		}
	} else if (input.content) {
		lines.push(input.content)
	} else {
		lines.push('Kent posted an image in Discord.')
	}
	if (input.images.length) {
		lines.push('')
		lines.push('Attached image(s):')
		for (const image of input.images) {
			lines.push(`- ${image.filename}: ${image.url}`)
		}
		lines.push('Look at those image URLs. They are the message, not decoration.')
	}
	return lines.join('\n')
}