fix: retain verified Telegram message receipts for weekly briefs

This commit is contained in:
2026-09-06 14:39:12 +03:00
parent 7a01e4bed7
commit df0626a71f
7 changed files with 63 additions and 9 deletions
+8
View File
@@ -1086,3 +1086,11 @@ Taslak türü seçicisine erişilebilir ad eklendi. Üretim derlemesi geçti. Ö
- Karara bağlanan mesajlar tekrar sahiplenilmiyor. Haftanın tüm mesajları kapalıysa değişen içerik planı da yeni mesaj başlatmıyor. Süresi dolmuş yarım gönderim yönetici listesinde belirsiz görünüyor; etkin işçi kaydı kapatılamıyor.
- Doğrulama: 260 test ve son UI değişikliği sonrası build başarılı. HTTP üzerinden yetki, zorunlu kanıt, karar kalıcılığı ve çakışma; depoda mesaj ayrımı, etkin sahiplik ve skip davranışı test edildi. İzole tarayıcıda altı kontrol geçti: kanıtla kapatma kalıcılığı, sağlayıcı kimliği, göndermeden kapatma, karar sahibi, ayrı abonelik kaydının korunması ve normal kullanıcıda gizlilik. Kanıt `rapor/qa-weekly-brief-review.json`. Test sekmesi ve bu çalışmaya ait geçici sunucu kapatıldı.
- Gerçek mesaj, canlı dağıtım veya gerçek sağlayıcı doğrulaması yapılmadı. Planın diğer kabul maddeleri, Telegram sağlayıcı mesaj kimliği ve haftalık telafi politikası açık.
### 2026-09-06 — Telegram sağlayıcı kabul kimliği
- Haftalık Telegram taşıyıcısı artık `returnReceipt:true` ile gerçek `result.message_id` ve `result.chat.id` bekliyor. Kalıcı kabul değeri `sohbetKimliği:mesajKimliği`; genel `telegram-accepted` işareti yeni gönderimlerde kullanılmıyor. Eski kayıtlara hiç alınmamış sağlayıcı kimliği sonradan üretilmiyor.
- Başarılı HTTP ve `ok === true` gerekiyor. Eksik/sıfır/güvensiz veya yanlış tipte mesaj/sohbet kimliği kabul edilmiyor; sayısal hedef sohbet ile dönen sohbet eşleşmeli. Geçersiz kabul haftalık kuyrukta belirsiz kalıyor. Diğer eski Telegram çağrıları varsayılan boolean dönüşü koruyor.
- Kaynak: Telegram Bot API `sendMessage` başarılı yanıtta Message döndürür: https://core.telegram.org/bots/api#sendmessage . Kabul kaydı alıcının mesajı okuduğu anlamına gelmez.
- Doğrulama: 262 test geçti; değişen sunucu dosyaları sözdizimi kontrolünden geçti. Yeni testler geçerli negatif sohbet kimliği, eksik/geçersiz kimlikler, yanlış sohbet, HTTP hata gövdesinde yanıltıcı ok, kalıcı kabul kimliği ve eksik kabul sonrası belirsiz durumu kapsıyor. Gerçek sağlayıcı çağrısı yapılmadı. Ön yüz değişmediği için son başarılı build tekrar çalıştırılmadı.
- Genel plan, izinli gerçek sağlayıcı kabulü ve kalan uçtan uca kontroller açısından henüz tamamlanmadı.
+2
View File
@@ -91,3 +91,5 @@ Haftalık özet teslimat deposu `IHALEIST_USER_DATA_DIR/brief.sqlite` içindedir
Eski `server/data/brief-son.json` silinmez: eski sürümün aynı hafta için işaretlediği otomatik gönderim geçişte tekrar yapılmaz. Eski sürüm bu işareti göndermeden önce yazdığı için bu kayıt teslimatı kanıtlamaz. Geçiş haftası manuel kontrol gerektirir. Yeni zamanlayıcı bu dosyaya tamamlandı kaydı yazmaz; kaynak yüklenemezse Pazartesi sonraki saatlik kontrolde tekrar yükleyebilir. Sağlayıcı çağrısına girilmiş fakat kabulü doğrulanamamış mesajlar otomatik yeniden gönderilmez. 120 saniyelik sahipliği sona eren yarım kayıt da belirsiz olur. `brief-outbox.mjs` içindeki salt-okuma `inspect()` sonuçları kayıt incelemesine yardım eder; yönetici karar ekranı/karar geçmişi henüz tamamlanmadı. Kayıtları silmek veya doğrudan pending yapmak yeniden mesaj gönderme riski taşır.
Haftalık belirsiz kayıtların karar akışı artık Yönetim → Mobil PWA → **Haftalık özet gönderim incelemesi** bölümündedir. `/api/admin/brief/outbox` yalnız yöneticiye açıktır. Gönderildi kararı sağlayıcı kimliği, açık kontrol onayı ve 20–2000 karakterlik açıklama ister; doğrulanamayan mesaj yeni gönderim yapmadan kapatılabilir. `brief_reviews` tablosu karar sahibini, türünü, açıklamasını ve tarihini aynı transaction içinde saklar. İkinci karar 409 ile reddedilir. Süresi dolmuş gönderim sahipliği liste yenilendiğinde belirsiz olarak görünür; etkin gönderim için karar verilemez. Tam volume yedeği karar tablosunu da kapsar. Bu bölüm önceki nottaki eksik yönetici karar ekranını tamamlar; sağlayıcı teslimat kanıtını otomatik olarak doğrulamaz.
Yeni haftalık Telegram kabulleri `brief_messages.receipt` alanında `chat_id:message_id` olarak saklanır. Eski `telegram-accepted` değerleri gerçek mesaj kimliği değildir ve geriye dönük kimlik üretilmez. Sağlayıcı incelemesinde mesajın ait olduğu sohbet de kontrol edilmelidir; mesaj numarası tek başına bütün sohbetlerde tekil kabul edilmez.
+3 -3
View File
@@ -20,8 +20,8 @@ export function createBriefSender({ store, buildBrief, text, html, telegramConfi
if (telegramConfigured() && telegram?.chatId) {
for (const part of splitBriefText(text(brief))) channels.push({
payload:{channel:'telegram',chatId:telegram.chatId,text:part},
name:'telegram', send:() => sendTelegram(telegram.chatId, part, { parseMode:null }),
valid:receipt => receipt === true,
name:'telegram', send:() => sendTelegram(telegram.chatId, part, { parseMode:null, returnReceipt:true }),
valid:receipt => typeof receipt?.id === 'string' && Boolean(receipt.id.trim()),
});
}
if (emailConfigured()) channels.push({
@@ -50,7 +50,7 @@ export function createBriefSender({ store, buildBrief, text, html, telegramConfi
try {
const receipt = await channel.send();
if (!channel.valid(receipt)) throw new Error('Sağlayıcı gönderimi doğrulamadı.');
if (claim) outbox.accept(claim,receipt === true ? 'telegram-accepted' : receipt.id);
if (claim) outbox.accept(claim,receipt.id);
counts.kabul++;
accepted = true;
} catch (error) {
+10 -2
View File
@@ -26,7 +26,7 @@ export async function botAdi() {
return null;
}
export async function sendTelegram(chatId, text, { parseMode = 'HTML' } = {}) {
export async function sendTelegram(chatId, text, { parseMode = 'HTML', returnReceipt = false } = {}) {
if (!telegramConfigured() || !chatId) return false;
const r = await fetch(`${API()}/sendMessage`, {
method: 'POST',
@@ -35,7 +35,15 @@ export async function sendTelegram(chatId, text, { parseMode = 'HTML' } = {}) {
signal: AbortSignal.timeout(15000),
});
const d = await r.json().catch(() => ({}));
if (!d.ok) throw new Error(d.description || `Telegram HTTP ${r.status}`);
if (!r.ok || d.ok !== true) throw new Error(d.description || `Telegram HTTP ${r.status}`);
if (returnReceipt) {
const messageId=d.result?.message_id,returnedChat=d.result?.chat?.id;
if (!Number.isSafeInteger(messageId) || messageId <= 0 || !Number.isSafeInteger(returnedChat) || returnedChat === 0)
throw new Error('Telegram mesaj kimliği doğrulanamadı; gönderim sonucu belirsiz.');
if (/^-?\d+$/.test(String(chatId)) && String(returnedChat) !== String(chatId))
throw new Error('Telegram kabulü hedef sohbetle eşleşmiyor; gönderim sonucu belirsiz.');
return { id:`${returnedChat}:${messageId}`, chatId:String(returnedChat), messageId };
}
return true;
}
+4 -4
View File
@@ -9,7 +9,7 @@ function fixture(overrides={}) {
store:{ listUsers:()=>[{email:'one@example.test'}], getData:(email,key)=>key==='brief'?prefs.get(email):{chatId:'test-chat'} },
buildBrief:()=>({bos:false,hafta:'Test'}),text:()=> 'Test metni',html:()=> '<p>Test metni</p>',
telegramConfigured:()=>true,emailConfigured:()=>true,
sendTelegram:async()=>{calls.push('telegram');return true;},
sendTelegram:async()=>{calls.push('telegram');return {id:'test-chat:1'};},
sendEmail:async()=>{calls.push('email');return {id:'test-receipt'};},
warn:()=>{}, ...overrides,
};
@@ -45,7 +45,7 @@ test('false and missing receipts do not become successful sends',async()=>{
});
test('opt-out during Telegram delivery prevents the following email',async()=>{
const f=fixture({sendTelegram:async()=>{f.prefs.set('one@example.test',{kapali:true});return true;}});
const f=fixture({sendTelegram:async()=>{f.prefs.set('one@example.test',{kapali:true});return {id:'test-chat:1'};}});
const result=await f.send(async()=>[]);
assert.equal(result.gonderilen,1);
assert.equal(result.kapali,1);
@@ -79,7 +79,7 @@ test('one recipient failure does not skip subsequent recipients; feed failure is
test('long brief is sent completely as plain text with each part accounted for',async()=>{
const content=('Ürün <test> & 🧪\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;}});
sendTelegram:async(chatId,text,options)=>{sent.push(text);assert.deepEqual(options,{parseMode:null,returnReceipt:true});return {id:'test-chat:1'};}});
const result=await f.send(async()=>[]);
assert.equal(sent.join(''),content);
assert.ok(sent.length>1);
@@ -89,7 +89,7 @@ test('long brief is sent completely as plain text with each part accounted for',
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 f=fixture({text:()=> 'x'.repeat(12000),sendTelegram:async()=>{attempts++;if(attempts===2)throw new Error('Unavailable');return {id:'test-chat:1'};}});
const result=await f.send(async()=>[]);
assert.equal(attempts,2);
assert.deepEqual(result.kanallar.telegram,{denenen:2,kabul:1,hata:1});
+12
View File
@@ -134,3 +134,15 @@ test('review queue exposes expired interrupted sends but does not allow reviewin
assert.throws(()=>outbox.resolve(`${batch.id}:0`,'admin@example.test',{action:'skip',note:'Bu gönderim halen etkin durumdadır.'}),{status:409});
assert.equal(outbox.queue(121001)[0].state,'uncertain');
});
test('Telegram provider receipt is persisted for each message and missing receipt stays uncertain',async t=>{
const {outbox}=fixture(t);
const telegram={store:{listUsers:()=>[{email:'telegram@example.test'}],getData:(email,key)=>key==='telegram'?{chatId:'123'}:null},
telegramConfigured:()=>true,emailConfigured:()=>false,sendTelegram:async()=>({id:'123:42',chatId:'123',messageId:42})};
const sent=await sender(outbox,telegram)(async()=>[]);
assert.equal(sent.gonderilen,1);assert.equal(outbox.inspect()[0].receipt,'123:42');
const missing=await sender(outbox,{...telegram,clock:()=>new Date('2026-09-14T05:00:00Z'),sendTelegram:async()=>true})(async()=>[]);
assert.equal(missing.hata,1);assert.equal(missing.gonderilen,0);
const uncertain=outbox.inspect().find(row=>row.week==='2026-09-14');
assert.equal(uncertain.state,'uncertain');assert.equal(uncertain.receipt,null);
});
+24
View File
@@ -20,3 +20,27 @@ test('brief plain-text mode reaches Telegram request without HTML parsing or con
else process.env.TELEGRAM_BOT_TOKEN=oldToken;
}
});
test('Telegram receipts preserve chat and message ids and reject incomplete or mismatched acceptance',async()=>{
const oldFetch=globalThis.fetch,oldToken=process.env.TELEGRAM_BOT_TOKEN;
process.env.TELEGRAM_BOT_TOKEN='synthetic-test-token';
let body={ok:true,result:{message_id:42,chat:{id:-100123}}},httpOk=true;
globalThis.fetch=async()=>({ok:httpOk,status:httpOk?200:502,json:async()=>body});
try {
const options={parseMode:null,returnReceipt:true};
assert.deepEqual(await sendTelegram('-100123','Test',options),{id:'-100123:42',chatId:'-100123',messageId:42});
for(const result of [undefined,{}, {message_id:0,chat:{id:-100123}}, {message_id:42,chat:{id:999}},
{message_id:'42',chat:{id:-100123}}, {message_id:42,chat:{id:0}}, {message_id:Number.MAX_SAFE_INTEGER+1,chat:{id:-100123}}]) {
body={ok:true,result};
await assert.rejects(sendTelegram('-100123','Test',options),/belirsiz/);
}
body={ok:true,result:{message_id:42,chat:{id:-100123}}};httpOk=false;
await assert.rejects(sendTelegram('-100123','Test',options),/HTTP 502/);
httpOk=true;body={ok:'true',result:{message_id:42,chat:{id:-100123}}};
await assert.rejects(sendTelegram('-100123','Test',options),/HTTP 200/);
} finally {
globalThis.fetch=oldFetch;
if(oldToken===undefined)delete process.env.TELEGRAM_BOT_TOKEN;
else process.env.TELEGRAM_BOT_TOKEN=oldToken;
}
});