diff --git a/docs/quality/uygulama-durumu.md b/docs/quality/uygulama-durumu.md index c074ebf..7879f3e 100644 --- a/docs/quality/uygulama-durumu.md +++ b/docs/quality/uygulama-durumu.md @@ -1062,3 +1062,11 @@ Taslak türü seçicisine erişilebilir ad eklendi. Üretim derlemesi geçti. Ö - Yönetici yanıtında `kanallar.telegram/email` için `denenen/kabul/hata`, kapalı tercih sayısı `kapali`, yapılandırılmış kanalı olmayanların sayısı `kanalsiz` var. `gonderilen` en az bir kanalda kabul edilen kullanıcı sayısı, `hata` en az bir hata yaşayan kullanıcı sayısıdır; kısmi başarıda ikisi de artabilir. `success` hata varsa false, `partial` en az bir kullanıcıya kabul varken hata varsa true. Sağlayıcı kabulü alıcının okuduğunu kanıtlamaz. - Altı yeni test: zorla opt-out, kısmi kanal hatası, eksik/false kabul, gönderim sırasında tercih değişikliği, boş/kanalsız özet, kullanıcı/ilan kaynağı hatası. 243 test geçti; gerçek e-posta/Telegram gönderilmedi. - Henüz kapanmayanlar: kalıcı kanal bazlı tekrar engeli, zamanlayıcının gönderimden önce hafta kaydı yazması, Telegram uzun içerik kesilmesi. Bu değişiklik otomatik yeniden deneme eklemiyor; kabul edilmiş iletilerin tekrarına yol açacak toplu tekrar çözüm olarak kullanılmamalı. + +### 2026-09-06 — Haftalık özet içerik bütünlüğü + +- Özet oluşturmadaki ilk 200 takip/ilk 15 sonuç sınırları kaldırıldı. Aynı takip anahtarı tekrar işlenmiyor; sonuçlar tarihe göre yeniler önce sıralanıyor. Önceki yedi tam takvim günü Türkiye saatine göre hesaplanıyor; başlangıç günü dahil, bugün ve gelecek tarihler hariç. Başlık bu aralığı açıkça belirtiyor; haftalık tarih etiketi sunucu saat dilimine bağlı değil. +- Telegram için özetin ilk 3900 karakterini kesmek yerine tüm metin en fazla 3900 UTF-16 birimlik parçalara ayrılıyor; emoji çiftleri bölünmüyor. Özet düz metin gönderiliyor; kaynak başlıklarındaki `<`, `>` ve `&` HTML olarak yorumlanmıyor. Diğer mevcut Telegram çağrılarının HTML modu korunuyor. +- Tercih her parça öncesi kontrol ediliyor. Bir parça hata verirse kalan Telegram parçaları gönderilmiyor; e-posta kanalı ayrıca deneniyor. Telegram `denenen/kabul/hata` sayaçları artık mesaj parçası sayısıdır. `gonderilen` en az bir mesaj kabulü bulunan kullanıcı sayısıdır, tüm parçaların tamamlandığı anlamına gelmez; hatalar birlikte değerlendirilmelidir. +- Doğrulama: 248 testlik tüm süit ve ek 1 Telegram taşıma testi geçti; build başarılı (mevcut büyük paket uyarısı sürüyor). 205 takip kaydı, 15 üzeri sonuç, tarih sınırları, uzun Türkçe/emoji içeriğin birebir birleşmesi, parça hatası ve gerçek HTTP çağrısı yerine sahte taşıyıcı ile düz metin gövdesi doğrulandı. Gerçek mesaj gönderilmedi. +- Açık işler: kalıcı parça/kanal teslimat kaydı, zamanlayıcı/yeniden deneme, sağlayıcı oran limitleri ve izinli test alıcısında kabul. Bu aşama çökme sonrası tam-bir-kez teslimat garantisi vermez. Kaynakta sözleşme tarihi bulunmadığında mevcut ihale tarihi yedeği kullanılıyor; geçmiş sonuç tarihi kaynak doğruluğu ayrıca doğrulanmalı. diff --git a/server/brief-content.mjs b/server/brief-content.mjs new file mode 100644 index 0000000..5048de4 --- /dev/null +++ b/server/brief-content.mjs @@ -0,0 +1,35 @@ +/** Assemble every tracked result in the previous seven Istanbul calendar dates. */ +export function createBriefBuilder({ trackedKeys, upcoming, results, parseDate }) { + return function buildBrief(email, tenders, now = new Date()) { + const yaklasan = upcoming(email, tenders, 7, now); + const today = new Date(now.getTime() + 3 * 3600000).toISOString().slice(0,10); + const end = Date.parse(today + 'T00:00:00+03:00'); + const start = end - 7 * 86400000; + const dated = []; + for (const ikn of new Set(trackedKeys(email))) { + for (const result of results(ikn) || []) { + const date = parseDate(result.sozlesmeTarihi) || parseDate(result.tarih); + if (!date || date.getTime() < start || date.getTime() >= end) continue; + dated.push({ timestamp:date.getTime(), value:{ ikn, baslik:result.baslik, kazanan:result.kazananFirma, + bedel:result.sozlesmeBedeli, birim:result.paraBirimi, teklif:result.teklifSayisi } }); + } + } + dated.sort((a,b) => b.timestamp-a.timestamp || String(a.value.ikn).localeCompare(String(b.value.ikn),'tr')); + const sonuclar = dated.map(row=>row.value); + const hafta = now.toLocaleDateString('tr-TR', { timeZone:'Europe/Istanbul', day:'2-digit', month:'long', year:'numeric' }); + return { hafta, yaklasan, sonuclar, bos:!yaklasan.length && !sonuclar.length }; + }; +} + +/** Keep UTF-16 surrogate pairs intact while bounding each Telegram message. */ +export function splitBriefText(text, limit=3900) { + if (!Number.isInteger(limit) || limit < 2 || limit > 3900) throw new Error('Geçersiz mesaj uzunluğu.'); + const parts=[]; + let part=''; + for (const char of String(text)) { + if (part.length + char.length > limit) { parts.push(part); part=''; } + part += char; + } + if (part) parts.push(part); + return parts; +} diff --git a/server/brief-delivery.mjs b/server/brief-delivery.mjs index 6e723eb..edf768b 100644 --- a/server/brief-delivery.mjs +++ b/server/brief-delivery.mjs @@ -1,3 +1,5 @@ +import { splitBriefText } from './brief-content.mjs'; + /** Delivery accounting shared by scheduled and administrator-triggered briefs. */ export function createBriefSender({ store, buildBrief, text, html, telegramConfigured, sendTelegram, emailConfigured, sendEmail, warn = console.warn }) { return async function sendBrief(tenderGetir) { @@ -14,16 +16,20 @@ export function createBriefSender({ store, buildBrief, text, html, telegramConfi if (brief.bos) { r.bos++; continue; } const channels = []; const telegram = store.getData(user.email, 'telegram'); - if (telegramConfigured() && telegram?.chatId) channels.push({ - name:'telegram', send:() => sendTelegram(telegram.chatId, text(brief).slice(0,3900)), - valid:receipt => receipt === true, - }); + if (telegramConfigured() && telegram?.chatId) { + for (const part of splitBriefText(text(brief))) channels.push({ + name:'telegram', send:() => sendTelegram(telegram.chatId, part, { parseMode:null }), + valid:receipt => receipt === true, + }); + } if (emailConfigured()) channels.push({ name:'email', send:() => sendEmail({ to:user.email, subject:`İhaleist — Haftalık Brief (${brief.hafta})`, html:html(brief), text:text(brief) }), valid:receipt => typeof receipt?.id === 'string' && Boolean(receipt.id.trim()), }); if (!channels.length) r.kanalsiz++; + const failedChannels = new Set(); for (const channel of channels) { + if (failedChannels.has(channel.name)) continue; // An earlier channel can await the network while the user disables briefs. if (store.getData(user.email, 'brief')?.kapali) { r.kapali++; break; } const counts = r.kanallar[channel.name]; @@ -35,6 +41,7 @@ export function createBriefSender({ store, buildBrief, text, html, telegramConfi accepted = true; } catch (error) { counts.hata++; + failedChannels.add(channel.name); failed = true; warn('[brief]', user.email, channel.name, error.message); } diff --git a/server/brief.mjs b/server/brief.mjs index 41e7755..31ee6f2 100644 --- a/server/brief.mjs +++ b/server/brief.mjs @@ -1,8 +1,9 @@ // Haftalık brief — Pazartesi 08:00: takipteki ihalelerden bu hafta yapılacaklar, -// geçen hafta sonuçlanan takipliler, takiplilere gelen zeyilname sayısı. +// önceki yedi takvim günündeki takipli sonuç kayıtları. // Telegram (bağlıysa) + e-posta. Son gönderim data/brief-son.json'da tutulur; // saatlik kontrol, haftada bir gönderim (node-cron yok, setInterval). import fs from 'node:fs'; +import { createBriefBuilder } from './brief-content.mjs'; import { createBriefSender } from './brief-delivery.mjs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -25,21 +26,9 @@ function sonGonderimYaz(d) { const paraTR = (v, birim) => (v == null ? '' : `${Number(v).toLocaleString('tr-TR')} ${birim || 'TL'}`); /** Bir kullanıcı için brief içeriği. */ -export function briefOlustur(email, tenders) { - const takip = store.getTrackedTenderKeys(email); - const yaklasan = yaklasanIhaleler(email, tenders, 7); - const birHafta = Date.now() - 7 * 86400000; - const sonuclar = []; - for (const ikn of takip.slice(0, 200)) { - const r = resultsByIkn(ikn) || []; - for (const s of r) { - const d = trTarih(s.sozlesmeTarihi) || trTarih(s.tarih); - if (d && d.getTime() >= birHafta) sonuclar.push({ ikn, baslik: s.baslik, kazanan: s.kazananFirma, bedel: s.sozlesmeBedeli, birim: s.paraBirimi, teklif: s.teklifSayisi }); - } - } - const hafta = new Date().toLocaleDateString('tr-TR', { day: '2-digit', month: 'long', year: 'numeric' }); - return { hafta, yaklasan, sonuclar: sonuclar.slice(0, 15), bos: !yaklasan.length && !sonuclar.length }; -} +export const briefOlustur = createBriefBuilder({ + trackedKeys:store.getTrackedTenderKeys, upcoming:yaklasanIhaleler, results:resultsByIkn, parseDate:trTarih, +}); const deadlineText = t => t.son_teklif_zamani ? new Date(t.son_teklif_zamani).toLocaleTimeString('tr-TR', { timeZone: 'Europe/Istanbul', hour: '2-digit', minute: '2-digit' }) + ' (Türkiye saati)' @@ -52,7 +41,7 @@ export function briefMetni(b) { for (const t of b.yaklasan) s.push(`• ${t.kalan_gun === 0 ? 'BUGÜN' : t.kalan_gun + ' gün'} — ${t.baslik} (${t.kurum}, ${t.tarih} · ${deadlineText(t)})`); } if (b.sonuclar.length) { - s.push('', `🏁 Geçen hafta sonuçlanan takipliler (${b.sonuclar.length}):`); + s.push('', `🏁 Önceki 7 takvim günündeki takipli sonuç kayıtları (${b.sonuclar.length}):`); for (const r of b.sonuclar) s.push(`• ${r.baslik} → ${r.kazanan || 'kazanan açıklanmadı'}${r.bedel != null ? ' · ' + paraTR(r.bedel, r.birim) : ''}${r.teklif != null ? ' · ' + r.teklif + ' teklif' : ''}`); } s.push('', 'ihaleist.com'); @@ -66,7 +55,7 @@ export function briefHtml(b) { return `

📋 Haftalık İhale Brief

${esc(b.hafta)}
${bolum('⏰ Bu hafta yapılacak takipli ihaleler', b.yaklasan.map((t) => `${t.kalan_gun === 0 ? 'BUGÜN' : t.kalan_gun + ' gün'} — ${esc(t.baslik)} (${esc(t.kurum)}, ${esc(t.tarih)} · ${esc(deadlineText(t))})`))} - ${bolum('🏁 Geçen hafta sonuçlanan takipliler', b.sonuclar.map((r) => `${esc(r.baslik)} → ${esc(r.kazanan || 'kazanan açıklanmadı')}${r.bedel != null ? ' · ' + esc(paraTR(r.bedel, r.birim)) : ''}`))} + ${bolum('🏁 Önceki 7 takvim günündeki takipli sonuç kayıtları', b.sonuclar.map((r) => `${esc(r.baslik)} → ${esc(r.kazanan || 'kazanan açıklanmadı')}${r.bedel != null ? ' · ' + esc(paraTR(r.bedel, r.birim)) : ''}`))}

Bu brief her Pazartesi gönderilir; Bildirimler sekmesinden kapatabilirsiniz.

`; } diff --git a/server/telegram.mjs b/server/telegram.mjs index b12fdbe..25f6170 100644 --- a/server/telegram.mjs +++ b/server/telegram.mjs @@ -26,12 +26,12 @@ export async function botAdi() { return null; } -export async function sendTelegram(chatId, text) { +export async function sendTelegram(chatId, text, { parseMode = 'HTML' } = {}) { if (!telegramConfigured() || !chatId) return false; const r = await fetch(`${API()}/sendMessage`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ chat_id: chatId, text: String(text).slice(0, 4000), parse_mode: 'HTML', disable_web_page_preview: true }), + body: JSON.stringify({ chat_id: chatId, text: String(text).slice(0, 4000), ...(parseMode === null ? {} : { parse_mode:parseMode }), disable_web_page_preview: true }), signal: AbortSignal.timeout(15000), }); const d = await r.json().catch(() => ({})); diff --git a/tests/brief-content.test.mjs b/tests/brief-content.test.mjs new file mode 100644 index 0000000..65b614a --- /dev/null +++ b/tests/brief-content.test.mjs @@ -0,0 +1,37 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createBriefBuilder, splitBriefText } from '../server/brief-content.mjs'; + +const parseDate = value => /^\d{4}-\d{2}-\d{2}$/.test(value || '') ? new Date(value+'T00:00:00+03:00') : null; + +test('brief includes more than 200 tracked tenders and 15 results without silent caps',()=>{ + const keys=Array.from({length:205},(_,i)=>`2026/${i}`), seen=[]; + const now=new Date('2026-09-06T21:05:00Z'); + const build=createBriefBuilder({trackedKeys:()=>[...keys,keys[0]], + upcoming:(email,tenders,days,clock)=>{assert.equal(clock,now);assert.equal(days,7);return [];}, + results:key=>{seen.push(key);return [{baslik:key,sozlesmeTarihi:'2026-09-06'}];},parseDate}); + const result=build('test@example.test',[],now); + assert.equal(result.sonuclar.length,205); + assert.equal(new Set(result.sonuclar.map(t=>t.ikn)).size,205); + assert.equal(seen.length,205); + assert.equal(result.bos,false); + assert.equal(result.hafta,'07 Eylül 2026'); +}); + +test('previous seven full Istanbul dates include the first day and exclude current/future dates',()=>{ + const build=createBriefBuilder({trackedKeys:()=>['2026/1'],upcoming:()=>[],parseDate, + results:()=>['2026-08-30','2026-08-31','2026-09-06','2026-09-07','2026-09-08','invalid'] + .map(date=>({baslik:date,sozlesmeTarihi:date}))}); + const result=build('test@example.test',[],new Date('2026-09-06T21:05:00Z')); + assert.deepEqual(result.sonuclar.map(t=>t.baslik),['2026-09-06','2026-08-31']); +}); + +test('long Turkish/emoji text is preserved exactly without splitting surrogate pairs',()=>{ + const text=('Laboratuvar & test 🧪\n').repeat(600); + const parts=splitBriefText(text); + assert.ok(parts.length>3); + assert.equal(parts.join(''),text); + for(const part of parts){assert.ok(part.length<=3900);assert.ok(part.isWellFormed());} + assert.deepEqual(splitBriefText(''),[]); + assert.throws(()=>splitBriefText('text',1)); +}); diff --git a/tests/brief-delivery.test.mjs b/tests/brief-delivery.test.mjs index cc4e43a..cec6870 100644 --- a/tests/brief-delivery.test.mjs +++ b/tests/brief-delivery.test.mjs @@ -75,3 +75,24 @@ test('one recipient failure does not skip subsequent recipients; feed failure is assert.deepEqual(f.calls,['email']); await assert.rejects(f.send(async()=>{throw new Error('Feed unavailable');}),/Feed unavailable/); }); + +test('long brief is sent completely as plain text with each part accounted for',async()=>{ + const content=('Ürün & 🧪\n').repeat(700),sent=[]; + const f=fixture({text:()=>content,emailConfigured:()=>false, + sendTelegram:async(chatId,text,options)=>{sent.push(text);assert.deepEqual(options,{parseMode:null});return true;}}); + const result=await f.send(async()=>[]); + assert.equal(sent.join(''),content); + assert.ok(sent.length>1); + assert.equal(result.kanallar.telegram.kabul,sent.length); + assert.equal(result.hata,0); +}); + +test('a failed Telegram part stops subsequent parts but still attempts email',async()=>{ + let attempts=0; + const f=fixture({text:()=> 'x'.repeat(12000),sendTelegram:async()=>{attempts++;if(attempts===2)throw new Error('Unavailable');return true;}}); + const result=await f.send(async()=>[]); + assert.equal(attempts,2); + assert.deepEqual(result.kanallar.telegram,{denenen:2,kabul:1,hata:1}); + assert.equal(result.kanallar.email.kabul,1); + assert.equal(result.hata,1); +}); diff --git a/tests/telegram-brief-transport.test.mjs b/tests/telegram-brief-transport.test.mjs new file mode 100644 index 0000000..4c67bc5 --- /dev/null +++ b/tests/telegram-brief-transport.test.mjs @@ -0,0 +1,22 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { sendTelegram } from '../server/telegram.mjs'; + +test('brief plain-text mode reaches Telegram request without HTML parsing or content changes',async()=>{ + const oldFetch=globalThis.fetch,oldToken=process.env.TELEGRAM_BOT_TOKEN; + process.env.TELEGRAM_BOT_TOKEN='synthetic-test-token'; + const payloads=[]; + globalThis.fetch=async(url,options)=>{payloads.push(JSON.parse(options.body));return {ok:true,json:async()=>({ok:true})};}; + try { + const text='Laboratuvar & ürün 🧪'; + assert.equal(await sendTelegram('test-chat',text,{parseMode:null}),true); + assert.equal(payloads[0].text,text); + assert.equal(Object.hasOwn(payloads[0],'parse_mode'),false); + await sendTelegram('test-chat','Başlık'); + assert.equal(payloads[1].parse_mode,'HTML'); + } finally { + globalThis.fetch=oldFetch; + if(oldToken===undefined)delete process.env.TELEGRAM_BOT_TOKEN; + else process.env.TELEGRAM_BOT_TOKEN=oldToken; + } +});