diff --git a/public/app.js b/public/app.js index 333c9d9..9aca2b5 100644 --- a/public/app.js +++ b/public/app.js @@ -1,4 +1,5 @@ import { setupGallery } from './gallery.js'; +import { setupGuestMessages } from './guest-messages.js'; import { setupEventActions } from './event-actions.js'; const tg = window.Telegram?.WebApp; const $ = id => document.getElementById(id); @@ -147,6 +148,7 @@ function inviteText(e,guest){ function share(e) { if(e.invitationMode==='named' && isManager(e))return openNamedLinks(e);openTelegram(`https://t.me/share/url?url=${encodeURIComponent(e.inviteUrl)}&text=${encodeURIComponent(inviteText(e))}`); } function element(tag, text, className) { const node = document.createElement(tag); node.textContent = text; if (className) node.className = className; return node; } function action(text, fn, className = 'secondary') { const b = element('button', text, className); b.type = 'button'; b.onclick = async () => { if(b.disabled)return; b.disabled=true; try { await fn(); } catch(error) { notice(error.message); } finally { b.disabled=false; } }; return b; } +const openGuestMessages=setupGuestMessages({$,document,api,notice,initData,zone:selectedZone}); async function openGuestList(id) { const navigation=pageNavigationGeneration; const {event:e}=await api(`events/${id}`); @@ -404,6 +406,7 @@ function renderEvents() { const actions = element('div', '', 'event-actions'); actions.append(action('💬 Open in chat', () => openTelegram(e.inviteUrl), 'primary')); if (isManager(e) && !e.cancelled) actions.append(action('✏️ Edit event', () => editEvent(e.id))); if(isManager(e))actions.append(action('👥 Guest list',()=>openGuestList(e.id))); + if(e.isOwner && !e.cancelled)actions.append(action('📨 Message guests',()=>openGuestMessages(e))); if(isManager(e) && !e.cancelled)actions.append(action('🎟 Check in guests',()=>openCheckin(e.id))); if(e.ticket)actions.append(action('🎟 Check-in code',()=>openTicket(e.id))); if((e.starPrice || ['bank','link'].includes(e.paymentMethod)) && !isManager(e) && e.status==='yes' && e.approval==='approved' && e.paymentStatus!=='paid')actions.append(action(e.starPrice?'⭐ Pay with Stars':'💳 Payment instructions',()=>openTelegram(e.inviteUrl.split('?')[0]+'?start=pay_'+e.id))); @@ -1038,6 +1041,7 @@ if (!initData) { if (compactPicker) { setupForm(state.events.find(e => e.id === data.session?.event) || null); document.querySelector('.bottom-nav').hidden = true; if (data.session?.token !== query.get('session')) { notice('This picker has expired. Open a new picker from the current chat step.'); $('save-event').disabled = true; } } else if(query.get('invitations'))await openNamedLinks({id:query.get('invitations')},query.get('guest')); else if (query.get('gallery')) await openGallery(query.get('gallery')); + else if (/^gallery_[a-f0-9]{16}$/.test(tg?.initDataUnsafe?.start_param || '')) await openGallery(tg.initDataUnsafe.start_param.slice(8)); else if (query.get('qr')) await showQr(query.get('qr')); else if(query.get('ticket'))await openTicket(query.get('ticket')); else if(query.get('checkin'))await openCheckin(query.get('checkin')); diff --git a/public/gallery.js b/public/gallery.js index 8be1e62..4a8d300 100644 --- a/public/gallery.js +++ b/public/gallery.js @@ -24,12 +24,17 @@ export function setupGallery({ $, api, element, action, go, notice, openTelegram galleryEvent=id;go('gallery');$('gallery-error').hidden=true; $('gallery-title').textContent='Shared media';$('gallery-summary').textContent='Loading files…'; $('gallery-upload').hidden=true;$('gallery-upload').onclick=null; + for(const name of ['gallery-copy','gallery-qr']){$(name).hidden=true;$(name).onclick=null;} $('gallery-list').replaceChildren(element('p','Loading files…','muted')); for(const url of urls) URL.revokeObjectURL(url);urls.clear(); try { const gallery=await api(`events/${id}/gallery`);if(current!==generation)return; $('gallery-title').textContent=gallery.title;$('gallery-summary').textContent=`${gallery.media.length} shared files. Download here or send a file to your Telegram chat.`; $('gallery-upload').hidden=!gallery.canUpload;$('gallery-upload').onclick=()=>openTelegram(gallery.uploadUrl); + $('gallery-copy').hidden=!gallery.galleryUrl; + $('gallery-copy').onclick=async()=>{try{await navigator.clipboard.writeText(gallery.galleryUrl);notice('Shared media link copied.');}catch{notice('Could not copy. Use Share in the QR code window.');}}; + $('gallery-qr').hidden=!gallery.galleryUrl || gallery.qrEnabled===false; + $('gallery-qr').onclick=()=>showQr(id,true); const list=$('gallery-list');list.replaceChildren();if(!gallery.media.length)list.append(element('p','No files have been shared yet.','muted')); for(const file of gallery.media) { const kind=file.previewKind || (['photo','video'].includes(file.type)?file.type:null),previewFile={...file,type:kind}; @@ -50,9 +55,13 @@ export function setupGallery({ $, api, element, action, go, notice, openTelegram } }catch(error){if(current!==generation)return;$('gallery-list').replaceChildren();$('gallery-summary').textContent='';$('gallery-error').textContent=error.message;$('gallery-error').hidden=false;} } - async function showQr(id) { + async function showQr(id,shared=false) { const pending=++qrGeneration; - try{const qr=await api(`events/${id}/upload-qr`);if(pending!==qrGeneration)return;$('upload-qr').src=qr.image; $('qr-description').textContent=qr.anyone ? 'Anyone with this link can add media without an RSVP. They can view Shared media if you enable viewing. Private event details stay hidden.' : 'Existing event guests can scan this code to add media. Enable uploads by link in event settings to let anyone contribute without an RSVP.'; + try{const qr=await api(`events/${id}/${shared?'gallery-qr':'upload-qr'}`);if(pending!==qrGeneration)return;$('upload-qr').src=qr.image; + $('qr-title').textContent=shared?'Shared media QR code':'Upload link & QR code'; + $('upload-qr').alt=shared?'QR code for shared media':'QR code for event uploads'; + $('qr-share').textContent=shared?'Share shared media link':'Share upload link'; + $('qr-description').textContent=shared?'Scan to open Shared media. Event access and viewing permissions still apply.':qr.anyone ? 'Anyone with this link can add media without an RSVP. They can view Shared media if you enable viewing. Private event details stay hidden.' : 'Existing event guests can scan this code to add media. Enable uploads by link in event settings to let anyone contribute without an RSVP.'; $('qr-share').onclick=()=>openTelegram(`https://t.me/share/url?url=${encodeURIComponent(qr.link)}&text=${encodeURIComponent('Share your event photos and files')}`); $('qr-download').onclick=()=>{const bytes=Uint8Array.from(atob(qr.image.split(',')[1]),character=>character.charCodeAt(0));saveBlob(new Blob([bytes],{type:'image/gif'}),'event-upload-qr.gif');};$('qr-dialog').showModal(); }catch(error){if(pending===qrGeneration)notice(error.message);} diff --git a/public/guest-messages.js b/public/guest-messages.js new file mode 100644 index 0000000..72923ea --- /dev/null +++ b/public/guest-messages.js @@ -0,0 +1,93 @@ +export function setupGuestMessages({$,document,api,notice,initData,zone=()=>Intl.DateTimeFormat().resolvedOptions().timeZone,schedule=setTimeout,cancel=clearTimeout}) { + const dialog=$('guest-message-dialog'); + let eventId,token,sent=false,uploading=false,sending=false,generation=0,timer,polls=0; + const groups={yes:'Accepted',no:'Rejected',maybe:'Maybe',later:'Respond later',unanswered:'Unanswered'}; + const node=(tag,text,className)=>{const el=document.createElement(tag);el.textContent=text || '';if(className)el.className=className;return el;}; + const button=(text,fn)=>{const el=node('button',text,'secondary');el.type='button';el.onclick=async()=>{if(el.disabled)return;el.disabled=true;try{await fn();}catch(error){$('guest-message-history-status').textContent=error.message;}finally{el.disabled=false;}};return el;}; + const stop=()=>{generation++;if(timer)cancel(timer);timer=null;}; + $('guest-message-close').onclick=()=>{stop();dialog.close();};dialog.onclose=stop; + async function loadHistory(){ + const request=++generation,id=eventId; + const result=await api(`events/${id}/messages/history`,{}); + if(request!==generation || id!==eventId)return; + const list=$('guest-message-history');list.replaceChildren(); + for(const message of result.messages){ + const card=node('article','','guest-message-record');card.setAttribute('role','listitem'); + const heading=node('div','','guest-message-record-heading'),date=node('time',new Intl.DateTimeFormat('en-AU',{timeZone:zone(),dateStyle:'medium',timeStyle:'short'}).format(new Date(message.createdAt))); + const menu=node('details'),summary=node('summary','⋯');summary.setAttribute('aria-label','More message options');menu.append(summary); + if(message.canDelete){ + menu.append(button('🗑 Delete from guest chats',async()=>{ + menu.open=false; + const confirm=node('div','','guest-message-delete-confirm'); + confirm.append(node('p','Delete this message and its attachments from guests’ chats?')); + const actions=node('div','','event-actions'); + actions.append(button('Confirm delete',async()=>{await api(`events/${id}/messages/delete`,{token:message.token});$('guest-message-history-status').textContent='Deletion requested.';polls=0;await loadHistory();}),button('Keep message',()=>confirm.remove())); + confirm.append(actions);card.append(confirm); + })); + }else menu.append(node('p',message.undone?'Deletion already requested.':'Telegram’s 48-hour deletion window has ended.','small muted')); + heading.append(date,menu);card.append(heading,node('p',message.preview,'guest-message-preview')); + if(message.attachments.length)card.append(node('p','📎 '+message.attachments.join(' · '),'small muted')); + card.append(node('p',message.delivered===null?`Sent to ${message.count} guests · Delivery count unavailable for this older message`:`Delivered: ${message.delivered}/${message.count} · Pending: ${message.undone?0:message.pending} · Failed: ${message.failed}`,'guest-message-counts')); + if(message.undone)card.append(node('p',message.deletePending?`Deleting ${message.deletePending} messages…`:message.deleteFailed?`Deleted: ${message.deleted} messages · Could not delete: ${message.deleteFailed}`:message.deleted?'Deleted from guest chats.':'Unsent messages stopped.','small muted')); + list.append(card); + } + if(!result.messages.length)list.append(node('p','No messages sent yet.','muted')); + if(timer)cancel(timer); + if(dialog.open && !$('guest-message-history-view').hidden && polls++<10 && result.messages.some(m=>!m.undone && m.pending>0 || m.deletePending>0))timer=schedule(()=>{loadHistory().catch(error=>{$('guest-message-history-status').textContent=error.message;});},2000); + } + async function showHistory(){ + $('guest-message-history-view').hidden=false;$('guest-message-form').hidden=true;polls=0; + await loadHistory(); + } + $('guest-message-refresh').onclick=async()=>{try{polls=0;await loadHistory();}catch(error){$('guest-message-history-status').textContent=error.message;}}; + $('guest-message-back').onclick=async()=>{if(uploading || sending)return;try{await showHistory();}catch(error){notice(error.message);}}; + $('guest-message-new').onclick=async()=>{ + if(uploading || sending)return;const control=$('guest-message-new');control.disabled=true; + try{ + const request=++generation,id=eventId,draft=await api(`events/${id}/messages/start`,{}); + if(request!==generation || !dialog.open)return; + stop();token=draft.token;sent=false; + $('guest-message-history-status').textContent=''; + $('guest-message-history-view').hidden=true;$('guest-message-form').hidden=false; + $('guest-message-text').value='';$('guest-message-files').value='';$('guest-message-uploads').replaceChildren();$('guest-message-status').textContent='Only guests who have opened the bot can receive messages. Attachments are also saved in your own bot chat.'; + $('guest-message-compose').hidden=false;$('guest-message-send').hidden=false;$('guest-message-undo').hidden=true;$('guest-message-all').checked=false; + const container=$('guest-message-groups');container.replaceChildren(); + for(const [key,label] of Object.entries(groups)){ + const row=node('label','','check-option'),box=node('input');box.type='checkbox';box.value=key;box.checked=key==='yes';box.onchange=()=>{$('guest-message-all').checked=[...container.querySelectorAll('input')].every(b=>b.checked);}; + row.append(box,node('span',`${label} (${draft.counts[key]})`));container.append(row); + } + }catch(error){$('guest-message-history-status').textContent=error.message;}finally{control.disabled=false;} + }; + $('guest-message-all').onchange=()=>{for(const box of $('guest-message-groups').querySelectorAll('input'))box.checked=$('guest-message-all').checked;}; + $('guest-message-files').onchange=async()=>{ + if(uploading || sent || sending)return;uploading=true;$('guest-message-send').disabled=true; + try{ + for(const file of $('guest-message-files').files){ + if(file.size>20*1024*1024)throw Error('Choose files under 20 MB.'); + const body=new FormData();body.set('file',file); + const response=await fetch(`/api/events/${eventId}/messages/upload`,{method:'POST',headers:{Authorization:'tma '+initData,'X-Draft-Token':token},body}); + const result=await response.json();if(!response.ok)throw Error(result.error || 'Attachment upload failed.'); + $('guest-message-uploads').append(node('li',file.name)); + } + $('guest-message-status').textContent='Attachments ready.'; + }catch(error){$('guest-message-status').textContent=error.message;} + finally{uploading=false;$('guest-message-send').disabled=false;$('guest-message-files').value='';} + }; + $('guest-message-form').onsubmit=async event=>{ + event.preventDefault();if(uploading || sent || sending)return;sending=true; + const control=$('guest-message-send');control.disabled=true; + try{ + const selected=[...$('guest-message-groups').querySelectorAll('input:checked')].map(box=>box.value); + const result=await api(`events/${eventId}/messages/send`,{token,groups:selected,text:$('guest-message-text').value}); + sent=true;$('guest-message-history-status').textContent=`Sending to ${result.count} guests now.`; + await showHistory(); + }catch(error){$(sent?'guest-message-history-status':'guest-message-status').textContent=error.message;} + finally{sending=false;control.disabled=false;} + }; + return async e=>{ + if(uploading || sending){notice('Wait for the current operation to finish.');return;} + stop();eventId=e.id;$('guest-message-event').textContent=e.title;$('guest-message-history-status').textContent='';$('guest-message-history').replaceChildren(); + $('guest-message-history-view').hidden=false;$('guest-message-form').hidden=true;dialog.showModal(); + try{polls=0;await loadHistory();}catch(error){$('guest-message-history-status').textContent=error.message;} + }; +} diff --git a/public/index.html b/public/index.html index fed136d..5dcb654 100644 --- a/public/index.html +++ b/public/index.html @@ -69,6 +69,7 @@
Media & QR codes +

Share photos with attendees in one event collection, with no built-in photo-count limit. Telegram and hosting limits apply.

@@ -119,13 +120,30 @@

Everything stays together.

Respond to invitations in the bot chat. Guest lists and media options depend on the organiser’s settings. Confirmed guests can find their invitation ticket here and in chat.

Open XEvents in Telegram ↗
- +

Shared media

Shared photo

Your ticket

Your check-in code

Check in guests

Ask the guest to open their check-in code.

Guest list

+ +

Message guests

+

+

Delivery counts update while sending. Telegram allows deletion within 48 hours.

+

+ +
diff --git a/public/style.css b/public/style.css index e1b8fd2..65c4d58 100644 --- a/public/style.css +++ b/public/style.css @@ -10,6 +10,15 @@ fieldset.panel{min-width:0}fieldset legend{font-size:18px;font-weight:700;letter #admin-view p { white-space:pre-wrap; overflow-wrap:anywhere; } #admin-view summary { cursor:pointer; padding:12px 0; font-weight:700; } #admin-whitelist summary{padding:0;line-height:1.5}#admin-whitelist[open] summary{margin-bottom:16px}.whitelist-warning{padding:12px 14px;border:1px solid #e7c78d;border-radius:10px;background:#fff4de;color:#72531e;font-size:13px}.whitelist-warning strong{font-weight:750}#admin-whitelist .check-option{margin-top:16px}#admin-whitelist .check-option .check-note{float:none}#whitelist-access,#whitelist-status{font-size:13px}#whitelist-status:empty{display:none}.whitelist-add-row{display:flex;gap:8px;align-items:stretch}.whitelist-add-row input{flex:1;min-width:0}.whitelist-add-row button{flex-shrink:0}.whitelist-users{list-style:none;padding:0;margin:12px 0 0}.whitelist-user{display:flex;align-items:center;justify-content:space-between;gap:12px;border-top:1px solid var(--line);padding:10px 0}.whitelist-user code{min-width:0;overflow-wrap:anywhere;font-size:15px}.whitelist-user button{margin:0;flex-shrink:0;font-size:12px}#admin-whitelist input:disabled{cursor:not-allowed}#admin-whitelist button:focus-visible{outline:2px solid var(--accent);outline-offset:2px}body.dark .whitelist-warning{background:#3e3323;color:#f6deac;border-color:#6b5938} +#guest-message-dialog{width:calc(100% - 32px);max-width:560px;max-height:85dvh;overflow:auto;background:var(--panel);color:var(--text);border:1px solid var(--line);border-radius:18px;padding:20px} +#guest-message-dialog::backdrop{background:rgba(0,0,0,.65)} +#guest-message-dialog .dialog-heading{position:sticky;top:-20px;z-index:2;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 0;background:var(--panel)} +#guest-message-groups{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0 12px} +#guest-message-dialog .check-option{margin:0;min-height:44px;padding:10px 0;align-items:center} +#guest-message-dialog p,#guest-message-uploads{font-size:13px;overflow-wrap:anywhere} +#guest-message-dialog textarea{margin-top:8px;resize:vertical} +#guest-message-dialog .event-actions button{flex:1;min-height:46px} +.guest-message-record{margin:14px 0;padding:14px;border:1px solid var(--line);border-radius:12px;background:var(--bg)}.guest-message-record-heading{display:flex;align-items:center;justify-content:space-between;gap:12px}.guest-message-record-heading time{font-size:12px;color:var(--muted)}.guest-message-record-heading details{position:relative;flex-shrink:0}.guest-message-record-heading summary{list-style:none;min-width:44px;min-height:44px;display:grid;place-items:center;border:1px solid var(--line);border-radius:8px;cursor:pointer;font-size:24px}.guest-message-record-heading details>button,.guest-message-record-heading details>p{position:absolute;right:0;z-index:3;width:220px;background:var(--panel);border:1px solid var(--line);padding:12px;border-radius:8px;color:var(--text)}.guest-message-preview{white-space:pre-wrap}.guest-message-counts{padding:10px;border-radius:8px;background:var(--panel)}.guest-message-delete-confirm{padding-top:10px;border-top:1px solid var(--line)}.guest-message-record-heading summary:focus-visible{outline:2px solid var(--accent);outline-offset:3px} #confirm-dialog { width:calc(100% - 36px); max-width:460px; padding:24px; border:1px solid var(--line); border-radius:18px; background:var(--panel); color:var(--text); } #confirm-dialog::backdrop { background:rgba(0,0,0,.5); } #confirm-dialog p { font-size:14px; overflow-wrap:anywhere; } diff --git a/scripts/check-guest-messages.mjs b/scripts/check-guest-messages.mjs new file mode 100644 index 0000000..0d3fc61 --- /dev/null +++ b/scripts/check-guest-messages.mjs @@ -0,0 +1,114 @@ +import {Miniflare,convertV4MiniflareOptions} from 'miniflare'; +import {readFile} from 'node:fs/promises'; +import {createHmac} from 'node:crypto'; +import assert from 'node:assert/strict'; + +export async function checkGuestMessages(){ + const calls=[],eventId='1010101010101010';let messageId=100,holdNext=false,release,blocked=false; + const worker=new Miniflare(convertV4MiniflareOptions({workers:[{ + name:'guest-messages',modules:true,scriptPath:'.wrangler/build/worker.js',compatibilityDate:'2026-10-05',compatibilityFlags:['nodejs_compat'],d1Databases:['DB'], + bindings:{APP_ENV:'production',BOT_USERNAME:'fictionalBot',APP_URL:'https://messages.test/app',TELEGRAM_BOT_TOKEN:'fictional-message-token',TELEGRAM_WEBHOOK_SECRET:'fictional-secret'}, + outboundService:async request=>{ + const method=new URL(request.url).pathname.split('/').at(-1); + const params=request.headers.get('Content-Type')?.includes('application/json')?await request.json():Object.fromEntries(await request.formData()); + calls.push({method,params}); + if(blocked && params.chat_id===710003 && ['sendMessage','copyMessage'].includes(method))return Response.json({ok:false,error_code:403}); + if(holdNext && params.text?.startsWith('📨 Message from the organiser')){holdNext=false;await new Promise(resolve=>{release=resolve;});} + return Response.json({ok:true,result:{message_id:++messageId}}); + } + }]})); + try{ + const db=await worker.getD1Database('DB'); + for(const file of ['0001_initial.sql','0002_delivery_lease.sql','0003_mini_app.sql'])await db.exec((await readFile('migrations/'+file,'utf8')).replace(/\n/g,' ')); + const event={id:eventId,owner:710001,title:'Fictional dinner',invitationMode:'legacy',cohost:{id:710003},guests:{710002:{status:'yes',name:'Example guest'},710003:{status:'no',name:'Other guest'}},permissions:{guestList:true},media:[]}; + await db.prepare("INSERT INTO records(kind,id,data) VALUES ('events',?,?)").bind(eventId,JSON.stringify(event)).run(); + const auth=id=>{ + const p=new URLSearchParams({auth_date:String(Math.floor(Date.now()/1000)),user:JSON.stringify({id,first_name:'Fictional user'})}); + const value=[...p.entries()].sort(([a],[b])=>a.localeCompare(b)).map(([k,v])=>`${k}=${v}`).join('\n'); + const key=createHmac('sha256','WebAppData').update('fictional-message-token').digest();p.set('hash',createHmac('sha256',key).update(value).digest('hex'));return 'tma '+p; + }; + const api=async(action,input={},id=710001)=>{ + const r=await worker.dispatchFetch(`https://messages.test/api/events/${eventId}/messages/${action}`,{method:'POST',headers:{Authorization:auth(id),'Content-Type':'application/json'},body:JSON.stringify(input)}); + return {status:r.status,data:await r.json()}; + }; + const settle=async(kick)=>{ + for(let i=0;i<100;i++){ + if((await db.prepare("SELECT COUNT(*) AS n FROM outbox WHERE id<>'fixture-delayed'").first()).n===0)return; + if(kick && i%5===0)await kick(); + await new Promise(r=>setTimeout(r,20)); + } + throw Error('Fictional message delivery did not finish.'); + }; + assert.equal((await api('start',{},710003)).status,400); + assert.equal((await api('history',{},710003)).status,400); + const draft=await api('start');assert.equal(draft.status,200);assert.equal(draft.data.counts.yes,1);assert.equal(draft.data.counts.no,1); + assert.equal((await api('send',{groups:['yes'],text:'Example'})).status,400); + assert.equal((await api('send',{token:draft.data.token,groups:['invalid'],text:'Example'})).status,400); + const body=new FormData();body.set('file',new File(['fictional bytes'],'example.txt',{type:'text/plain'})); + const uploadRequest=new Request('https://messages.test/upload',{method:'POST',body}); + const uploaded=await worker.dispatchFetch(`https://messages.test/api/events/${eventId}/messages/upload`,{method:'POST',headers:{Authorization:auth(710001),'X-Draft-Token':draft.data.token,'Content-Type':uploadRequest.headers.get('Content-Type')},body:await uploadRequest.arrayBuffer()}); + assert.equal(uploaded.status,200,await uploaded.text()); + const input={token:draft.data.token,groups:['yes'],text:'Fictional announcement'}; + await db.prepare("INSERT INTO outbox(id,method,params,due) VALUES ('fixture-delayed','sendMessage',?,unixepoch()+3600)").bind(JSON.stringify({chat_id:710001,text:'Delayed routine message'})).run(); + const sent=await api('send',input);assert.equal(sent.status,200);assert.equal(sent.data.count,1); + await settle(); + assert.equal((await api('send',input)).status,200);await settle(); + const deliveries=calls.filter(c=>c.method==='copyMessage' || c.params.text==='Fictional announcement'); + assert.equal(deliveries.length,2);assert.ok(deliveries.every(c=>c.params.chat_id===710002)); + assert.equal(calls.some(c=>c.params.text==='Delayed routine message'),false); + const history=await api('history');assert.equal(history.status,200);assert.equal(history.data.messages[0].delivered,1);assert.equal(history.data.messages[0].pending,0);assert.equal(history.data.messages[0].preview,'Fictional announcement');assert.deepEqual(history.data.messages[0].attachments,['example.txt']); + assert.ok(calls.every(c=>!('__broadcast' in c.params) && !('__broadcastDelivered' in c.params))); + assert.equal((await api('undo',{token:sent.data.token},710003)).status,400); + const pref=JSON.parse((await db.prepare("SELECT data FROM records WHERE kind='preferences' AND id='710001'").first()).data);pref.broadcasts[sent.data.token].undoUntil=Date.now()-1; + await db.prepare("UPDATE records SET data=? WHERE kind='preferences' AND id='710001'").bind(JSON.stringify(pref)).run(); + assert.equal((await api('undo',{token:sent.data.token})).status,400); + assert.equal((await api('delete',{token:sent.data.token})).status,200);await settle(); + assert.equal(calls.filter(c=>c.method==='deleteMessage' && c.params.chat_id===710002).length,3); + assert.equal((await api('history')).data.messages[0].deleted,3); + assert.ok(calls.every(c=>!('__broadcastDelete' in c.params))); + // Undo while a Telegram send is in flight: delete its receipt and skip the + // remaining queued content, including attachments. + const second=await api('start');holdNext=true;const before=calls.length; + const next=await api('send',{token:second.data.token,groups:['no'],text:'Should not be delivered'}); + assert.equal(next.status,200); + for(let i=0;!release && i<100;i++)await new Promise(r=>setTimeout(r,20)); + assert.equal(typeof release,'function'); + const undone=await api('undo',{token:next.data.token});assert.equal(undone.status,200); + release();await settle(); + assert.equal(calls.slice(before).some(c=>c.params.text==='Should not be delivered'),false); + assert.equal(calls.slice(before).filter(c=>c.method==='deleteMessage' && c.params.chat_id===710003).length,1); + blocked=true; + const third=await api('start');await api('send',{token:third.data.token,groups:['no'],text:'Blocked guest fixture'});await settle(); + const failed=(await api('history')).data.messages.find(m=>m.token===third.data.token); + assert.equal(failed.failed,1);assert.equal(failed.delivered,0);assert.equal(failed.pending,0); + await db.prepare('DELETE FROM outbox').run(); + event.permissions.uploadMedia=true;event.qrEnabled=true; + await db.prepare("UPDATE records SET data=? WHERE kind='events' AND id=?").bind(JSON.stringify(event),eventId).run(); + const qrResponse=await worker.dispatchFetch('https://messages.test/telegram',{method:'POST',headers:{'Content-Type':'application/json','X-Telegram-Bot-Api-Secret-Token':'fictional-secret'},body:JSON.stringify({update_id:9001,callback_query:{id:'fictional-query',from:{id:710001,first_name:'Fictional host'},data:'upload-qr:'+eventId}})}); + assert.equal(qrResponse.status,200);await settle(); + const photo=calls.find(c=>c.method==='sendPhoto');assert.ok(photo);assert.equal(photo.params.photo.type,'image/png'); + assert.equal(photo.params.__photoUpload,undefined);assert.equal(photo.params.chat_id,'710001'); + const callback=async(data,update_id,message)=>worker.dispatchFetch('https://messages.test/telegram',{method:'POST',headers:{'Content-Type':'application/json','X-Telegram-Bot-Api-Secret-Token':'fictional-secret'},body:JSON.stringify({update_id,callback_query:{id:'fictional-category-query',from:{id:710001,first_name:'Fictional host'},data,message}})}); + assert.equal((await callback('bm:'+eventId,9002)).status,200);await settle(); + const session=JSON.parse((await db.prepare("SELECT data FROM records WHERE kind='sessions' AND id=?").bind('710001').first()).data); + const start=calls.length; + assert.equal((await callback(`bm-group:${eventId}:no:${session.token}`,9003,{chat:{id:710001},message_id:444})).status,200);await settle(); + const edited=calls.slice(start).find(c=>c.method==='editMessageText');assert.ok(edited);assert.equal(edited.params.message_id,444); + assert.ok(edited.params.reply_markup.inline_keyboard.flat().some(b=>b.text.startsWith('❌'))); + assert.equal(edited.params.__broadcastNotice,undefined);assert.equal(calls.slice(start).some(c=>c.method==='sendMessage'),false); + // A guest delivery completing while a webhook owns the state lease must + // retry receipt persistence without leaving content waiting for a minute. + blocked=false;release=undefined;holdNext=true; + const contentionDraft=await api('start'),contentionStart=calls.length; + await api('send',{token:contentionDraft.data.token,groups:['yes'],text:'Lease contention fixture'}); + for(let i=0;!release && i<100;i++)await new Promise(resolve=>setTimeout(resolve,20)); + assert.equal(typeof release,'function'); + await db.prepare("INSERT INTO lease(id,owner,expires) VALUES (1,'fictional-contention',unixepoch()+60)").run(); + const unlock=(async()=>{await new Promise(resolve=>setTimeout(resolve,250));await db.prepare("DELETE FROM lease WHERE owner='fictional-contention'").run();})(); + release();await unlock;await settle(); + const contentionHistory=(await api('history')).data.messages.find(m=>m.token===contentionDraft.data.token); + assert.equal(contentionHistory.delivered,1);assert.equal(contentionHistory.pending,0); + assert.equal(calls.slice(contentionStart).filter(c=>c.params.text==='Lease contention fixture').length,1); + console.log('Guest messaging Worker integration passed: creator authorization, filters, attachments, durable receipts, idempotent send and undo. Telegram mocked.'); + }finally{await worker.dispose();} +} diff --git a/scripts/check-worker.mjs b/scripts/check-worker.mjs index 7d445f6..200bb64 100644 --- a/scripts/check-worker.mjs +++ b/scripts/check-worker.mjs @@ -2,12 +2,13 @@ import { Miniflare, convertV4MiniflareOptions } from 'miniflare'; import { readFile } from 'node:fs/promises'; import assert from 'node:assert/strict'; import { createHmac } from 'node:crypto'; +import {checkGuestMessages} from './check-guest-messages.mjs'; const telegramCalls=[]; const serveAssets = async request => { const pathname=new URL(request.url).pathname; const file=pathname==='/' ? 'index.html' : pathname.slice(1); - if(!['index.html','app.js','errors.js','gallery.js','event-actions.js','style.css'].includes(file))return new Response('Not found',{status:404}); + if(!['index.html','app.js','errors.js','gallery.js','event-actions.js','guest-messages.js','style.css'].includes(file))return new Response('Not found',{status:404}); const headers={'Content-Type':file.endsWith('.js') ? 'application/javascript' : file.endsWith('.css') ? 'text/css' : 'text/html'}; if(file==='index.html') { headers.ETag='"fixture-index"';headers['Last-Modified']='Thu, 01 Oct 2026 00:00:00 GMT'; @@ -1298,3 +1299,4 @@ try { console.log('Worker integration passed: compact invitation tools, private history and archives, edits, response changes, revocation/deletion and idempotent notifications; fixed personal attendee counts and live count button captions, invitation response summaries, add/revoke with notify or silent removal, exact durable notifications, native button event creation, profiles, private photos, ticket bookings, private expiring check-in codes and legacy QR compatibility, personal RSVP links, multiple tagged co-host links and isolated revocation, legacy co-host migration, approvals, payments, durable refunds and media. Telegram mocked.'); } finally { await mf.dispose(); } await checkTestAccess(); +await checkGuestMessages(); diff --git a/scripts/database-snapshot.mjs b/scripts/database-snapshot.mjs index 44bb4f5..01cf039 100644 --- a/scripts/database-snapshot.mjs +++ b/scripts/database-snapshot.mjs @@ -177,6 +177,8 @@ export function prepareSnapshot({ sourceSql, developmentSchema, developmentSetti for (const trigger of objects.filter(row => row.type === 'trigger')) db.exec(`DROP TRIGGER ${identifier(trigger.name)}`); const removedSessions = Number(rows(db, "SELECT count(*) AS n FROM records WHERE kind='sessions'")[0].n); db.exec("DELETE FROM records WHERE kind='sessions'"); + // Telegram message IDs and undo state belong to the sending bot only. + db.exec("UPDATE records SET data=json_remove(data,'$.broadcasts') WHERE kind='preferences' AND json_type(data,'$.broadcasts') IS NOT NULL"); for (const table of runtimeTables) db.exec(`DELETE FROM ${identifier(table)}`); db.exec('DELETE FROM app_settings'); const set = db.prepare('INSERT INTO app_settings(key,value) VALUES (?,?)'); diff --git a/src/bot.js b/src/bot.js index 345075b..4eb12c3 100644 --- a/src/bot.js +++ b/src/bot.js @@ -1,4 +1,6 @@ import {paymentMethod,paidEvent} from './event-payment.js'; +import {qrPhoto} from './telegram-upload.js'; +import {startBroadcast,toggleBroadcast,collectBroadcast,sendBroadcast,undoBroadcast} from './guest-messages.js'; import {invitationMode,claimInvitation,invitationSettings,invitationParticipantMode,oneTimeInvites,invitationAvailable,consumeInvitation,reconcileInvitationClaims} from './invitations.js'; import {isManager,cohostEntries,cohostIds,cohostGuard,cohostLink,createCohostInvite,revokeCohost,claimCohost} from './cohosts.js'; import {invitationLinksCard,invitationCopyCard} from './invitation-links.js'; @@ -83,11 +85,19 @@ export class Bot { return this.afterIdentity(id,e,s); } hasPending(id) { return Object.values(this.db.events).some(e => this.allowed(e,id) && !isManager(e,id) && invitationMode(e)!=='tickets' && !e.cancelled && e.guests[id]?.status === 'later'); } - home(id, text = 'Welcome to XEvents 🎉\nTap Create event or My events below.') { return this.send(id, text, homeKeyboard()); } + home(id, text = 'Welcome to XEvents 🎉\nTap Create event or My events below.',immediate=false) { return this.api('sendMessage',{chat_id:id,text,reply_markup:homeKeyboard(),...(immediate?{__broadcastNotice:true}:{})}); } session(id, value) { if (value) this.db.sessions[id] = value; else delete this.db.sessions[id]; } link(e) { return `https://t.me/${this.username}?start=e_${e.id}`; } miniButton(text, params = '') { return { text, web_app: { url: this.appUrl + params } }; } time(e, id) { return eventTime(e, this.db.preferences[id]?.timezone); } + updatedAt(e, id, now = new Date()) { + const options = { day: 'numeric', month: 'short', year: 'numeric', hour: 'numeric', minute: '2-digit', second: '2-digit', timeZoneName: 'short' }; + const zone = this.db.preferences[id]?.timezone || e.timezone || 'UTC'; + let formatted; + try { formatted = new Intl.DateTimeFormat('en-AU', { ...options, timeZone: zone }).format(now); } + catch { formatted = new Intl.DateTimeFormat('en-AU', { ...options, timeZone: 'UTC' }).format(now); } + return 'Last updated: ' + formatted; + } allowed(e, id) { return e && (isManager(e,id) || !!e.guests[id] && invitationAvailable(e,id)); } mediaAllowed(e, id) { return e && (this.allowed(e, id) || (this.db.preferences[id]?.mediaAccess?.[e.id] === e.uploadToken && !!uploadLink(e, this.username))); } async mediaCard(id, e) { @@ -249,17 +259,21 @@ export class Bot { if (host) extras.push(...(mode==='named' ? [this.appUrl ? this.miniButton('Personal invitations',`?invitations=${e.id}`) : button('Personal invitations',`invite-links:${e.id}`)] : [{ text: mode==='tickets' ? 'Share ticket link' : '📨 Invite people', url: `https://t.me/share/url?url=${encodeURIComponent(this.link(e))}&text=${encodeURIComponent([e.inviteMessage,e.title].filter(Boolean).join('\n\n'))}` }]), button('⚙️ Manage', `h:${e.id}`)); if(paidEvent(e) && (owner || !host))extras.push({text:owner?'Payments & refunds':'Payment support',url:`https://t.me/${this.username}?start=payments`}); if (can(e, id, 'guestList')) extras.push(button('👥 Guest list', `g:${e.id}`)); + if (owner) extras.push(button('📨 Message guests', `bm:${e.id}`)); if (!host && accepted && paidEvent(e) && (!requiresApproval(e) || e.guests[id].approval === 'approved') && e.guests[id].payment?.status !== 'paid') extras.push(button(e.starPrice?'⭐ Pay with Stars':'Payment instructions', `star-terms:${e.id}`)); if (!host && accepted && (requiresApproval(e) || mode==='tickets')) extras.push(button('🎟 My status', `status:${e.id}`)); if (can(e, id, 'uploadMedia')) extras.push(button('📎 Add media', `u:${e.id}`)); if (this.appUrl && can(e, id, 'viewMedia')) extras.push(this.miniButton('🗂 Shared media', `?gallery=${e.id}`)); + if(host && shareUploadLink(e,this.username)){ + rows.push(...paired(extras));extras.length=0; + const sharing=[{text:'📋 Copy upload link',copy_text:{text:shareUploadLink(e,this.username)}}]; + if(e.qrEnabled!==false)sharing.push(button('▦ Show QR code',`upload-qr:${e.id}`)); + rows.push(sharing); + } if(this.appUrl && host)extras.push(this.miniButton(e.qrEnabled!==false?'Scan tickets':'Check tickets',`?checkin=${e.id}`)); if(this.appUrl && !host && confirmed(e,e.guests[id]))extras.push(this.miniButton('🎟 Check-in code',`?ticket=${e.id}`)); - if (upcoming(e)) extras.push(button('🔔 Reminder', `reminder:${e.id}`)); - if (canSeeLocation(e, id) && e.location) extras.push(e.location.length <= 256 ? { text: '📋 Copy address', copy_text: { text: e.location } } : button('📋 Copy address', `address:${e.id}`)); - if (host && shareUploadLink(e,this.username)) extras.push(this.appUrl && e.qrEnabled!==false ? this.miniButton('Upload QR code',`?qr=${e.id}`) : {text:'Share upload link',url:`https://t.me/share/url?url=${encodeURIComponent(shareUploadLink(e,this.username))}`}); } - if (owner) extras.push(button('Delete event', `delete:${e.id}`)); + if (owner) extras.push(e.cancelled ? button('Delete event', `delete:${e.id}`) : button('Cancel event', `x:${e.id}`)); rows.push(...paired(extras)); if(!rsvpOnly)rows.push([button(menu.events, 'nav:events'), button(menu.home, 'nav:home')]); const visibility = can(e, id, 'guestList') ? 'Guest names and RSVP comments can be seen in the guest list.' : 'The organiser has kept the guest list private. Your response and comment are shared with the organiser.'; @@ -274,6 +288,10 @@ export class Bot { const limit=e.banner && withBanner ? Math.max(0,1024-summary.length) : 1500; const description=[e.inviteMessage,e.description].filter(Boolean).join('\n\n');text=summary+(description.length>limit ? description.slice(0,Math.max(0,limit-1)).replace(/[\uD800-\uDBFF]$/,'')+'…' : description); } + if (!rsvpOnly) { + const titleEnd = text.indexOf('\n'); + text = text.slice(0, titleEnd) + '\n' + this.updatedAt(e, id) + text.slice(titleEnd); + } if (e.banner && withBanner) { const captionContent = text.length <= 1024 ? text : text.slice(0, 940).replace(/[\uD800-\uDBFF]$/, ''); const caption = captionContent + (text.length > 1024 ? '\n\nTap Full details to read more.' : ''); @@ -306,6 +324,13 @@ export class Bot { const paymentLink=text.match(/^\/start pay_([a-f0-9]{16})$/); if(paymentLink) {const e=this.db.events[paymentLink[1]];return this.allowed(e,id) ? this.paymentInfo(id,e) : this.home(id,'Open your event invitation before paying.');} const current = this.db.sessions[id]; + if(current?.step==='broadcast' && !text.startsWith('/') && !Object.values(menu).includes(text)) { + const event=this.db.events[current.event]; + if(!event || event.owner!==id || event.cancelled){this.session(id);return this.home(id,'This event is unavailable for messages.');} + if(text==='✖ Cancel'){this.session(id);await this.home(id,'Message cancelled.');return this.card(id,event);} + if(text==='Done' || text==='📨 Send message')return sendBroadcast(this,id,event); + return collectBroadcast(this,id,event,m); + } if(text===menu.new)return startChatCreation(this,id); const navigation = { [menu.new]: '/new', [menu.events]: '/events', [menu.help]: '/help', [menu.home]: '/start', [menu.cancel]: '/cancel', [menu.app]: '/app', [menu.picker]: '/picker', [menu.pending]: '/pending' }; if (['📱 Open app','📱 Open planner','📱 Open XEvents planner'].includes(text)) text = '/app'; @@ -517,6 +542,21 @@ export class Bot { const [action, eid, arg, version] = (q.data || '').split(':'); const e = this.db.events[eid]; if(q.id)await this.api('answerCallbackQuery', { callback_query_id: q.id }).catch(() => {}); + if(action==='bm-undo')return undoBroadcast(this,id,eid); + if(action==='upload-qr'){ + const link=e && shareUploadLink(e,this.username); + if(!e || !isManager(e,id) || !link || e.qrEnabled===false)return this.send(id,'Upload QR code is unavailable.'); + return this.api('sendPhoto',{chat_id:id,__photoUpload:qrPhoto(link),caption:`${e.title}\nScan to add event photos and files.`,reply_markup:keyboard([{text:'📋 Copy upload link',copy_text:{text:link}}])}); + } + if(['bm','bm-group','bm-send','bm-cancel'].includes(action)) { + if(!e || e.owner!==id || e.cancelled)return this.send(id,'Only the creator of an active event can message guests.'); + if(action==='bm')return startBroadcast(this,id,e); + const s=this.db.sessions[id]; + if(s?.step!=='broadcast' || s.event!==eid || (action==='bm-group' ? version!==s.token : arg!==s.token))return this.send(id,'These message controls have expired. Open Message guests again.'); + if(action==='bm-group')return toggleBroadcast(this,id,e,arg,q.message); + if(action==='bm-cancel'){this.session(id);await this.home(id,'Message cancelled.');return this.card(id,e);} + return sendBroadcast(this,id,e); + } if (action === 'sr' || action === 'src') { const order=orderFor(this.db,eid,arg); if (!order || order.owner !== id || !['paid','refund_failed'].includes(order.status)) return this.send(id,'No refundable payment is available.'); @@ -613,7 +653,7 @@ export class Bot { return this.chooseParticipants(id,e,session,Number(arg)); } if (required && !can(e, id, required)) return this.send(id, 'The organiser has not enabled this option for guests.'); - if (action === 'v') { this.session(id); await this.home(id, 'Use the event buttons below.'); return this.card(id, e); } + if (action === 'v') { this.session(id); return this.card(id, e); } if (action === 'details') return this.card(id, e, false); if(action==='invite-links')return this.personalLinks(id,e,arg); if(action==='invite-copy')return invitationCopyCard(this,id,e,arg); @@ -647,13 +687,24 @@ export class Bot { return this.prompt(id, arg === 'yes' ? `${requiresApproval(e) ? 'The organiser will send invitation details and your ticket after approving your response.\n\n' : ''}What name should the organiser see? Enter a custom name, or tap Use Telegram name.` : `Selected: ${labels[arg]}. Add a comment ${permissions(e).guestList ? 'visible in the guest list' : 'for the organiser only'}, or tap Skip to save your response.`); } if (action === 'g') { - let text = `👥 ${e.title}\n`; - for (const [status, label] of [...Object.entries(labels), ['pending', '⏳ Awaiting approval']]) { - text += `\n${label}\n`; - const group = guests(e).filter(g => status === 'yes' ? confirmed(e, g) : status === 'pending' ? g.status === 'yes' && requiresApproval(e) && g.approval === 'pending' : g.status === status); + const active=Object.entries(e.guests).filter(([uid])=>Number(uid)!==e.owner && invitationAvailable(e,Number(uid))).map(([,g])=>g); + const unopened=Object.entries(e.invitees || {}).filter(([token])=>!active.some(g=>g.invitationToken===token)).map(([,g])=>g); + const accepted=active.filter(g=>confirmed(e,g)); + const pending=active.filter(g=>g.status==='yes' && requiresApproval(e) && g.approval!=='approved'); + const payment=active.filter(g=>g.status==='yes' && !confirmed(e,g) && !pending.includes(g)); + const groups=[ + ['✅ Accepted',accepted],['❌ Rejected',active.filter(g=>g.status==='no')], + ['🤔 Tentative',active.filter(g=>g.status==='maybe')], + ['⏳ Respond later',active.filter(g=>g.status==='later' && hasRecordedResponse(g))], + ['📭 Not responded',[...active.filter(g=>!hasRecordedResponse(g)),...unopened]], + ['⏳ Awaiting approval',pending],...(payment.length ? [['⭐ Awaiting payment',payment]] : []) + ]; + let text = `👥 ${e.title}\n${this.updatedAt(e, id)}\n\nTotal invitations: ${active.length+unopened.length}\nPeople accepted: ${accepted.reduce((sum,g)=>sum+participantCount(e,g),0)}\n`; + for (const [label,group] of groups) { + text += `\n${label} (${group.length})\n`; text += group.length ? group.map(g => `• ${g.name}${g.status === 'yes' ? ' (' + participantCount(e, g) + ' people)' : ''}${g.comment ? ' — ' + g.comment : ''}`).join('\n') + '\n' : 'Nobody yet\n'; } - return this.long(id, text, keyboard([button('Back to event', `v:${eid}`)])); + return this.long(id, text, keyboard([button('↻ Refresh', `g:${eid}`),button('Back to event', `v:${eid}`)])); } if (action === 'c') { return this.send(id, isManager(e,id) ? 'You’re an event host — no RSVP needed.' : 'Choose an RSVP to add or update your comment.'); @@ -697,8 +748,8 @@ export class Bot { const rows=[[button('Guest responses', `a:${eid}`), button('Guest options', `permissions:${eid}`)], [button('Edit title', `edit:${eid}:title`), button('Edit time', `edit:${eid}:when`)], [button('Edit location', `edit:${eid}:location`), button('Edit description', `edit:${eid}:description`)], [button('Edit invite message',`edit:${eid}:inviteMessage`),button('🖼 Edit banner', `banner:${eid}`)]]; if(invitationMode(e)==='named')rows.push([button('Invitation links',`invite-links:${eid}:0`)]); if(e.owner===id)rows.push([button('Co-hosts',`cohost:${eid}`),button('Replace invite link', `rotate:${eid}`)],[button('Cancel event', `x:${eid}`), button('Delete event', `delete:${eid}`)]); - rows.push([button('Back to event',`v:${eid}`)]); - return this.send(id,(e.owner===id?'Organiser':'Co-host')+' tools\n'+priceText(e,this.db.preferences[id],this.pricing || this.db.preferences._pricing),keyboard(...rows)); + rows.push([button('↻ Refresh',`h:${eid}`),button('Back to event',`v:${eid}`)]); + return this.send(id,`${e.title}\nEvent settings\n`+priceText(e,this.db.preferences[id],this.pricing || this.db.preferences._pricing),keyboard(...rows)); } if (action === 'permissions' || action === 'toggle') { if(invitationMode(e)==='named'){ @@ -721,7 +772,7 @@ export class Bot { const counts=responseCounts(e),accepted=guests(e).filter(g=>g.status==='yes'); const rows = guests(e).map(g => `${g.name} — ${g.status === 'yes' && !confirmed(e, g) ? requiresApproval(e) && g.approval==='pending' ? 'Awaiting approval':'Awaiting payment' : labels[g.status]}${g.status==='yes'?' · '+participantCount(e,g)+' people':''}${g.phone?'\nPhone: '+g.phone:''}${(g.answers || []).map(a=>'\n'+a.question+': '+(a.answer || 'Skipped')).join('')}${g.comment?'\n'+g.comment:''}`); const unopened=Object.entries(e.invitees || {}).filter(([token])=>!Object.values(e.guests).some(g=>g.invitationToken===token)).map(([,g])=>g.name+' — Not opened'); - await this.long(id, `${e.title}\nAccepted: ${accepted.length} responses · ${accepted.reduce((sum,g)=>sum+participantCount(e,g),0)} people\nConfirmed: ${counts.participants} people · Approval: ${counts.pending} · Payment: ${counts.awaitingPayment}\nMaybe: ${counts.maybe} · Rejected: ${counts.no} · Unanswered: ${counts.later+unopened.length}\n\n${[...rows,...unopened].join('\n\n') || 'No guests yet.'}`, keyboard([button('Back to organiser tools', `h:${eid}`)])); + await this.long(id, `${e.title}\nAccepted: ${accepted.length} responses · ${accepted.reduce((sum,g)=>sum+participantCount(e,g),0)} people\nConfirmed: ${counts.participants} people · Approval: ${counts.pending} · Payment: ${counts.awaitingPayment}\nMaybe: ${counts.maybe} · Rejected: ${counts.no} · Unanswered: ${counts.later+unopened.length}\n\n${[...rows,...unopened].join('\n\n') || 'No guests yet.'}`, keyboard([button('↻ Refresh', `a:${eid}`),button('Event settings', `h:${eid}`)])); if(e.owner===id && ['bank','link'].includes(paymentMethod(e)))for(const [uid,g] of Object.entries(e.guests)){ if(g.payment?.status==='reported')await this.send(id,g.name+' — payment reported',keyboard([button('Confirm received',`manual-confirm:${e.id}:${uid}`),button('Clear report',`manual-clear:${e.id}:${uid}`)])); if(g.payment?.status==='paid')await this.send(id,g.name+' — payment confirmed',keyboard([button('Clear payment record',`manual-clear:${e.id}:${uid}`)])); @@ -739,6 +790,9 @@ export class Bot { if (action === 'delete') return this.send(id, 'Permanently delete this event and its saved responses and media references? Accepted and tentative guests will be notified. Previously sent Telegram messages and files remain in their chats.', keyboard([button('Yes, delete event', `delete-confirm:${eid}`), button('Keep event', `v:${eid}`)])); if (action === 'delete-confirm') return this.endEvent(id, e, true); if (action === 'x') return this.send(id, 'Cancel this event? Accepted and tentative guests will be notified and new responses/uploads will close.', keyboard([button('Yes, cancel event', `z:${eid}`), button('Keep event', `v:${eid}`)])); - if (action === 'z') { await this.endEvent(id, e); return this.card(id, e); } + if (action === 'z') { + await this.endEvent(id, e); + return this.send(id, 'Delete the cancelled event too, or keep its records?', keyboard([button('Delete event', `delete:${eid}`), button('Keep records', `v:${eid}`)])); + } } } diff --git a/src/guest-message-api.js b/src/guest-message-api.js new file mode 100644 index 0000000..7cc932f --- /dev/null +++ b/src/guest-message-api.js @@ -0,0 +1,80 @@ +import { mutateState } from './worker-store.js'; +import { startBroadcast,broadcastRecipients,sendBroadcast,undoBroadcast,broadcastHistory } from './guest-messages.js'; +import { InputError } from './time.js'; +import { mayUseTestApp } from './test-access.js'; +import { snapshotDeliveryAllowed } from './snapshot-mode.js'; + +export async function guestMessageApi(request,env,user,eventId,action,respond) { + let phase='validate'; + const check=(data,token)=>{ + const e=data.events[eventId],s=data.sessions[user.id]; + if(!e || e.owner!==user.id || e.cancelled)throw new InputError('Only the creator of an active event can message guests.'); + if(token && (s?.step!=='broadcast' || s.event!==eventId || s.token!==token))throw new InputError('This draft expired. Reopen Message guests.'); + return {e,s}; + }; + try { + if(action==='upload'){ + const token=request.headers.get('X-Draft-Token'); + if(!token)throw new InputError('Open Message guests first.'); + await mutateState(env,data=>{const {s}=check(data,token);if(s.items.length>=20)throw new InputError('Maximum 20 attachments.');}); + if(!await snapshotDeliveryAllowed(env,'sendDocument',{chat_id:user.id}))throw new InputError('Uploads are unavailable for this test account.'); + const reader=request.body?.getReader();if(!reader)throw new InputError('Choose a file.'); + phase='read-upload'; + const chunks=[];let size=0; + while(true){const part=await reader.read();if(part.done)break;size+=part.value.length;if(size>21*1024*1024){await reader.cancel();return respond({error:'Choose a file under 20 MB.'},413);}chunks.push(part.value);} + const form=await new Response(new Blob(chunks),{headers:{'Content-Type':request.headers.get('Content-Type') || ''}}).formData(); + phase='save-upload'; + const file=form.get('file'); + if(!(file instanceof File) || !file.size || file.size>20*1024*1024)throw new InputError('Choose a file under 20 MB.'); + if(!await mayUseTestApp(env,user))throw new InputError('This test account cannot upload.'); + const upload=new FormData();upload.set('chat_id',String(user.id));upload.set('document',file);upload.set('disable_notification','true'); + const result=await(await fetch(`https://api.telegram.org/bot${env.TELEGRAM_BOT_TOKEN}/sendDocument`,{method:'POST',body:upload,signal:AbortSignal.timeout(20000)})).json(); + if(!result.ok || !Number.isSafeInteger(result.result?.message_id))throw new InputError('Could not upload attachment. Please retry.'); + await mutateState(env,data=>{const {s}=check(data,token);if(s.items.length>=20)throw new InputError('Maximum 20 attachments.');s.items.push(result.result.message_id);(s.attachments ||= []).push(file.name.slice(0,200));}); + return respond({uploaded:true}); + } + const reader=request.body?.getReader();if(!reader)throw new InputError('Invalid request.'); + const chunks=[];let size=0; + while(true){const part=await reader.read();if(part.done)break;size+=part.value.length;if(size>24000){await reader.cancel();return respond({error:'Too much text.'},413);}chunks.push(part.value);} + let input; + try{input=JSON.parse(await new Blob(chunks).text());if(!input || typeof input!=='object' || Array.isArray(input))throw Error();}catch{throw new InputError('Invalid request.');} + if(action==='history'){ + const [event,preference]=await Promise.all([ + env.DB.prepare("SELECT data FROM records WHERE kind='events' AND id=?").bind(eventId).first(), + env.DB.prepare("SELECT data FROM records WHERE kind='preferences' AND id=?").bind(String(user.id)).first() + ]); + if(!event || JSON.parse(event.data).owner!==user.id)throw new InputError('Only the event creator can view message history.'); + return respond({messages:broadcastHistory({preferences:{[user.id]:preference?JSON.parse(preference.data):{}}},user.id,eventId)}); + } + return respond(await mutateState(env,async(data,bot)=>{ + if(action==='undo' || action==='delete'){ + const r=data.preferences[user.id]?.broadcasts?.[input.token]; + if(!r || r.event!==eventId)throw new InputError('This message is unavailable.'); + if(!r.undone && (action==='delete' ? Date.now()-(r.createdAt || r.undoUntil-300000)>=48*3600000 : Date.now()>r.undoUntil))throw new InputError(action==='delete'?'Telegram can only delete messages sent within 48 hours.':'The five-minute undo window has ended.'); + if(!r.undone)await undoBroadcast(bot,user.id,input.token,action==='delete'); + return {undone:true}; + } + if(action==='send'){ + if(typeof input.token!=='string' || !/^[a-f0-9]{12}$/.test(input.token))throw new InputError('This draft expired. Reopen Message guests.'); + const previous=data.preferences[user.id]?.broadcasts?.[input.token]; + if(previous?.event===eventId)return {token:input.token,count:previous.count,undoUntil:previous.undoUntil,undone:previous.undone}; + } + const {e,s}=check(data,action==='send'?input.token:undefined); + if(action==='start'){ + startBroadcast(bot,user.id,e,true); + return {token:data.sessions[user.id].token,counts:Object.fromEntries(['yes','no','maybe','later','unanswered'].map(key=>[key,broadcastRecipients(e,[key]).length]))}; + } + if(action!=='send')throw new InputError('Unknown messaging action.'); + if(!Array.isArray(input.groups) || !input.groups.length || input.groups.some(g=>!['yes','no','maybe','later','unanswered'].includes(g)))throw new InputError('Select at least one guest group.'); + if(typeof input.text!=='string' || input.text.length>4000)throw new InputError('Use up to 4000 characters.'); + s.groups=[...new Set(input.groups)];s.text=input.text.trim(); + if(!s.items.length && !s.text)throw new InputError('Add a message or attachment first.'); + const count=broadcastRecipients(e,s.groups).length;if(!count)throw new InputError('No reachable guests selected.'); + const token=s.token;await sendBroadcast(bot,user.id,e); + return {token,count,undoUntil:data.preferences[user.id].broadcasts[token].undoUntil}; + })); + } catch(error) { + if(!(error instanceof InputError))console.error(JSON.stringify({event:'guest_message_failed',phase,name:error.name})); + return respond({error:error instanceof InputError?error.message:'Could not process the message. Please retry.'},error instanceof InputError?400:503); + } +} diff --git a/src/guest-messages.js b/src/guest-messages.js new file mode 100644 index 0000000..5561965 --- /dev/null +++ b/src/guest-messages.js @@ -0,0 +1,109 @@ +import { randomBytes } from 'node:crypto'; +import { invitationAvailable } from './invitations.js'; +import { hasRecordedResponse } from './invitation-management.js'; +import { invitationHistory } from './invitation-history.js'; + +const groups = { yes: 'Accepted', no: 'Rejected', maybe: 'Maybe', later: 'Respond later', unanswered: 'Unanswered' }; +const markup = rows => ({ inline_keyboard: rows }); +const button = (text, callback_data) => ({ text, callback_data }); +export const broadcastKeyboard = () => ({keyboard:[[{text:'📨 Send message'},{text:'✖ Cancel'}]],resize_keyboard:true,is_persistent:true}); +export function broadcastRecord(data, owner, token) { return data.preferences[owner]?.broadcasts?.[token]; } +export function broadcastHistory(data,owner,eventId) { + return Object.entries(data.preferences[owner]?.broadcasts || {}).filter(([,r])=>r.event===eventId).map(([token,r])=>{ + const recipients=new Map(); + for(const receipt of r.receipts)recipients.set(String(receipt.chatId),(recipients.get(String(receipt.chatId)) || 0)+1); + const delivered=r.expectedMessages ? [...recipients.values()].filter(n=>n>=r.expectedMessages).length : null; + const failed=Object.keys(r.failures || {}).filter(id=>!r.expectedMessages || (recipients.get(id) || 0)receipt.deleted).length; + const deleteFailed=r.receipts.filter(receipt=>receipt.deleteFailed).length; + return {token,createdAt,preview:r.preview || 'Previous message (content unavailable)',attachments:r.attachments || [],count:r.count,delivered,failed,pending:delivered===null?null:Math.max(0,r.count-delivered-failed),undone:!!r.undone,deleted,deleteFailed,deletePending:r.undone?Math.max(0,r.receipts.length-deleted-deleteFailed):0,canDelete:!r.undone && Date.now()-createdAt<48*3600000}; + }).sort((a,b)=>b.createdAt-a.createdAt).slice(0,100); +} +export async function queueBroadcastDelete(bot,owner,token,receipt) { + if(receipt.deleted)return; + const result=await bot.api('deleteMessage',{chat_id:receipt.chatId,message_id:receipt.messageId,__broadcastDelete:{owner,token}}); + if(result===true)receipt.deleted=true; +} +export function broadcastDeliveryResult(data,owner,token,chatId,result,messageId) { + const r=broadcastRecord(data,owner,token);if(!r)return; + if(messageId!==undefined){ + const receipt=r.receipts.find(item=>item.chatId===chatId && item.messageId===messageId); + if(receipt){receipt.deleted=result.ok===true;receipt.deleteFailed=!result.ok;} + }else if(!result.ok){(r.failures ||= {})[chatId]=result.snapshotBlocked?'test-restricted':result.error_code===403?'unavailable':'failed';} +} +export function broadcastRecipients(e, selected) { + return Object.entries(e.guests).filter(([id,g]) => Number.isSafeInteger(Number(id)) && Number(id)>0 && Number(id)!==e.owner && invitationAvailable(e,Number(id)) && selected.includes(hasRecordedResponse(g,invitationHistory(e,g.invitationToken),Number(id)) ? g.status : 'unanswered')).map(([id])=>Number(id)); +} +export async function broadcastReceipt(bot, owner, token, chatId, messageId) { + const record=broadcastRecord(bot.db,owner,token); + if(!record || !Number.isSafeInteger(messageId) || messageId<=0)return; + const key=chatId+':'+messageId; + if(record.receipts.some(r=>r.key===key))return; + const receipt={key,chatId,messageId,sentAt:Date.now()};record.receipts.push(receipt); + if(record.undone)await queueBroadcastDelete(bot,owner,token,receipt); +} +export async function undoBroadcast(bot,id,token,deleteOld=false) { + const record=broadcastRecord(bot.db,id,token); + if(!record)return bot.send(id,'This message is unavailable.'); + if(record.undone)return bot.send(id,'Undo already requested.'); + if(deleteOld ? Date.now()-(record.createdAt || record.undoUntil-300000)>=48*3600000 : Date.now()>record.undoUntil)return bot.send(id,deleteOld?'Telegram can only delete messages sent within 48 hours.':'The five-minute undo window has ended.'); + record.undone=true; + record.deleteRequestedAt=Date.now(); + for(const r of record.receipts)await queueBroadcastDelete(bot,id,token,r); + return bot.api('sendMessage',{chat_id:id,text:'Undo requested. Queued messages are stopped; delivered messages are being deleted.',__broadcastNotice:true}); +} +export async function startBroadcast(bot,id,e,quiet=false) { + bot.session(id,{step:'broadcast',event:e.id,token:randomBytes(6).toString('hex'),groups:['yes'],items:[],chatComposer:!quiet}); + if(!quiet){await broadcastMenu(bot,id,e);return bot.send(id,'Write your message or attach files. When finished, press Send message.',broadcastKeyboard());} +} +export function broadcastMenu(bot,id,e,message) { + const s=bot.db.sessions[id],counts=Object.fromEntries(Object.keys(groups).map(key=>[key,broadcastRecipients(e,[key]).length])); + const options=Object.entries(groups).map(([key,label])=>button(`${s.groups.includes(key)?'✅':'❌'} ${label} (${counts[key]})`,`bm-group:${e.id}:${key}:${s.token}`)); + const text=`${e.title}\nMessage guests · ${broadcastRecipients(e,s.groups).length} recipients\nChoose recipient categories below.`; + const reply_markup=markup([ + [button(`${s.groups.length===Object.keys(groups).length?'✅':'❌'} All` ,`bm-group:${e.id}:all:${s.token}`)], + ...Array.from({length:Math.ceil(options.length/2)},(_,i)=>options.slice(i*2,i*2+2)), + ]); + if(message?.chat?.id===id && Number.isSafeInteger(message.message_id) && message.message_id>0)return bot.api('editMessageText',{chat_id:id,message_id:message.message_id,text,reply_markup,__broadcastNotice:true}); + return bot.send(id,text,reply_markup); +} +export function toggleBroadcast(bot,id,e,key,message) { + const s=bot.db.sessions[id]; + if(key==='all')s.groups=s.groups.length===Object.keys(groups).length?[]:Object.keys(groups); + else if(Object.hasOwn(groups,key))s.groups=s.groups.includes(key)?s.groups.filter(k=>k!==key):[...s.groups,key]; + return broadcastMenu(bot,id,e,message); +} +export function collectBroadcast(bot,id,e,m) { + const s=bot.db.sessions[id]; + if(!(m.text || m.photo || m.video || m.document || m.audio || m.voice || m.animation) || !Number.isSafeInteger(m.message_id))return bot.send(id,'Send text, a photo, video or file.'); + if(s.items.includes(m.message_id))return; + if(s.items.length>=20)return bot.send(id,'Maximum 20 items. Tap Send message below.'); + s.items.push(m.message_id); + (s.previews ||= []).push((m.text || m.caption || '').slice(0,500)); + if(!m.text)(s.attachments ||= []).push(m.document?.file_name || m.audio?.file_name || (m.photo?'Photo':m.video?'Video':m.voice?'Voice message':'Attachment')); + // Albums arrive as separate updates; avoid a reply for every attachment. +} +export async function sendBroadcast(bot,id,e) { + const s=bot.db.sessions[id],recipients=broadcastRecipients(e,s.groups); + if(!recipients.length)return bot.send(id,'No reachable guests selected. Choose another group.'); + if(!s.items.length && !s.text)return bot.send(id,'Write your message or attach files first. Then press Send message.',broadcastKeyboard()); + const pref=bot.db.preferences[id] ||= {},records=pref.broadcasts ||= {}; + const token=s.token; + records[token]={event:e.id,count:recipients.length,createdAt:Date.now(),preview:(s.text || s.previews?.filter(Boolean).join('\n') || 'Message with attachments').slice(0,4000),attachments:s.attachments || [],expectedMessages:1+(s.text?1:0)+s.items.length,undoUntil:Date.now()+300000,receipts:[],undone:false}; + const items=[...s.items];bot.session(id); + for(const chatId of recipients){ + const header=await bot.api('sendMessage',{chat_id:chatId,text:`📨 Message from the organiser · ${e.title}`,__broadcast:{owner:id,token}}); + await broadcastReceipt(bot,id,token,chatId,header?.message_id); + if(s.text){ + const result=await bot.api('sendMessage',{chat_id:chatId,text:s.text,__broadcast:{owner:id,token}}); + await broadcastReceipt(bot,id,token,chatId,result?.message_id); + } + for(const messageId of items){ + const result=await bot.api('copyMessage',{chat_id:chatId,from_chat_id:id,message_id:messageId,__broadcast:{owner:id,token}}); + await broadcastReceipt(bot,id,token,chatId,result?.message_id); + } + } + if(s.chatComposer)await bot.home(id,'Message queued. Back to the main menu.',true); + return bot.api('sendMessage',{chat_id:id,text:`Sending to ${recipients.length} guests now. Undo is available for 5 minutes.`,reply_markup:markup([[button('↩ Undo message',`bm-undo:${token}`)],[button('Back to event',`v:${e.id}`)]]),__broadcastNotice:true}); +} diff --git a/src/main.js b/src/main.js index affeba2..4938f44 100644 --- a/src/main.js +++ b/src/main.js @@ -1,4 +1,5 @@ import { resolve } from 'node:path'; +import {telegramPayload} from './telegram-upload.js'; import { open, unlink } from 'node:fs/promises'; import { Store } from './store.js'; import { Bot } from './bot.js'; @@ -19,11 +20,12 @@ let bot; for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => { stopping = true; }); async function api(method, params = {}) { + const {__broadcast,__broadcastDelete,__broadcastNotice,...safeParams}=params; // Never log URLs or raw fetch errors: Telegram URLs contain the token. let response; try { response = await fetch(`https://api.telegram.org/bot${token}/${method}`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(params), signal: AbortSignal.timeout(45000) + method: 'POST', ...telegramPayload(safeParams), signal: AbortSignal.timeout(45000) }); } catch { throw new Error(`Telegram ${method}: network failure`); } const body = await response.json(); diff --git a/src/media-api.js b/src/media-api.js index 269bffd..ea64123 100644 --- a/src/media-api.js +++ b/src/media-api.js @@ -8,7 +8,7 @@ import {invitationAvailable} from './invitations.js'; export async function mediaApi(request, env, user) { const url = new URL(request.url); - const match = url.pathname.match(/^\/api\/events\/([a-f0-9]{16})\/(gallery|upload-qr|media\/([a-f0-9]{12})(?:\/(send|thumbnail))?)$/); + const match = url.pathname.match(/^\/api\/events\/([a-f0-9]{16})\/(gallery|gallery-qr|upload-qr|media\/([a-f0-9]{12})(?:\/(send|thumbnail))?)$/); if (!match) return null; const headers = { 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' }; const json = (value, status = 200) => Response.json(value, { status, headers }); @@ -26,7 +26,13 @@ export async function mediaApi(request, env, user) { return json({ link, anyone: !!e.allowLinkUploads, image: qr.createDataURL(6, 24) }); } if (!can(e, user.id, 'viewMedia')) return json({ error: 'The organiser has not enabled the gallery for guests.' }, 403); - if (match[2] === 'gallery' && request.method === 'GET') return json({ id: e.id, title: e.title, cancelled: !!e.cancelled, canUpload: !e.cancelled && can(e, user.id, 'uploadMedia'), uploadUrl: `https://t.me/${env.BOT_USERNAME}?start=a_${e.id}`, media: (e.media || []).map(f => ({ id: f.id, type: f.type, previewKind:mediaPreview(f),hasThumbnail:!!f.thumbnail,filename: f.filename, caption: f.caption, name: f.name, at: f.at, size: f.size || null })) }); + const galleryUrl=`https://t.me/${env.BOT_USERNAME}?startapp=gallery_${e.id}`; + if(match[2]==='gallery-qr' && request.method==='GET'){ + if(e.qrEnabled===false)return json({error:'QR codes are disabled for this event.'},400); + const qr=qrcode(0,'M');qr.addData(galleryUrl);qr.make(); + return json({link:galleryUrl,image:qr.createDataURL(6,24)}); + } + if (match[2] === 'gallery' && request.method === 'GET') return json({ id: e.id, title: e.title, galleryUrl, qrEnabled:e.qrEnabled!==false, cancelled: !!e.cancelled, canUpload: !e.cancelled && can(e, user.id, 'uploadMedia'), uploadUrl: `https://t.me/${env.BOT_USERNAME}?start=a_${e.id}`, media: (e.media || []).map(f => ({ id: f.id, type: f.type, previewKind:mediaPreview(f),hasThumbnail:!!f.thumbnail,filename: f.filename, caption: f.caption, name: f.name, at: f.at, size: f.size || null })) }); const f = e.media?.find(item => item.id === match[3]); if (!f) return json({ error: 'This file is no longer in the event.' }, 404); if (match[4] === 'send' && request.method === 'POST') { diff --git a/src/mini-api.js b/src/mini-api.js index edb4951..5090f22 100644 --- a/src/mini-api.js +++ b/src/mini-api.js @@ -1,4 +1,5 @@ import {parseEventPayment,paymentMethod,paidEvent} from './event-payment.js'; +import {guestMessageApi} from './guest-message-api.js'; import {issueTicket,verifyTicket} from './tickets.js'; import {profileFields,profilePreference,profilePhotoApi} from './profile.js'; import {readOnlinePricing} from './exchange.js'; @@ -257,6 +258,8 @@ async function miniApiForUser(request, env, user) { return respond({branding:{hasIcon:!!(branding && JSON.parse(branding.data).botIcon)},pricing:await readOnlinePricing(env,pricing ? JSON.parse(pricing.data) : {}),currencyCodes,localCurrency:localCurrency(pref), user: { id: user.id, firstName: user.first_name || 'Guest', isSuperAdmin: isSuperAdmin(user, env) }, preference: profilePreference(pref), session: pickerSession, events: results.map(r=>JSON.parse(r.data)).filter(e=>isManager(e,user.id) || (e.guests[user.id] && invitationAvailable(e,user.id))).map(e=>publicEvent(e,user.id,env.BOT_USERNAME)) }); } if (request.method !== 'POST') return respond({ error: 'Not found' }, 404); + const messageMatch=path.match(/^\/api\/events\/([a-f0-9]{16})\/messages\/(start|send|upload|undo|history|delete)$/); + if(messageMatch)return guestMessageApi(request,env,user,messageMatch[1],messageMatch[2],respond); const raw = await request.text(); if (raw.length > 24000) return respond({ error: 'Too much text.' }, 413); let input; diff --git a/src/telegram-upload.js b/src/telegram-upload.js new file mode 100644 index 0000000..d68a881 --- /dev/null +++ b/src/telegram-upload.js @@ -0,0 +1,24 @@ +import qrcode from 'qrcode-generator'; +import { deflateSync } from 'node:zlib'; + +function chunk(type,data){ + const bytes=Buffer.concat([Buffer.from(type),data]);let crc=0xffffffff; + for(const byte of bytes){crc^=byte;for(let i=0;i<8;i++)crc=(crc>>>1)^((crc&1)?0xedb88320:0);} + const size=Buffer.alloc(4),sum=Buffer.alloc(4);size.writeUInt32BE(data.length);sum.writeUInt32BE((crc^0xffffffff)>>>0); + return Buffer.concat([size,bytes,sum]); +} +export function qrPhoto(link){ + const qr=qrcode(0,'M');qr.addData(link);qr.make();const scale=6,margin=4,size=(qr.getModuleCount()+margin*2)*scale; + const raw=Buffer.alloc((size+1)*size,255); + for(let y=0;y=0 && c>=0 && rsetTimeout(resolve,100)); + } if (!lock) throw new BusyError('Please try again in a moment.'); try { if (env.snapshotUser && !await mayUseTestApp(env, env.snapshotUser)) throw new TestAccessError(); diff --git a/src/worker.js b/src/worker.js index 433f0a6..b694404 100644 --- a/src/worker.js +++ b/src/worker.js @@ -1,4 +1,7 @@ import { mutateState, BusyError } from './worker-store.js'; +import {telegramPayload} from './telegram-upload.js'; +import {broadcastRecord,broadcastReceipt,broadcastDeliveryResult} from './guest-messages.js'; +import {invitationAvailable} from './invitations.js'; import { miniApi } from './mini-api.js'; import { isSuperAdmin, rememberUser } from './admin.js'; import { sendDueReminders } from './reminders.js'; @@ -21,7 +24,7 @@ async function telegram(env, method, params) { let response; try { response = await fetch(`https://api.telegram.org/bot${env.TELEGRAM_BOT_TOKEN}/${method}`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(params), signal: AbortSignal.timeout(method === 'answerPreCheckoutQuery' ? 5000 : 10000) + method: 'POST', ...telegramPayload(params), signal: AbortSignal.timeout(method === 'answerPreCheckoutQuery' ? 5000 : 10000) }); } catch { return { ok: false, error_code: 503 }; } try { return await response.json(); } catch { return { ok: false, error_code: 503 }; } @@ -64,31 +67,86 @@ export async function processUpdate(env, update) { } } -export async function drainOutbox(env) { +export function unavailablePhotoFallback(params, result) { + if (result.ok || result.error_code !== 400 || typeof params.caption !== 'string' || !params.caption.trim() || !/file[_ ]?id|file identifier|wrong remote file|photo|image|media|failed to get http url/i.test(result.description || '')) return null; + const { chat_id, caption, caption_entities, reply_markup, message_thread_id, reply_parameters, disable_notification, protect_content } = params; + return { chat_id, text: caption + '\n\nImage unavailable. The organiser can upload it again.', + ...(caption_entities ? { entities: caption_entities } : {}), ...(reply_markup ? { reply_markup } : {}), + ...(message_thread_id !== undefined ? { message_thread_id } : {}), ...(reply_parameters ? { reply_parameters } : {}), + ...(disable_notification !== undefined ? { disable_notification } : {}), ...(protect_content !== undefined ? { protect_content } : {}) }; +} + +export async function drainOutbox(env, immediate=false, attempt=0) { const owner = crypto.randomUUID(); const lock = await env.DB.prepare('INSERT INTO delivery_lease(id,owner,expires) VALUES (1,?,unixepoch()+60) ON CONFLICT(id) DO UPDATE SET owner=excluded.owner,expires=excluded.expires WHERE delivery_lease.expiressetTimeout(resolve,200));return drainOutbox(env,immediate,attempt+1);} + return; + } const deadline = Date.now() + 20000; try { - const { results } = await env.DB.prepare('SELECT id,due FROM outbox ORDER BY rowid LIMIT 20').all(); + deliveryLoop: do { + const query=immediate ? "SELECT id,due FROM outbox ORDER BY CASE WHEN json_type(params,'$.__broadcastDelete') IS NOT NULL THEN 0 WHEN json_type(params,'$.__broadcastNotice') IS NOT NULL THEN 1 WHEN json_type(params,'$.__broadcast') IS NOT NULL THEN 2 ELSE 3 END,rowid LIMIT 20" : 'SELECT id,due FROM outbox ORDER BY rowid LIMIT 20'; + const { results } = await env.DB.prepare(query).all(); + if(!results.length)break; for (const { id, due } of results) { - if (Date.now() > deadline || due > Math.floor(Date.now() / 1000)) break; + if (Date.now() > deadline || due > Math.floor(Date.now() / 1000)) break deliveryLoop; const row = await env.DB.prepare('UPDATE outbox SET due=unixepoch()+60, attempts=attempts+1 WHERE id=? AND due<=unixepoch() RETURNING *').bind(id).first(); if (!row) continue; - const result = await telegram(env, row.method, JSON.parse(row.params)); + const {__broadcast:tracking,__broadcastDelete:deletion,__broadcastNotice,__broadcastDelivered:delivered,...deliveryParams}=JSON.parse(row.params); + try{ + if(tracking && !delivered){ + const allowed=await mutateState(env,data=>{ + const record=broadcastRecord(data,tracking.owner,tracking.token); + const event=record && data.events[record.event]; + return !!(record && !record.undone && !record.failures?.[deliveryParams.chat_id] && event && !event.cancelled && event.owner===tracking.owner && event.guests[deliveryParams.chat_id] && invitationAvailable(event,deliveryParams.chat_id)); + }); + if(!allowed){await env.DB.prepare('DELETE FROM outbox WHERE id=?').bind(id).run();continue;} + } + let result = delivered ? {ok:true,result:{message_id:delivered}} : await telegram(env, row.method, deliveryParams); + if(tracking && result.ok && Number.isSafeInteger(result.result?.message_id) && result.result.message_id>0) { + if(!delivered){ + row.params=JSON.stringify({...deliveryParams,__broadcast:tracking,__broadcastDelivered:result.result.message_id}); + await env.DB.prepare('UPDATE outbox SET method=?,params=? WHERE id=?').bind(row.method,row.params,id).run(); + } + await mutateState(env,(_data,bot)=>broadcastReceipt(bot,tracking.owner,tracking.token,deliveryParams.chat_id,result.result.message_id)); + } + const fallback = row.method === 'sendPhoto' && unavailablePhotoFallback(JSON.parse(row.params), result); + if (fallback) { + // Keep the same durable delivery identity. A retry sends the text card, not + // the permanently invalid photo. Snapshot recipient checks still apply. + row.method = 'sendMessage';row.params = JSON.stringify(fallback); + await env.DB.prepare('UPDATE outbox SET method=?,params=? WHERE id=?').bind(row.method, row.params, id).run(); + result = await telegram(env, row.method, fallback); + } if (row.method === 'refundStarPayment' && !result.snapshotBlocked && (result.ok || [400,403].includes(result.error_code))) { const params=JSON.parse(row.params); await mutateState(env, (data,bot) => refundResult(bot,params.user_id,params.telegram_payment_charge_id,result.ok)); } if (result.ok || [400, 403].includes(result.error_code)) { + if(deletion || tracking && !result.ok){ + const target=deletion || tracking; + await mutateState(env,data=>broadcastDeliveryResult(data,target.owner,target.token,deliveryParams.chat_id,result,deletion?deliveryParams.message_id:undefined)); + } await env.DB.prepare('DELETE FROM outbox WHERE id=?').bind(id).run(); if (!result.ok) console.log(JSON.stringify({ event: 'delivery_rejected', method: row.method, code: result.error_code })); } else { const seconds = Math.max(5, Math.min(result.parameters?.retry_after || 2 ** Math.min(row.attempts, 10), 3600)); await env.DB.prepare('UPDATE outbox SET due=unixepoch()+? WHERE id=?').bind(seconds, id).run(); - break; + break deliveryLoop; + } + }catch(error){ + // A state lease collision must not strand this claimed message for a minute. + // Successful broadcast sends already persist their Telegram message ID, + // so retrying receipt persistence cannot send the content twice. + if(error instanceof BusyError){ + await env.DB.prepare('UPDATE outbox SET due=unixepoch() WHERE id=?').bind(id).run(); + if(Date.now()setTimeout(resolve,100));continue deliveryLoop;} + } + throw error; } } + } while(Date.now(){ + const f=fixture(),e=await f.create(); + await f.cb(1,`h:${e.id}`); + let card=f.calls.at(-1); + assert.match(card.text,/^Birthday\nEvent settings\n/); + assert.ok(card.reply_markup.inline_keyboard.flat().some(b=>b.text==='↻ Refresh' && b.callback_data===`h:${e.id}`)); + await f.cb(1,`a:${e.id}`); + card=f.calls.at(-1); + const refresh=card.reply_markup.inline_keyboard.flat().find(b=>b.text==='↻ Refresh'); + assert.equal(refresh.callback_data,`a:${e.id}`); + assert.match(card.text,/Accepted: 0 responses/); + e.guests[2]={name:'Example guest',status:'yes',participants:2}; + await f.cb(1,refresh.callback_data); + assert.match(f.calls.at(-1).text,/Accepted: 1 responses/); + assert.match(f.calls.at(-1).text,/Example guest/); + for(const action of ['a','h']){ + await f.cb(3,`${action}:${e.id}`); + assert.doesNotMatch(f.calls.at(-1).text,/Example guest|Event settings/); + } +}); + +test('opening an event sends the event card directly without a placeholder message',async()=>{ + const f=fixture(),e=await f.create();e.banner='fictional-banner';f.calls.length=0; + await f.cb(1,`v:${e.id}`); + assert.equal(f.calls.filter(call=>['sendMessage','sendPhoto'].includes(call.method)).length,1); + assert.equal(f.calls.at(-1).method,'sendPhoto'); + assert.ok(f.calls.at(-1).reply_markup.inline_keyboard.flat().some(b=>b.callback_data===`h:${e.id}`)); + assert.equal(f.calls.some(call=>call.text==='Use the event buttons below.'),false); +}); + +test('guest list keeps refresh while event card hides it and guest list refresh rechecks visibility',async()=>{ + const f=fixture(),e=await f.create();e.guests[2]={name:'Example attendee',status:'yes',participants:1}; + await f.cb(1,`v:${e.id}`); + assert.ok(!f.calls.at(-1).reply_markup.inline_keyboard.flat().some(b=>b.text==='↻ Refresh')); + await f.cb(1,`g:${e.id}`); + const refresh=f.calls.at(-1).reply_markup.inline_keyboard.flat().find(b=>b.text==='↻ Refresh'); + assert.equal(refresh.callback_data,`g:${e.id}`); + e.guests[2].name='Updated attendee';await f.cb(1,refresh.callback_data); + assert.match(f.calls.at(-1).text,/Updated attendee/); + e.permissions.guestList=false;await f.cb(2,refresh.callback_data); + assert.doesNotMatch(f.calls.at(-1).text,/Updated attendee/); + assert.match(f.calls.at(-1).text,/not enabled/); +}); + +test('guest list counts invitations, attendees and unanswered named links without merging later responses',async()=>{ + const f=fixture(),e=await f.create(); + e.invitationMode='named';e.oneTimeInvite=false;e.requireApproval=true; + e.invitees={a:{name:'Accepted',participants:2},b:{name:'Rejected'},c:{name:'Tentative'},d:{name:'Later'},f:{name:'Opened'},g:{name:'Unopened'},h:{name:'Pending',participants:3}}; + e.guests={ + 1:{name:'Host',status:'yes'},2:{name:'Accepted',status:'yes',approval:'approved',participants:2,invitationToken:'a'}, + 3:{name:'Rejected',status:'no',invitationToken:'b'},4:{name:'Tentative',status:'maybe',invitationToken:'c'}, + 5:{name:'Later',status:'later',responseRecorded:true,invitationToken:'d'},6:{name:'Opened',status:'later',invitationToken:'f'}, + 7:{name:'Pending',status:'yes',approval:'pending',participants:3,invitationToken:'h'}, + 8:{name:'Revoked',status:'yes',invitationToken:'removed'} + }; + await f.cb(1,`g:${e.id}`);let text=f.calls.at(-1).text; + assert.match(text,/Total invitations: 7\nPeople accepted: 5/); + assert.match(text,/Accepted \(2\)/);assert.match(text,/Awaiting approval \(0\)/); + for(const label of ['Rejected','Tentative','Respond later'])assert.ok(text.includes(label+' (1)')); + assert.match(text,/Not responded \(2\)\n• Opened\n• Unopened/);assert.doesNotMatch(text,/Revoked|• Host/); + e.guests[6]={...e.guests[6],status:'no',responseRecorded:true};await f.cb(1,`g:${e.id}`); + text=f.calls.at(-1).text;assert.match(text,/Rejected \(2\)/);assert.match(text,/Not responded \(1\)/); +}); + +test('guest list keeps pending approvals and payments out of confirmed acceptance counts',async()=>{ + const f=fixture(),e=await f.create();e.requireApproval=true;e.starPrice=10;e.askParticipantCount=true; + e.guests={2:{name:'Confirmed',status:'yes',approval:'approved',participants:2,payment:{status:'paid'}},3:{name:'Pending',status:'yes'},4:{name:'Unpaid',status:'yes',approval:'approved'}}; + await f.cb(1,`g:${e.id}`);const text=f.calls.at(-1).text; + assert.match(text,/Total invitations: 3\nPeople accepted: 2/); + for(const label of ['Accepted','Awaiting approval','Awaiting payment'])assert.ok(text.includes(label+' (1)')); +}); + +test('event card offers cancellation before optional deletion and keeps records by default',async()=>{ + const f=fixture(),e=await f.create(); + await f.cb(1,`v:${e.id}`); + let buttons=f.calls.at(-1).reply_markup.inline_keyboard.flat(); + assert.ok(buttons.some(b=>b.text==='Cancel event' && b.callback_data===`x:${e.id}`)); + assert.equal(buttons.some(b=>b.text==='Delete event'),false); + await f.cb(1,`x:${e.id}`); + assert.equal(e.cancelled,false); + await f.cb(1,`z:${e.id}`); + assert.equal(e.cancelled,true); + assert.equal(f.store.data.events[e.id],e); + buttons=f.calls.at(-1).reply_markup.inline_keyboard.flat(); + assert.ok(buttons.some(b=>b.text==='Delete event' && b.callback_data===`delete:${e.id}`)); + assert.ok(buttons.some(b=>b.text==='Keep records' && b.callback_data===`v:${e.id}`)); + await f.cb(1,`delete:${e.id}`); + assert.equal(f.store.data.events[e.id],e); + await f.cb(1,`delete-confirm:${e.id}`); + assert.equal(f.store.data.events[e.id],undefined); +}); + +test('refresh timestamps use the viewer timezone and remain visible on long banner cards',async(t)=>{ + const f=fixture(),e=await f.create(); + f.store.data.preferences[1]={timezone:'Australia/Sydney'}; + t.mock.timers.enable({apis:['Date'],now:Date.parse('2026-10-08T00:00:00Z')}); + await f.cb(1,`g:${e.id}`); + assert.match(f.calls.at(-1).text,/Last updated: 8 Oct 2026, 11:00:00 am AEDT/); + t.mock.timers.tick(60000); + await f.cb(1,`g:${e.id}`); + assert.match(f.calls.at(-1).text,/Last updated: 8 Oct 2026, 11:01:00 am AEDT/); + e.banner='fictional-banner';e.description='Long description '.repeat(150); + await f.cb(1,`v:${e.id}`); + assert.match(f.calls.at(-1).caption,/Last updated: 8 Oct 2026, 11:01:00 am AEDT/); + assert.ok(f.calls.at(-1).caption.length<=1024); + f.store.data.preferences[1].timezone='UTC'; + assert.match(f.bot.updatedAt(e,1),/8 Oct 2026, 12:01:00 am UTC/); +}); + test('native App menu stays available without duplicate App buttons or stale keyboard launchers',async()=>{ const f=fixture();f.bot.appUrl='https://example.test/app'; await f.msg(1,'/start'); @@ -334,11 +443,11 @@ test('banners persist from creation, are owner-controlled, and addresses are cop await f.msg(2,`/start e_${e.id}`); await f.cb(2,`banner:${e.id}`); assert.equal(f.store.data.sessions[2],undefined); e.guests[2].status='yes';e.guests[2].approval='approved'; await f.bot.card(2,e); const card=f.calls.at(-1); - assert.equal(card.reply_markup.inline_keyboard.flat().find(b=>b.copy_text).copy_text.text,'My house'); + assert.ok(!card.reply_markup.inline_keyboard.flat().some(b=>b.text.includes('Copy address'))); assert.equal(card.method,'sendPhoto'); const entity=card.caption_entities[0]; assert.equal(card.caption.slice(entity.offset,entity.offset+entity.length),'My house'); e.location='A'.repeat(300); await f.bot.card(2,e); - assert.ok(f.calls.at(-1).reply_markup.inline_keyboard.flat().some(b=>b.callback_data===`address:${e.id}`)); + assert.ok(!f.calls.at(-1).reply_markup.inline_keyboard.flat().some(b=>b.callback_data===`address:${e.id}`)); await f.cb(2,`address:${e.id}`); assert.equal(f.calls.at(-1).entities[0].length,300); }); @@ -420,3 +529,17 @@ test('media links show only banner/title and media actions without RSVP and revo assert.equal(f.calls.at(-1).caption,e.title);assert.doesNotMatch(f.calls.at(-1).caption,/PRIVATE/); await f.cb(1,`toggle:${e.id}:allowLinkUploads`);assert.equal(f.bot.mediaAllowed(e,99),false); }); + +test('organiser media sharing has its own copy and QR row with protected chat QR delivery',async()=>{ + const f=fixture(),e=await f.create();e.qrEnabled=true;f.bot.appUrl='https://example.invalid/app'; + await f.bot.card(1,e);let rows=f.calls.at(-1).reply_markup.inline_keyboard; + const index=rows.findIndex(row=>row.some(b=>b.text==='📋 Copy upload link')); + assert.ok(rows[index-1].some(b=>b.text==='🗂 Shared media')); + assert.deepEqual(rows[index].map(b=>b.text),['📋 Copy upload link','▦ Show QR code']); + assert.equal(rows[index][0].url,undefined);assert.match(rows[index][0].copy_text.text,/^https:\/\/t\.me\//); + assert.ok(!rows.flat().some(b=>/Copy address|Refresh|Reminder/.test(b.text))); + await f.cb(1,'upload-qr:'+e.id);assert.equal(f.calls.at(-1).method,'sendPhoto');assert.ok(f.calls.at(-1).__photoUpload); + await f.cb(2,'upload-qr:'+e.id);assert.equal(f.calls.at(-1).method,'sendMessage'); + e.qrEnabled=false;await f.bot.card(1,e);assert.ok(!f.calls.at(-1).reply_markup.inline_keyboard.flat().some(b=>b.text==='▦ Show QR code')); + await f.cb(1,'upload-qr:'+e.id);assert.equal(f.calls.at(-1).method,'sendMessage'); +}); diff --git a/test/cohost-ui.test.js b/test/cohost-ui.test.js index 88bb30a..9f3a646 100644 --- a/test/cohost-ui.test.js +++ b/test/cohost-ui.test.js @@ -4,6 +4,7 @@ import fs from 'node:fs'; import {webcrypto} from 'node:crypto'; import {setupGallery} from '../public/gallery.js'; import {setupEventActions} from '../public/event-actions.js'; +import {setupGuestMessages} from '../public/guest-messages.js'; const eventFixture=fields=>({id:'0123456789abcdef',title:'Club evening',location:'Club house',description:'Meet the team',isOwner:true,isManager:true,isCoHost:false,cohosts:[],cohostLinks:[],cohost:null,cohostInviteUrl:null,cohostVersion:'0000000000000000',invitationMode:'named',invitees:[{name:'Alex',participants:2,url:'https://t.me/test?start=guest'}],group:'Upcoming events',upcoming:true,startsAt:'2099-10-24T08:00:00Z',timezone:'Australia/Sydney',localDate:'2099-10-24',localTime:'18:00',permissions:{},qrEnabled:true,uploadLink:'https://t.me/test?start=upload',paymentMethod:'stars',starPrice:100,starPricing:'person',paymentTerms:'Admission for one person.',...fields}); const pendingLink=(id,label)=>({id,label,status:'pending',createdAt:'2026-10-07T00:00:00Z',cohost:null,url:'https://t.me/test?start=cohost_'+id}); @@ -78,14 +79,14 @@ async function harness(initial,{scheduleError,clipboardMode='ok',search='',invit if(path==='/api/preview')return response({startsAt:event.startsAt,timezone:event.timezone}); return response({}); }; - const source=fs.readFileSync(new URL('../public/app.js',import.meta.url),'utf8').replace("import { setupGallery } from './gallery.js';",'').replace("import { setupEventActions } from './event-actions.js';",''); + const source=fs.readFileSync(new URL('../public/app.js',import.meta.url),'utf8').replace(/^import .*$/gm,''); const AsyncFunction=Object.getPrototypeOf(async function(){}).constructor; const navigator={}; if(clipboardMode!=='absent')navigator.clipboard={writeText:async text=>{if(clipboardMode==='denied')throw Error('Clipboard denied.');copied.push(text);}}; const TestDate=clock?class extends Date{constructor(...args){super(...(args.length?args:[clock.now]));}static now(){return clock.now;}}:Date; const schedule=clock?(callback,delay=0)=>{const id=++timerSequence;timers.set(id,{callback,at:clock.now+delay});return id;}:setTimeout; const unschedule=clock?id=>timers.delete(id):clearTimeout; - await new AsyncFunction('window','document','location','fetch','crypto','navigator','setupGallery','setupEventActions','Date','setTimeout','clearTimeout',source)(window,document,{search},fetcher,webcrypto,navigator,setupGallery,setupEventActions,TestDate,schedule,unschedule); + await new AsyncFunction('window','document','location','fetch','crypto','navigator','setupGallery','setupEventActions','Date','setTimeout','clearTimeout','setupGuestMessages',source)(window,document,{search},fetcher,webcrypto,navigator,setupGallery,setupEventActions,TestDate,schedule,unschedule,setupGuestMessages); await new Promise(resolve=>setImmediate(resolve)); const descendants=node=>node.children.flatMap(child=>[child,...descendants(child)]); const findButton=(id,label)=>descendants(ids.get(id)).find(node=>node.tag==='button' && node.textContent===label); diff --git a/test/database-snapshot.test.js b/test/database-snapshot.test.js index 326688c..b4ebf6f 100644 --- a/test/database-snapshot.test.js +++ b/test/database-snapshot.test.js @@ -84,6 +84,13 @@ test('snapshot clears sessions and delivery state while retaining the dev whitel } finally { db.close(); } }); +test('snapshot removes bot-specific broadcast receipts while preserving profile and payment records',()=>{ + const pref={timezone:'UTC',starOrders:{example:{status:'paid'}},broadcasts:{example:{receipts:[{chatId:900000004,messageId:77}],undoUntil:1234}}}; + const sourceSql=source("INSERT INTO records VALUES('preferences','fixture','"+JSON.stringify(pref)+"');"); + const db=restore(prepareSnapshot(options({sourceSql})).sql); + try{delete pref.broadcasts;assert.deepEqual(JSON.parse(db.prepare("SELECT data FROM records WHERE kind='preferences'").get().data),pref);}finally{db.close();} +}); + test('empty development settings do not inherit a production whitelist or endpoint', () => { const { sql } = prepareSnapshot(options({ sourceSql: source("INSERT INTO app_settings VALUES('test-whitelist','[\"900000002\"]'); INSERT INTO app_settings VALUES('stars-webhook','https://production.test/telegram');") })); const db = restore(sql); diff --git a/test/delivery-fallback.test.js b/test/delivery-fallback.test.js new file mode 100644 index 0000000..5b974ee --- /dev/null +++ b/test/delivery-fallback.test.js @@ -0,0 +1,33 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {unavailablePhotoFallback,drainOutbox} from '../src/worker.js'; + +const photo={chat_id:710001,photo:'fictional-other-bot-file',caption:'Example event\nExample address',caption_entities:[{type:'code',offset:14,length:15}],reply_markup:{inline_keyboard:[[{text:'Manage',callback_data:'h:0123456789abcdef'}]]}}; +const rejected={ok:false,error_code:400,description:'Bad Request: wrong file identifier/HTTP URL specified'}; +test('unavailable photo fallback preserves authorised content, entities and buttons',()=>{ + const fallback=unavailablePhotoFallback(photo,rejected); + assert.equal(fallback.chat_id,photo.chat_id);assert.ok(fallback.text.startsWith(photo.caption)); + assert.deepEqual(fallback.entities,photo.caption_entities);assert.deepEqual(fallback.reply_markup,photo.reply_markup); + assert.equal('photo' in fallback,false); + for(const result of [{ok:true},{ok:false,error_code:403,description:'Forbidden'},{ok:false,error_code:429},{ok:false,error_code:503}, {...rejected,description:'Bad Request: BUTTON_DATA_INVALID'}])assert.equal(unavailablePhotoFallback(photo,result),null); + assert.equal(unavailablePhotoFallback({...photo,caption:''},rejected),null); +}); + +for(const retry of [false,true])test(`photo delivery persists text fallback and ${retry?'retries transient errors':'delivers the event buttons'}`,async t=>{ + const row={id:'fictional-delivery',method:'sendPhoto',params:JSON.stringify(photo),due:0,attempts:1},calls=[],removed=[],saved=[]; + const env={APP_ENV:'production',TELEGRAM_BOT_TOKEN:'fictional-token',DB:{prepare(sql){ + return {values:[],bind(...values){this.values=values;return this;},async first(){if(sql.startsWith('INSERT INTO delivery_lease'))return {owner:'fixture'};if(sql.startsWith('UPDATE outbox SET due=unixepoch()+60'))return {...row};throw Error('Unexpected first');},async all(){assert.match(sql,/SELECT id,due FROM outbox/);return {results:removed.length?[]:[row]};},async run(){ + if(sql==='UPDATE outbox SET method=?,params=? WHERE id=?'){saved.push(this.values);row.method=this.values[0];row.params=this.values[1];} + else if(sql==='DELETE FROM outbox WHERE id=?')removed.push(this.values[0]); + else if(sql.startsWith('UPDATE outbox SET due=unixepoch()+?'))saved.push(['retry']); + else assert.equal(sql,'DELETE FROM delivery_lease WHERE owner=?'); + }}; + }}}; + t.mock.method(globalThis,'fetch',async(url,options)=>{calls.push({method:new URL(url).pathname.split('/').at(-1),params:JSON.parse(options.body)});return Response.json(calls.length===1?rejected:retry&&calls.length===2?{ok:false,error_code:503}:{ok:true});}); + await drainOutbox(env); + assert.deepEqual(calls.map(call=>call.method),['sendPhoto','sendMessage']); + assert.deepEqual(calls[1].params.reply_markup,photo.reply_markup); + assert.equal(row.method,'sendMessage');assert.equal(saved[0][2],row.id); + if(retry){assert.deepEqual(removed,[]);assert.deepEqual(saved.at(-1),['retry']);await drainOutbox(env);assert.equal(calls.at(-1).method,'sendMessage');} + assert.deepEqual(removed,[row.id]); +}); diff --git a/test/delivery-queue.test.js b/test/delivery-queue.test.js new file mode 100644 index 0000000..ffb7854 --- /dev/null +++ b/test/delivery-queue.test.js @@ -0,0 +1,11 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {drainOutbox} from '../src/worker.js'; + +test('normal delivery drains beyond one batch so messages do not wait for the next cron',async t=>{ + const rows=Array.from({length:25},(_,i)=>({id:'fictional-'+i,due:0,attempts:1,method:'sendMessage',params:JSON.stringify({chat_id:1,text:'Fictional notice '+i})})); + const sent=[]; + const env={APP_ENV:'production',TELEGRAM_BOT_TOKEN:'fictional-token',DB:{prepare(sql){return {bind(...args){this.args=args;return this;},async first(){if(sql.startsWith('INSERT INTO delivery_lease'))return {owner:'fixture'};if(sql.startsWith('UPDATE outbox SET due=unixepoch()+60'))return rows.find(r=>r.id===this.args[0]);throw Error('Unexpected query');},async all(){return {results:rows.slice(0,20)};},async run(){if(sql==='DELETE FROM outbox WHERE id=?')rows.splice(rows.findIndex(r=>r.id===this.args[0]),1);else assert.equal(sql,'DELETE FROM delivery_lease WHERE owner=?');}};}}}; + t.mock.method(globalThis,'fetch',async(_url,options)=>{sent.push(JSON.parse(options.body).text);return Response.json({ok:true});}); + await drainOutbox(env);assert.equal(sent.length,25);assert.equal(rows.length,0);assert.equal(new Set(sent).size,25); +}); diff --git a/test/edit-form.test.js b/test/edit-form.test.js index a08acb7..8ea6094 100644 --- a/test/edit-form.test.js +++ b/test/edit-form.test.js @@ -4,6 +4,7 @@ import fs from 'node:fs'; import {webcrypto} from 'node:crypto'; import {setupGallery} from '../public/gallery.js'; import {setupEventActions} from '../public/event-actions.js'; +import {setupGuestMessages} from '../public/guest-messages.js'; test('editing fetches fresh details, preserves all attendee count modes and invitation message and previews the existing banner',async()=>{ class El { @@ -13,7 +14,7 @@ test('editing fetches fresh details, preserves all attendee count modes and invi setAttribute(){} removeAttribute(){} addEventListener(){} reset(){} focus(){} setCustomValidity(text){this.validation=text;} querySelector(){return new El();} } const html=fs.readFileSync(new URL('../public/index.html',import.meta.url),'utf8'); - const source=fs.readFileSync(new URL('../public/app.js',import.meta.url),'utf8').replace("import { setupGallery } from './gallery.js';",'').replace("import { setupEventActions } from './event-actions.js';",''); + const source=fs.readFileSync(new URL('../public/app.js',import.meta.url),'utf8').replace(/^import .*$/gm,''); const ids=new Map([...html.matchAll(/id="([^"]+)"/g)].map(m=>[m[1],new El()])); const tabs=[...html.matchAll(/data-tab="([^"]+)"/g)].map(m=>{const el=m[1]==='admin'?ids.get('admin-tab'):new El();el.dataset.tab=m[1];return el;}); const nav=new El(),errors=[]; @@ -24,7 +25,7 @@ test('editing fetches fresh details, preserves all attendee count modes and invi const paths=[]; const fetcher=async path=>{paths.push(path);return {ok:true,status:200,json:async()=>path==='/api/bootstrap'?bootstrap:path==='/api/events/'+event.id?{event}: {localDate:event.localDate,localTime:event.localTime,startsAt:event.startsAt,timezone:event.timezone},blob:async()=>new Blob(['banner'],{type:'image/jpeg'})};}; const AsyncFunction=Object.getPrototypeOf(async function(){}).constructor; - await new AsyncFunction('window','document','location','fetch','crypto','navigator','setupGallery','setupEventActions',source)(window,document,{search:'?event='+event.id},fetcher,webcrypto,{},setupGallery,setupEventActions); + await new AsyncFunction('window','document','location','fetch','crypto','navigator','setupGallery','setupEventActions','setupGuestMessages',source)(window,document,{search:'?event='+event.id},fetcher,webcrypto,{},setupGallery,setupEventActions,setupGuestMessages); await new Promise(resolve=>setImmediate(resolve)); assert.deepEqual(errors,[]); assert.ok(paths.includes('/api/events/'+event.id)); diff --git a/test/gallery-ux.test.js b/test/gallery-ux.test.js index 5200bc0..dbeea8f 100644 --- a/test/gallery-ux.test.js +++ b/test/gallery-ux.test.js @@ -3,6 +3,14 @@ import assert from 'node:assert/strict'; import {setupGallery} from '../public/gallery.js'; function deferred(){let resolve,reject;const promise=new Promise((yes,no)=>{resolve=yes;reject=no;});return {promise,resolve,reject};} + +test('gallery shows a direct sharing action and respects disabled QR codes',async t=>{ + let enabled=true; + const f=galleryHarness(t,async path=>path.endsWith('gallery-qr')?{image:'data:image/gif;base64,R0lG',link:'https://t.me/fictional_bot?startapp=gallery_test'}:{title:'Club meetup',media:[],galleryUrl:'https://t.me/fictional_bot?startapp=gallery_test',qrEnabled:enabled}); + await f.openGallery('first');assert.equal(f.$('gallery-copy').hidden,false);assert.equal(f.$('gallery-qr').hidden,false); + await f.$('gallery-qr').onclick();assert.equal(f.$('qr-title').textContent,'Shared media QR code'); + f.dismissPendingMedia();enabled=false;await f.openGallery('second');assert.equal(f.$('gallery-qr').hidden,true);assert.equal(f.$('gallery-copy').hidden,false); +}); function galleryHarness(t,api=async()=>({title:'Club meetup',media:[],canUpload:false})){ class Node{ constructor(tag='div',text=''){this.tagName=tag.toUpperCase();this.textContent=text;this.children=[];this.attributes={};this.isConnected=true;this.open=false;this.opens=0;this.pauses=0;this.loads=0;} diff --git a/test/guest-messages-ui.test.js b/test/guest-messages-ui.test.js new file mode 100644 index 0000000..8557ab5 --- /dev/null +++ b/test/guest-messages-ui.test.js @@ -0,0 +1,50 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {setupGuestMessages} from '../public/guest-messages.js'; + +function harness(){ + class Node{ + constructor(tag){this.tag=tag;this.children=[];this.checked=false;this.value='';this.files=[];} + append(...nodes){this.children.push(...nodes);} + replaceChildren(){this.children=[];} + querySelectorAll(selector){return this.children.flatMap(n=>[n,...n.children]).filter(n=>n.tag==='input' && (selector!=='input:checked' || n.checked));} + setAttribute(){}remove(){this.removed=true;} + showModal(){this.open=true;}close(){this.open=false;} + } + const nodes=new Map(),$=id=>{if(!nodes.has(id))nodes.set(id,new Node('div'));return nodes.get(id);},calls=[]; + let fail=false,messages=[]; + const open=setupGuestMessages({$,document:{createElement:tag=>new Node(tag)},initData:'fictional',notice(){},schedule(){return 1;},cancel(){},api:async(path,input)=>{ + calls.push({path,input});if(fail)throw Error('Fictional retry error'); + if(path.endsWith('/history'))return {messages}; + if(path.endsWith('/send'))messages=[{token:'test-draft',createdAt:Date.now(),preview:input.text,attachments:[],count:2,delivered:1,pending:1,failed:0,canDelete:true}]; + if(path.endsWith('/delete'))messages=messages.map(m=>({...m,undone:true,canDelete:false,deletePending:1})); + return path.endsWith('/start')?{token:'test-draft',counts:{yes:2,no:1,maybe:0,later:1,unanswered:3}}:{token:'test-draft',count:2,undoUntil:Date.now()+300000}; + }}); + return {$,calls,open,setFailure(value){fail=value;},all(node){return [node,...node.children.flatMap(n=>this.all(n))];}}; +} +test('Mini App message groups default to accepted, support all and submit selected groups once',async()=>{ + const f=harness();await f.open({id:'example',title:'Fictional event'}); + assert.equal(f.$('guest-message-dialog').open,true); + assert.equal(f.$('guest-message-form').hidden,true);await f.$('guest-message-new').onclick(); + assert.deepEqual(f.$('guest-message-groups').querySelectorAll('input:checked').map(b=>b.value),['yes']); + f.$('guest-message-all').checked=true;f.$('guest-message-all').onchange(); + assert.equal(f.$('guest-message-groups').querySelectorAll('input:checked').length,5); + f.$('guest-message-text').value='Hello guests'; + await f.$('guest-message-form').onsubmit({preventDefault(){}}); + const send=f.calls.find(c=>c.path.endsWith('/send'));assert.equal(send.input.text,'Hello guests');assert.equal(send.input.groups.length,5); + const n=f.calls.length;await f.$('guest-message-form').onsubmit({preventDefault(){}});assert.equal(f.calls.length,n); + const history=f.all(f.$('guest-message-history')); + assert.ok(history.some(node=>node.textContent==='Hello guests')); + assert.ok(history.some(node=>node.textContent==='Delivered: 1/2 · Pending: 1 · Failed: 0')); + await history.find(node=>node.textContent==='🗑 Delete from guest chats').onclick(); + await f.all(f.$('guest-message-history')).find(node=>node.textContent==='Confirm delete').onclick(); + assert.ok(f.calls.some(c=>c.path==='events/example/messages/delete')); + f.$('guest-message-close').onclick();assert.equal(f.$('guest-message-dialog').open,false); +}); +test('Mini App displays send failures and keeps composer usable for retry',async()=>{ + const f=harness();await f.open({id:'example',title:'Fictional event'});await f.$('guest-message-new').onclick();f.setFailure(true); + await f.$('guest-message-form').onsubmit({preventDefault(){}}); + assert.equal(f.$('guest-message-send').disabled,false); + assert.match(f.$('guest-message-status').textContent,/retry error/); + assert.equal(f.$('guest-message-compose').hidden,false); +}); diff --git a/test/guest-messages.test.js b/test/guest-messages.test.js new file mode 100644 index 0000000..1b4f90f --- /dev/null +++ b/test/guest-messages.test.js @@ -0,0 +1,120 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {Bot} from '../src/bot.js'; +import {broadcastRecipients,broadcastReceipt,broadcastHistory,broadcastDeliveryResult,undoBroadcast} from '../src/guest-messages.js'; + +function fixture(){ + const e={id:'0123456789abcdef',title:'Fictional gathering',owner:1,invitationMode:'legacy',guests:{2:{status:'yes',name:'A'},3:{status:'no',name:'B'},4:{status:'maybe',name:'C'},5:{status:'later',responseRecorded:true,name:'D'},6:{status:'later',name:'E'}},permissions:{guestList:true},media:[]}; + const data={events:{[e.id]:e},sessions:{},preferences:{}},calls=[]; + const bot=new Bot({data},async(method,params)=>{calls.push({method,...params});return method==='copyMessage' || params.__broadcast ? {message_id:calls.length}:{};},'fictionalBot'); + const cb=(id,action)=>bot.handle({callback_query:{from:{id},data:action}}); + const msg=(id,text,extra={})=>bot.handle({message:{from:{id},chat:{id,type:'private'},message_id:99,text,...extra}}); + return {e,data,calls,bot,cb,msg}; +} +test('empty message prompt keeps send and cancel controls available',async()=>{ + const f=fixture();await f.cb(1,`bm:${f.e.id}`);const token=f.data.sessions[1].token; + assert.match(f.calls.at(-2).text,/Choose recipient categories below\./); + assert.equal(f.calls.at(-1).text,'Write your message or attach files. When finished, press Send message.'); + assert.ok(f.calls.at(-1).reply_markup.keyboard); + await f.cb(1,`bm-send:${f.e.id}:${token}`); + const prompt=f.calls.at(-1);assert.equal(prompt.text,'Write your message or attach files first. Then press Send message.'); + assert.deepEqual(prompt.reply_markup.keyboard[0].map(b=>b.text),['📨 Send message','✖ Cancel']); + assert.equal(prompt.reply_markup.inline_keyboard,undefined); + await f.msg(1,'Example message');await f.msg(1,'📨 Send message'); + assert.equal(f.calls.filter(c=>c.method==='copyMessage').length,1); + assert.equal(f.calls.some(c=>c.reply_markup?.keyboard?.[0]?.some(b=>b.text==='🎉 Create event')),true); + assert.equal(f.data.sessions[1],undefined); + assert.ok(f.calls.some(c=>c.__broadcastNotice && c.reply_markup?.keyboard?.[0]?.some(b=>b.text==='🎉 Create event'))); +}); + +test('composer reply keyboard cancels without sending its button text',async()=>{ + const f=fixture();await f.cb(1,`bm:${f.e.id}`); + assert.equal(f.calls.some(c=>c.reply_markup?.keyboard?.[0]?.[0]?.text==='📨 Send message'),true); + await f.msg(1,'Draft');await f.msg(1,'✖ Cancel'); + assert.equal(f.data.sessions[1],undefined); + assert.equal(f.calls.some(c=>c.method==='copyMessage'),false); + assert.equal(f.calls.some(c=>c.reply_markup?.keyboard?.[0]?.some(b=>b.text==='🎉 Create event')),true); +}); + +test('recipient selection edits the original message and shows red crosses for unselected groups',async()=>{ + const f=fixture();await f.cb(1,`bm:${f.e.id}`);const token=f.data.sessions[1].token; + const count=f.calls.filter(c=>c.method==='sendMessage').length; + const toggle=key=>f.bot.handle({callback_query:{from:{id:1},message:{message_id:123,chat:{id:1}},data:`bm-group:${f.e.id}:${key}:${token}`}}); + await toggle('no');let edited=f.calls.at(-1); + assert.equal(edited.method,'editMessageText');assert.equal(edited.message_id,123); + assert.equal(f.calls.filter(c=>c.method==='sendMessage').length,count); + let labels=edited.reply_markup.inline_keyboard.flat().map(b=>b.text); + assert.ok(labels.includes('✅ Rejected (1)'));assert.ok(labels.includes('❌ Maybe (1)'));assert.ok(labels.includes('❌ All')); + await toggle('all');labels=f.calls.at(-1).reply_markup.inline_keyboard.flat().map(b=>b.text);assert.ok(labels.every(label=>label.startsWith('✅'))); + await toggle('all');labels=f.calls.at(-1).reply_markup.inline_keyboard.flat().map(b=>b.text);assert.ok(labels.every(label=>label.startsWith('❌'))); + assert.equal(f.calls.filter(c=>c.method==='sendMessage').length,count); +}); + +test('messaging defaults to accepted and isolates each response group including unanswered',async()=>{ + const f=fixture(); + for(const [group,id] of [['yes',2],['no',3],['maybe',4],['later',5],['unanswered',6]])assert.deepEqual(broadcastRecipients(f.e,[group]),[id]); + await f.cb(1,`bm:${f.e.id}`); + assert.deepEqual(f.data.sessions[1].groups,['yes']); + await f.msg(1,'Example message'); + assert.equal(f.calls.some(c=>c.method==='copyMessage'),false); + const token=f.data.sessions[1].token; + await f.cb(1,`bm-send:${f.e.id}:${token}`); + assert.deepEqual(f.calls.filter(c=>c.method==='copyMessage').map(c=>c.chat_id),[2]); + const n=f.calls.length;await f.cb(1,`bm-send:${f.e.id}:${token}`); + assert.equal(f.calls.slice(n).some(c=>c.method==='copyMessage'),false); + await f.cb(1,`bm-undo:${token}`); + assert.equal(f.calls.filter(c=>c.method==='deleteMessage').length,2); + const before=f.calls.length;await f.cb(1,`bm-undo:${token}`); + assert.equal(f.calls.slice(before).some(c=>c.method==='deleteMessage'),false); +}); +test('creator-only controls reject guests and co-hosts; cancelling a draft sends nothing',async()=>{ + const f=fixture();f.e.cohost={id:3}; + for(const id of [2,3]){await f.cb(id,`bm:${f.e.id}`);assert.equal(f.data.sessions[id],undefined);} + await f.cb(1,`bm:${f.e.id}`);await f.cb(1,`bm-group:${f.e.id}:all:${f.data.sessions[1].token}`); + assert.deepEqual(new Set(f.data.sessions[1].groups),new Set(['yes','no','maybe','later','unanswered'])); + await f.msg(1,undefined,{photo:[{file_id:'fictional-photo'}]}); + await f.cb(1,`bm-cancel:${f.e.id}:${f.data.sessions[1].token}`); + assert.equal(f.data.sessions[1],undefined);assert.equal(f.calls.some(c=>c.method==='copyMessage'),false); +}); +test('undo is owner-scoped, expires after five minutes, and deletes deliveries racing with undo',async(t)=>{ + const f=fixture();t.mock.timers.enable({apis:['Date'],now:1000000}); + await f.cb(1,`bm:${f.e.id}`);await f.msg(1,'Test');const token=f.data.sessions[1].token; + await f.cb(1,`bm-send:${f.e.id}:${token}`); + await f.cb(2,`bm-undo:${token}`);assert.equal(f.data.preferences[1].broadcasts[token].undone,false); + await f.cb(1,`bm-undo:${token}`); + await broadcastReceipt(f.bot,1,token,2,12345); + assert.equal(f.calls.at(-1).method,'deleteMessage');assert.equal(f.calls.at(-1).message_id,12345); + await f.cb(1,`bm:${f.e.id}`);await f.msg(1,'Another');const next=f.data.sessions[1].token; + await f.cb(1,`bm-send:${f.e.id}:${next}`);t.mock.timers.tick(300001); + await f.cb(1,`bm-undo:${next}`);assert.match(f.calls.at(-1).text,/window has ended/); + assert.equal(f.data.preferences[1].broadcasts[next].undone,false); +}); + +test('history counts complete deliveries instead of partial messages, preserves old history and records deletion results',async()=>{ + const f=fixture(); + f.data.preferences[1]={broadcasts:{old:{event:f.e.id,count:1,undoUntil:Date.now()-86400000*3,receipts:[]}}}; + await f.cb(1,`bm:${f.e.id}`);await f.msg(1,'Example announcement');const token=f.data.sessions[1].token; + await f.cb(1,`bm-send:${f.e.id}:${token}`); + let history=broadcastHistory(f.data,1,f.e.id);assert.equal(history.length,2); + assert.equal(history[0].preview,'Example announcement');assert.equal(history[0].delivered,1);assert.equal(history[1].delivered,null); + const record=f.data.preferences[1].broadcasts[token];record.count=3;record.receipts.push({key:'3:900',chatId:3,messageId:900}); + broadcastDeliveryResult(f.data,1,token,4,{ok:false,error_code:403}); + history=broadcastHistory(f.data,1,f.e.id);assert.equal(history[0].delivered,1);assert.equal(history[0].pending,1);assert.equal(history[0].failed,1); + await undoBroadcast(f.bot,1,token,true); + broadcastDeliveryResult(f.data,1,token,3,{ok:true},900); + assert.equal(broadcastHistory(f.data,1,f.e.id)[0].deleted,1); + assert.equal(broadcastHistory(f.data,2,f.e.id).length,0); +}); + +test('history deletion works after five-minute undo until Telegram deletion limit',async(t)=>{ + const f=fixture();t.mock.timers.enable({apis:['Date'],now:1000000}); + await f.cb(1,`bm:${f.e.id}`);await f.msg(1,'Example');const token=f.data.sessions[1].token;await f.cb(1,`bm-send:${f.e.id}:${token}`); + t.mock.timers.tick(3600000);await f.cb(1,`bm-undo:${token}`); + assert.equal(f.data.preferences[1].broadcasts[token].undone,false); + assert.equal(broadcastHistory(f.data,1,f.e.id)[0].canDelete,true); + await undoBroadcast(f.bot,1,token,true);assert.equal(f.data.preferences[1].broadcasts[token].undone,true); + await f.cb(1,`bm:${f.e.id}`);await f.msg(1,'Another');const next=f.data.sessions[1].token;await f.cb(1,`bm-send:${f.e.id}:${next}`); + t.mock.timers.tick(48*3600000);await undoBroadcast(f.bot,1,next,true); + assert.equal(f.data.preferences[1].broadcasts[next].undone,false); + assert.match(f.calls.at(-1).text,/48 hours/); +}); diff --git a/test/optional-qr.test.js b/test/optional-qr.test.js index 86f5b5f..0a19a29 100644 --- a/test/optional-qr.test.js +++ b/test/optional-qr.test.js @@ -8,6 +8,21 @@ function fixture(qrEnabled){ return {event,env,request:new Request(`https://test/api/events/${event.id}/upload-qr`)}; } +test('shared media links and QR codes preserve guest access and QR settings',async()=>{ + const {event,env}=fixture(true);event.permissions.viewMedia=true; + const request=path=>new Request(`https://test/api/events/${event.id}/${path}`); + const gallery=await (await mediaApi(request('gallery'),env,{id:2})).json(); + assert.equal(gallery.galleryUrl,`https://t.me/TestEvents_bot?startapp=gallery_${event.id}`); + assert.equal(gallery.qrEnabled,true); + const qr=await (await mediaApi(request('gallery-qr'),env,{id:2})).json(); + assert.equal(qr.link,gallery.galleryUrl);assert.match(qr.image,/^data:image\/gif;base64,/); + assert.equal((await mediaApi(request('gallery-qr'),env,{id:3})).status,403); + event.permissions.viewMedia=false; + assert.equal((await mediaApi(request('gallery-qr'),env,{id:2})).status,403); + event.qrEnabled=false; + assert.equal((await mediaApi(request('gallery-qr'),env,{id:1})).status,400); +}); + test('disabled upload QR cannot be requested directly by an organiser or guest',async()=>{ const {env,request}=fixture(false); const organiser=await mediaApi(request,env,{id:1}); diff --git a/test/snapshot-mode.test.js b/test/snapshot-mode.test.js index 741d1ab..b1ce819 100644 --- a/test/snapshot-mode.test.js +++ b/test/snapshot-mode.test.js @@ -91,7 +91,7 @@ test('suppressed refund deliveries do not contact Telegram or rewrite imported a const refund = { id: 'fictional-refund', due: 0, method: 'refundStarPayment', params: JSON.stringify({ user_id: 111002, telegram_payment_charge_id: 'fictional-charge' }) }; env.DB.prepare = sql => { if (sql.startsWith('INSERT INTO delivery_lease')) return { bind() { return this; }, async first() { return { owner: 'fictional-lease' }; } }; - if (sql === 'SELECT id,due FROM outbox ORDER BY rowid LIMIT 20') return { async all() { return { results: [refund] }; } }; + if (sql === 'SELECT id,due FROM outbox ORDER BY rowid LIMIT 20') return { async all() { return { results: removed.length?[]:[refund] }; } }; if (sql.startsWith('UPDATE outbox SET due=unixepoch()+60')) return { bind() { return this; }, async first() { return refund; } }; if (sql === 'DELETE FROM outbox WHERE id=?') return { bind(id) { removed.push(id); return this; }, async run() {} }; if (sql === 'DELETE FROM delivery_lease WHERE owner=?') return { bind() { return this; }, async run() {} }; diff --git a/test/telegram-upload.test.js b/test/telegram-upload.test.js new file mode 100644 index 0000000..825d68c --- /dev/null +++ b/test/telegram-upload.test.js @@ -0,0 +1,15 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {inflateSync} from 'node:zlib'; +import {qrPhoto,telegramPayload} from '../src/telegram-upload.js'; +test('QR photo uses a valid PNG and multipart transport without internal metadata',async()=>{ + const encoded=qrPhoto('https://t.me/FictionalBot?start=a_example'),png=Buffer.from(encoded,'base64'); + assert.deepEqual([...png.subarray(0,8)],[137,80,78,71,13,10,26,10]); + const size=png.readUInt32BE(16);assert.ok(size>100); + const compressedSize=png.readUInt32BE(33);assert.equal(inflateSync(png.subarray(41,41+compressedSize)).length,(size+1)*size); + const result=telegramPayload({chat_id:1,__photoUpload:encoded,caption:'Fictional QR',reply_markup:{inline_keyboard:[]}}); + assert.ok(result.body instanceof FormData);assert.equal(result.body.has('__photoUpload'),false); + assert.equal(result.body.get('photo').type,'image/png');assert.equal(result.body.get('chat_id'),'1'); + assert.deepEqual(Buffer.from(await result.body.get('photo').arrayBuffer()),png); + assert.deepEqual(JSON.parse(telegramPayload({chat_id:1,text:'Example'}).body),{chat_id:1,text:'Example'}); +});