From ee63172e08cedc60085bf3ce18cdf00e5ef022db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9D=D0=B8=D0=BA=20=28Claude=29?= Date: Thu, 11 Jun 2026 20:01:50 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20MAX=20publisher=20=E2=80=94=20platform-?= =?UTF-8?q?api.max.ru=20with=20image=20upload?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit publishToMax(): - API: platform-api.max.ru (новый домен, Authorization header) - Text: POST /messages?chat_id={id} с {text, attachments} - Photo: POST /uploads?type=image → presigned URL → multipart upload → {token} Attach: {type:'image', payload:{token}} в теле сообщения - Graceful fallback: при ошибке фото — пост без картинки - Убрана заглушка 'не реализована' --- src/services/scheduledPostsRunner.js | 67 +++++++++++++++++++++++++++- 1 file changed, 65 insertions(+), 2 deletions(-) diff --git a/src/services/scheduledPostsRunner.js b/src/services/scheduledPostsRunner.js index 78b268d..dba05be 100644 --- a/src/services/scheduledPostsRunner.js +++ b/src/services/scheduledPostsRunner.js @@ -212,8 +212,71 @@ async function publishToMax({ channel, text, photoUrl, article }) { if (!channel.max_channel_id || !channel.max_access_token) { throw new Error('MAX не настроен'); } - // Заглушка — точный endpoint MAX заполним когда подключим живой канал - throw new Error('MAX публикация не реализована'); + + const BASE = 'https://platform-api.max.ru'; + const token = channel.max_access_token; + const chatId = channel.max_channel_id; + const headers = { Authorization: token, 'Content-Type': 'application/json' }; + + // Добавляем ссылку на статью в конец текста + let finalText = text; + if (article) { + const url = articleUrl(article); + if (!finalText.includes(url)) { + const buttonText = channel.auto_publish_button_text || DEFAULT_BUTTON_TEXT; + finalText = `${finalText}\n\n${buttonText}\n${url}`; + } + } + + const body = { text: finalText, attachments: [] }; + + // Загрузка фото через 2-step upload + if (photoUrl) { + try { + // Шаг 1: получаем presigned URL для загрузки + const uploadUrlRes = await axios.post(`${BASE}/uploads?type=image`, null, { + headers: { Authorization: token }, + timeout: 10_000, + }); + const uploadUrl = uploadUrlRes.data?.url; + if (!uploadUrl) throw new Error('MAX: нет upload URL'); + + // Шаг 2: загружаем файл (multipart) + const localPath = resolveLocalPhoto(photoUrl); + let fileBuffer, fileName; + if (localPath) { + fileBuffer = fs.readFileSync(localPath); + fileName = path.basename(localPath); + } else { + const dl = await axios.get(photoUrl, { responseType: 'arraybuffer', timeout: 30_000 }); + fileBuffer = Buffer.from(dl.data); + fileName = 'photo.jpg'; + } + + const form = new FormData(); + form.append('file', fileBuffer, { filename: fileName, contentType: 'image/jpeg' }); + const uploadRes = await axios.post(uploadUrl, form, { + headers: form.getHeaders(), + timeout: 60_000, + }); + + const token_img = uploadRes.data?.token; + if (token_img) { + body.attachments.push({ type: 'image', payload: { token: token_img } }); + } + } catch (photoErr) { + console.warn(`[MAX] photo upload failed, posting without photo: ${photoErr.message}`); + } + } + + if (!body.attachments.length) delete body.attachments; + + const res = await axios.post(`${BASE}/messages?chat_id=${chatId}`, body, { + headers, + timeout: 15_000, + }); + if (res.data?.error) throw new Error(`MAX: ${res.data.error.message || res.data.error}`); + return res.data?.message?.body?.mid; } async function publishOne(scheduledPost) {