mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-11 14:33:49 +08:00
feat: support image prompts across IM channels
This commit is contained in:
parent
99a877c640
commit
65c842c045
40 changed files with 3806 additions and 274 deletions
|
|
@ -1,4 +1,7 @@
|
|||
import { fetchImageBuffer } from '../shared/image-prompt.mjs';
|
||||
|
||||
const DEFAULT_BASE_URL = 'https://api.telegram.org/';
|
||||
const TELEGRAM_FILE_HOSTS = Object.freeze(['api.telegram.org']);
|
||||
|
||||
function cleanString(value) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
|
|
@ -47,6 +50,40 @@ export class TelegramApi {
|
|||
});
|
||||
}
|
||||
|
||||
async getFile({ fileId, signal } = {}) {
|
||||
const id = cleanString(fileId);
|
||||
if (!id || !/^[A-Za-z0-9_-]{1,512}$/.test(id)) {
|
||||
throw new TypeError('Telegram file id is invalid');
|
||||
}
|
||||
return this.#call('getFile', { file_id: id }, { signal });
|
||||
}
|
||||
|
||||
async downloadFile({ fileId, signal, maxBytes } = {}) {
|
||||
const file = await this.getFile({ fileId, signal });
|
||||
const filePath = cleanString(file?.file_path);
|
||||
if (!filePath || filePath.startsWith('/') || filePath.includes('\\')
|
||||
|| filePath.includes('?') || filePath.includes('#')) {
|
||||
throw new Error('Telegram returned an invalid file path');
|
||||
}
|
||||
let decodedSegments;
|
||||
try {
|
||||
decodedSegments = filePath.split('/').map((segment) => decodeURIComponent(segment));
|
||||
} catch {
|
||||
throw new Error('Telegram returned an invalid file path');
|
||||
}
|
||||
if (decodedSegments.some((segment) => !segment || segment === '.' || segment === '..')) {
|
||||
throw new Error('Telegram returned an invalid file path');
|
||||
}
|
||||
const url = new URL(this.#baseUrl);
|
||||
url.pathname = `/file/bot${this.#token}/${filePath}`;
|
||||
return fetchImageBuffer(url, {
|
||||
fetchImpl: this.#fetch,
|
||||
signal,
|
||||
maxBytes,
|
||||
allowedHosts: TELEGRAM_FILE_HOSTS,
|
||||
});
|
||||
}
|
||||
|
||||
async sendMessage({ chatId, text, replyToMessageId, messageThreadId, signal }) {
|
||||
return this.#call('sendMessage', {
|
||||
chat_id: chatId,
|
||||
|
|
|
|||
|
|
@ -7,14 +7,17 @@ function escaped(value) {
|
|||
}
|
||||
|
||||
function mentionedUsername(message, username) {
|
||||
if (!username || typeof message?.text !== 'string' || !Array.isArray(message.entities)) return false;
|
||||
return message.entities.some((entity) => {
|
||||
if (entity?.type !== 'mention' || !Number.isInteger(entity.offset) || !Number.isInteger(entity.length)) {
|
||||
return false;
|
||||
}
|
||||
return message.text.slice(entity.offset, entity.offset + entity.length).toLowerCase()
|
||||
=== `@${username.toLowerCase()}`;
|
||||
});
|
||||
if (!username) return false;
|
||||
return [
|
||||
[message?.text, message?.entities],
|
||||
[message?.caption, message?.caption_entities],
|
||||
].some(([text, entities]) => typeof text === 'string' && Array.isArray(entities)
|
||||
&& entities.some((entity) => {
|
||||
if (entity?.type !== 'mention' || !Number.isInteger(entity.offset)
|
||||
|| !Number.isInteger(entity.length)) return false;
|
||||
return text.slice(entity.offset, entity.offset + entity.length).toLowerCase()
|
||||
=== `@${username.toLowerCase()}`;
|
||||
}));
|
||||
}
|
||||
|
||||
function withoutBotMention(text, username) {
|
||||
|
|
@ -22,7 +25,63 @@ function withoutBotMention(text, username) {
|
|||
return text.replace(new RegExp(`@${escaped(username)}\\b`, 'ig'), '').trim();
|
||||
}
|
||||
|
||||
export function normalizeTelegramUpdate(update, { botId, username }) {
|
||||
const IMAGE_MEDIA_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
|
||||
const IMAGE_FILE_TYPES = new Map([
|
||||
['.jpg', 'image/jpeg'],
|
||||
['.jpeg', 'image/jpeg'],
|
||||
['.png', 'image/png'],
|
||||
['.webp', 'image/webp'],
|
||||
['.gif', 'image/gif'],
|
||||
]);
|
||||
|
||||
function imageTypeForDocument(document) {
|
||||
const declaredType = document?.mime_type ?? document?.mimetype;
|
||||
const type = typeof declaredType === 'string' ? declaredType.toLowerCase() : '';
|
||||
if (IMAGE_MEDIA_TYPES.has(type)) return type;
|
||||
const filename = typeof document?.file_name === 'string' ? document.file_name.toLowerCase() : '';
|
||||
for (const [extension, mediaType] of IMAGE_FILE_TYPES) {
|
||||
if (filename.endsWith(extension)) return mediaType;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function fileSize(value) {
|
||||
return Number.isSafeInteger(value) && value >= 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function photoScore(photo) {
|
||||
return fileSize(photo?.file_size) ?? ((Number(photo?.width) || 0) * (Number(photo?.height) || 0));
|
||||
}
|
||||
|
||||
function telegramImageSource(message, loadFile) {
|
||||
let file;
|
||||
let mediaType;
|
||||
let name;
|
||||
if (Array.isArray(message?.photo) && message.photo.length > 0) {
|
||||
file = message.photo.reduce((largest, candidate) => (
|
||||
photoScore(candidate) > photoScore(largest) ? candidate : largest
|
||||
));
|
||||
mediaType = 'image/jpeg';
|
||||
name = `${file.file_unique_id ?? file.file_id ?? 'telegram-photo'}.jpg`;
|
||||
} else if (message?.document) {
|
||||
const type = imageTypeForDocument(message.document);
|
||||
if (!type) return null;
|
||||
file = message.document;
|
||||
mediaType = type;
|
||||
name = typeof file.file_name === 'string' ? file.file_name : undefined;
|
||||
}
|
||||
if (!file || typeof file.file_id !== 'string') return null;
|
||||
return {
|
||||
name,
|
||||
mediaType,
|
||||
size: fileSize(file.file_size),
|
||||
load: (options) => loadFile(file.file_id, options),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeTelegramUpdate(update, { botId, username, loadFile = async () => {
|
||||
throw new Error('Telegram file downloader is unavailable');
|
||||
} }) {
|
||||
const message = update?.message;
|
||||
const chatId = message?.chat?.id;
|
||||
const senderId = message?.from?.id;
|
||||
|
|
@ -36,6 +95,7 @@ export function normalizeTelegramUpdate(update, { botId, username }) {
|
|||
|| mentionedUsername(message, username);
|
||||
const messageThreadId = Number.isSafeInteger(message.message_thread_id)
|
||||
? message.message_thread_id : undefined;
|
||||
const image = telegramImageSource(message, loadFile);
|
||||
return {
|
||||
messageId: String(update.update_id),
|
||||
senderId: String(senderId),
|
||||
|
|
@ -44,6 +104,7 @@ export function normalizeTelegramUpdate(update, { botId, username }) {
|
|||
conversationId: messageThreadId === undefined
|
||||
? String(chatId) : `${chatId}:${messageThreadId}`,
|
||||
content: withoutBotMention(message.text ?? message.caption ?? '', username),
|
||||
images: image ? [image] : [],
|
||||
addressed,
|
||||
replyTarget: {
|
||||
chatId,
|
||||
|
|
@ -250,6 +311,7 @@ export class TelegramRuntime {
|
|||
const message = normalizeTelegramUpdate(update, {
|
||||
botId: this.#config.platformId,
|
||||
username: this.#config.username,
|
||||
loadFile: (fileId, options) => this.#api.downloadFile({ fileId, ...options }),
|
||||
});
|
||||
if (message) {
|
||||
void this.#bridge.accept(message).catch((error) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue