From 6bb9149db5ec16a48013026b4a3f56322408e0bb Mon Sep 17 00:00:00 2001 From: Ehsan <15076484+ehsan0921@users.noreply.github.com> Date: Thu, 8 Oct 2026 17:13:18 +1100 Subject: [PATCH 01/14] Label Telegram settings by event and add response refresh buttons --- src/bot.js | 6 +++--- test/bot.test.js | 21 +++++++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/bot.js b/src/bot.js index 345075b..e082f2a 100644 --- a/src/bot.js +++ b/src/bot.js @@ -697,8 +697,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 +721,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}`)])); diff --git a/test/bot.test.js b/test/bot.test.js index 9e682f1..d5f4e58 100644 --- a/test/bot.test.js +++ b/test/bot.test.js @@ -26,6 +26,27 @@ function fixture() { return { store, bot, calls, msg, cb, create }; } +test('event settings identify the event and response refresh loads current counts for managers only',async()=>{ + 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('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'); From 93f27856265d35fc3f96a52cdbcdd3e61ef2434b Mon Sep 17 00:00:00 2001 From: Ehsan <15076484+ehsan0921@users.noreply.github.com> Date: Thu, 8 Oct 2026 17:18:08 +1100 Subject: [PATCH 02/14] Keep event buttons available when Telegram rejects an unavailable banner --- src/bot.js | 2 +- src/worker.js | 19 ++++++++++++++++++- test/bot.test.js | 9 +++++++++ test/delivery-fallback.test.js | 33 +++++++++++++++++++++++++++++++++ 4 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 test/delivery-fallback.test.js diff --git a/src/bot.js b/src/bot.js index e082f2a..0ae2c8b 100644 --- a/src/bot.js +++ b/src/bot.js @@ -613,7 +613,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); diff --git a/src/worker.js b/src/worker.js index 433f0a6..db8107f 100644 --- a/src/worker.js +++ b/src/worker.js @@ -64,6 +64,15 @@ export async function processUpdate(env, update) { } } +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) { 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.expires deadline || due > Math.floor(Date.now() / 1000)) break; 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)); + let result = await telegram(env, row.method, JSON.parse(row.params)); + 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)); diff --git a/test/bot.test.js b/test/bot.test.js index d5f4e58..f1a0001 100644 --- a/test/bot.test.js +++ b/test/bot.test.js @@ -47,6 +47,15 @@ test('event settings identify the event and response refresh loads current count } }); +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('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'); diff --git a/test/delivery-fallback.test.js b/test/delivery-fallback.test.js new file mode 100644 index 0000000..66ab6ea --- /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:[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]); +}); From b9c6160b81f99eba90e698fe1dbe759ccac551db Mon Sep 17 00:00:00 2001 From: Ehsan <15076484+ehsan0921@users.noreply.github.com> Date: Thu, 8 Oct 2026 17:20:22 +1100 Subject: [PATCH 03/14] Add refresh buttons to Telegram guest lists and event cards --- src/bot.js | 4 ++-- test/bot.test.js | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/bot.js b/src/bot.js index 0ae2c8b..0df4623 100644 --- a/src/bot.js +++ b/src/bot.js @@ -261,7 +261,7 @@ export class Bot { } if (owner) extras.push(button('Delete event', `delete:${e.id}`)); rows.push(...paired(extras)); - if(!rsvpOnly)rows.push([button(menu.events, 'nav:events'), button(menu.home, 'nav:home')]); + if(!rsvpOnly)rows.push([button('↻ Refresh',`v:${e.id}`)],[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.'; const location = canSeeLocation(e, id) ? e.location || 'Location to follow' : paidEvent(e) ? requiresApproval(e) ? 'Shared after approval and confirmed payment' : 'Shared after confirmed payment' : requiresApproval(e) ? 'Shared after organiser approval' : 'Shared after acceptance'; let text = `πŸŽ‰ ${e.title}${e.cancelled ? ' β€” CANCELLED' : ''}\n\nπŸ—“ ${this.time(e, id)}\nπŸ“ ${location}\n\n${priceText(e,this.db.preferences[id],this.pricing || this.db.preferences._pricing)}\n\n${[e.inviteMessage,e.description].filter(Boolean).join('\n\n')}\n\n${host ? (owner ? 'You’re the organiser.\n\n' : 'You’re the co-host.\n\n') : mode==='named' ? 'Personal invitation for '+e.guests[id].name+'\n\n'+(accepted ? 'βœ… Accepted\n\n' : '') : mode==='tickets' ? (accepted ? '🎟 Ticket '+(confirmed(e,e.guests[id]) ? 'confirmed' : 'requested')+'\n\n' : '') : accepted ? 'βœ… Accepted\n\n' : ''}${closed ? '⏰ Responses closed β€” deadline passed.\n\n' : ''}${e.responseDeadline ? 'Response deadline: ' + eventTime({ startsAt: e.responseDeadline, timezone: e.deadlineTimezone || e.timezone || 'UTC' }, this.db.preferences[id]?.timezone) + '\n\n' : ''}${counts}${host && mode!=='named' ? '\n\n'+(mode==='tickets' ? 'Ticket link:' : 'Invite people:')+'\n' + this.link(e) : ''}\n\n${visibility} `; @@ -653,7 +653,7 @@ export class Bot { const group = guests(e).filter(g => status === 'yes' ? confirmed(e, g) : status === 'pending' ? g.status === 'yes' && requiresApproval(e) && g.approval === 'pending' : g.status === status); 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.'); diff --git a/test/bot.test.js b/test/bot.test.js index f1a0001..7935c22 100644 --- a/test/bot.test.js +++ b/test/bot.test.js @@ -56,6 +56,20 @@ test('opening an event sends the event card directly without a placeholder messa assert.equal(f.calls.some(call=>call.text==='Use the event buttons below.'),false); }); +test('guest list and event card have refresh buttons 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' && b.callback_data===`v:${e.id}`)); + 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('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'); From 97872ee9f1ef42ba082400b871746d4220afb39c Mon Sep 17 00:00:00 2001 From: Ehsan <15076484+ehsan0921@users.noreply.github.com> Date: Thu, 8 Oct 2026 17:24:06 +1100 Subject: [PATCH 04/14] Show last updated timestamps on Telegram event cards and guest lists --- src/bot.js | 14 +++++++++++++- test/bot.test.js | 17 +++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/bot.js b/src/bot.js index 0df4623..63d5f7b 100644 --- a/src/bot.js +++ b/src/bot.js @@ -88,6 +88,14 @@ export class Bot { 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) { @@ -274,6 +282,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.' : ''); @@ -647,7 +659,7 @@ 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`; + let text = `πŸ‘₯ ${e.title}\n${this.updatedAt(e, id)}\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); diff --git a/test/bot.test.js b/test/bot.test.js index 7935c22..928ef29 100644 --- a/test/bot.test.js +++ b/test/bot.test.js @@ -70,6 +70,23 @@ test('guest list and event card have refresh buttons and guest list refresh rech assert.match(f.calls.at(-1).text,/not enabled/); }); +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'); From 1eda75f0c3784106dfaa6bf7e9f126de004f8a5b Mon Sep 17 00:00:00 2001 From: Ehsan <15076484+ehsan0921@users.noreply.github.com> Date: Thu, 8 Oct 2026 17:24:57 +1100 Subject: [PATCH 05/14] Offer cancellation before optional event deletion --- src/bot.js | 7 +++++-- test/bot.test.js | 20 ++++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/bot.js b/src/bot.js index 63d5f7b..1082c8e 100644 --- a/src/bot.js +++ b/src/bot.js @@ -267,7 +267,7 @@ export class Bot { 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('↻ Refresh',`v:${e.id}`)],[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.'; @@ -751,6 +751,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/test/bot.test.js b/test/bot.test.js index 928ef29..191c3b0 100644 --- a/test/bot.test.js +++ b/test/bot.test.js @@ -70,6 +70,26 @@ test('guest list and event card have refresh buttons and guest list refresh rech assert.match(f.calls.at(-1).text,/not enabled/); }); +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'}; From 9e3fe55a45c81f0e4725911ac3053c2be8fdb870 Mon Sep 17 00:00:00 2001 From: Ehsan <15076484+ehsan0921@users.noreply.github.com> Date: Thu, 8 Oct 2026 17:41:09 +1100 Subject: [PATCH 06/14] Add creator guest messaging with attachments and five-minute undo --- public/app.js | 3 ++ public/guest-messages.js | 46 +++++++++++++++++++ public/index.html | 16 +++++++ public/style.css | 8 ++++ scripts/check-guest-messages.mjs | 72 +++++++++++++++++++++++++++++ scripts/check-worker.mjs | 4 +- scripts/database-snapshot.mjs | 2 + src/bot.js | 18 ++++++++ src/guest-message-api.js | 72 +++++++++++++++++++++++++++++ src/guest-messages.js | 79 ++++++++++++++++++++++++++++++++ src/main.js | 3 +- src/mini-api.js | 3 ++ src/worker.js | 22 ++++++++- test/cohost-ui.test.js | 5 +- test/database-snapshot.test.js | 7 +++ test/edit-form.test.js | 5 +- test/guest-messages-ui.test.js | 41 +++++++++++++++++ test/guest-messages.test.js | 52 +++++++++++++++++++++ 18 files changed, 450 insertions(+), 8 deletions(-) create mode 100644 public/guest-messages.js create mode 100644 scripts/check-guest-messages.mjs create mode 100644 src/guest-message-api.js create mode 100644 src/guest-messages.js create mode 100644 test/guest-messages-ui.test.js create mode 100644 test/guest-messages.test.js diff --git a/public/app.js b/public/app.js index 333c9d9..2a0fcd6 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}); 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))); diff --git a/public/guest-messages.js b/public/guest-messages.js new file mode 100644 index 0000000..bf1dae7 --- /dev/null +++ b/public/guest-messages.js @@ -0,0 +1,46 @@ +export function setupGuestMessages({$,document,api,notice,initData}) { + const dialog=$('guest-message-dialog'); + let eventId,token,sent=false,uploading=false; + const groups={yes:'Accepted',no:'Rejected',maybe:'Maybe',later:'Respond later',unanswered:'Unanswered'}; + $('guest-message-close').onclick=()=>dialog.close(); + $('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)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.'); + const item=document.createElement('li');item.textContent=file.name;$('guest-message-uploads').append(item); + } + $('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)return; + const button=$('guest-message-send');button.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-compose').hidden=true;button.hidden=true; + $('guest-message-status').textContent=`Queued for ${result.count} guests. You can undo until ${new Date(result.undoUntil).toLocaleTimeString([], {hour:'numeric',minute:'2-digit'})}.`; + const undo=$('guest-message-undo');undo.hidden=false;undo.disabled=false; + undo.onclick=async()=>{undo.disabled=true;try{await api(`events/${eventId}/messages/undo`,{token:result.token});$('guest-message-status').textContent='Undo requested. Queued messages stopped; delivered messages are being deleted.';undo.hidden=true;}catch(error){$('guest-message-status').textContent=error.message;undo.disabled=false;}}; + }catch(error){$('guest-message-status').textContent=error.message;} + finally{button.disabled=false;} + }; + return async e=>{ + if(uploading){notice('Wait for the attachment upload to finish.');return;} + const draft=await api(`events/${e.id}/messages/start`,{});eventId=e.id;token=draft.token;sent=false; + $('guest-message-event').textContent=e.title;$('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=document.createElement('label');row.className='check-option';const box=document.createElement('input');box.type='checkbox';box.value=key;box.checked=key==='yes';box.onchange=()=>{$('guest-message-all').checked=[...container.querySelectorAll('input')].every(b=>b.checked);}; + const name=document.createElement('span');name.textContent=`${label} (${draft.counts[key]})`;row.append(box,name);container.append(row); + } + dialog.showModal(); + }; +} diff --git a/public/index.html b/public/index.html index fed136d..d505a41 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.

@@ -126,6 +127,21 @@

Your ticket

Your check-in code

Check in guests

Ask the guest to open their check-in code.

Guest list

+ +

Message guests

+

+
+
+ +
+ + +

Up to 20 attachments, 20 MB each.

    +
    +

    +
    +
    +
    diff --git a/public/style.css b/public/style.css index e1b8fd2..af0f1ff 100644 --- a/public/style.css +++ b/public/style.css @@ -10,6 +10,14 @@ 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} #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..522c744 --- /dev/null +++ b/scripts/check-guest-messages.mjs @@ -0,0 +1,72 @@ +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; + 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():{}; + calls.push({method,params}); + 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').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); + 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'}; + 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.ok(calls.every(c=>!('__broadcast' in c.params) && !('__broadcastDelivered' in c.params))); + assert.equal((await api('undo',{token:sent.data.token},710003)).status,400); + assert.equal((await api('undo',{token:sent.data.token})).status,200);await settle(); + assert.equal(calls.filter(c=>c.method==='deleteMessage' && c.params.chat_id===710002).length,3); + // 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(()=>api('undo',{token:next.data.token})); + 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); + 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 1082c8e..ce5895a 100644 --- a/src/bot.js +++ b/src/bot.js @@ -1,4 +1,5 @@ import {paymentMethod,paidEvent} from './event-payment.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'; @@ -257,6 +258,7 @@ 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}`)); @@ -318,6 +320,12 @@ 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.send(id,'This event is unavailable for messages.');} + if(text==='Done')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'; @@ -529,6 +537,16 @@ 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(['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); + if(action==='bm-cancel'){this.session(id);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.'); diff --git a/src/guest-message-api.js b/src/guest-message-api.js new file mode 100644 index 0000000..e1f4aa7 --- /dev/null +++ b/src/guest-message-api.js @@ -0,0 +1,72 @@ +import { mutateState } from './worker-store.js'; +import { startBroadcast,broadcastRecipients,sendBroadcast,undoBroadcast } 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);}); + 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.');} + return respond(await mutateState(env,async(data,bot)=>{ + if(action==='undo'){ + const r=data.preferences[user.id]?.broadcasts?.[input.token]; + if(!r || r.event!==eventId)throw new InputError('This message is unavailable.'); + if(Date.now()>r.undoUntil)throw new InputError('The five-minute undo window has ended.'); + if(!r.undone)await undoBroadcast(bot,user.id,input.token); + 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..c8f613e --- /dev/null +++ b/src/guest-messages.js @@ -0,0 +1,79 @@ +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 function broadcastRecord(data, owner, token) { return data.preferences[owner]?.broadcasts?.[token]; } +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))return; + const key=chatId+':'+messageId; + if(record.receipts.some(r=>r.key===key))return; + record.receipts.push({key,chatId,messageId}); + if(record.undone)await bot.api('deleteMessage',{chat_id:chatId,message_id:messageId}); +} +export async function undoBroadcast(bot,id,token) { + 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(Date.now()>record.undoUntil)return bot.send(id,'The five-minute undo window has ended.'); + record.undone=true; + for(const r of record.receipts)await bot.api('deleteMessage',{chat_id:r.chatId,message_id:r.messageId}); + return bot.send(id,'Undo requested. Queued messages are stopped; delivered messages are being deleted.'); +} +export function startBroadcast(bot,id,e,quiet=false) { + bot.session(id,{step:'broadcast',event:e.id,token:randomBytes(6).toString('hex'),groups:['yes'],items:[]}); + if(!quiet)return broadcastMenu(bot,id,e); +} +export function broadcastMenu(bot,id,e) { + 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}`)); + return bot.send(id,`${e.title}\nMessage guests Β· ${broadcastRecipients(e,s.groups).length} recipients\nChoose groups, then send text, photos, videos or files here.\n${s.items.length} items added. Tap Done to send.`,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)), + [button('βœ… Done Β· Send',`bm-send:${e.id}:${s.token}`),button('βœ– Cancel',`bm-cancel:${e.id}:${s.token}`)] + ])); +} +export function toggleBroadcast(bot,id,e,key) { + 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); +} +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 Done to send.'); + s.items.push(m.message_id); + // 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,'Add a message or attachment first.'); + const pref=bot.db.preferences[id] ||= {},records=pref.broadcasts ||= {}; + for(const [key,r] of Object.entries(records))if(r.undoUntil { stopping = true; }); async function api(method, params = {}) { + const {__broadcast,...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', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(safeParams), signal: AbortSignal.timeout(45000) }); } catch { throw new Error(`Telegram ${method}: network failure`); } const body = await response.json(); diff --git a/src/mini-api.js b/src/mini-api.js index edb4951..0ec7521 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)$/); + 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/worker.js b/src/worker.js index db8107f..635a2e8 100644 --- a/src/worker.js +++ b/src/worker.js @@ -1,4 +1,6 @@ import { mutateState, BusyError } from './worker-store.js'; +import {broadcastRecord,broadcastReceipt} 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'; @@ -84,7 +86,23 @@ export async function drainOutbox(env) { if (Date.now() > deadline || due > Math.floor(Date.now() / 1000)) break; 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; - let result = await telegram(env, row.method, JSON.parse(row.params)); + const {__broadcast:tracking,__broadcastDelivered:delivered,...deliveryParams}=JSON.parse(row.params); + 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 && 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)) { + 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 @@ -120,7 +138,7 @@ export default { return response; } catch (error) { const reference=crypto.randomUUID();console.error(JSON.stringify({event:'mini_api_failed',reference,path:url.pathname,type:error.name})); return Response.json({ error: 'Could not load the planner. Please try again.',reference }, { status: 503, headers: { 'Cache-Control': 'no-store' } }); } } - if (request.method === 'GET' && (url.pathname === '/app' || url.pathname === '/app/' || ['/app.js', '/errors.js', '/gallery.js', '/event-actions.js', '/style.css'].includes(url.pathname))) { + if (request.method === 'GET' && (url.pathname === '/app' || url.pathname === '/app/' || ['/app.js', '/errors.js', '/gallery.js', '/event-actions.js', '/guest-messages.js', '/style.css'].includes(url.pathname))) { const target = new URL(request.url); const appPage = url.pathname === '/app' || url.pathname === '/app/'; if (appPage) target.pathname = '/'; 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/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/guest-messages-ui.test.js b/test/guest-messages-ui.test.js new file mode 100644 index 0000000..b4956e0 --- /dev/null +++ b/test/guest-messages-ui.test.js @@ -0,0 +1,41 @@ +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));} + 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; + const open=setupGuestMessages({$,document:{createElement:tag=>new Node(tag)},initData:'fictional',notice(){},api:async(path,input)=>{ + calls.push({path,input});if(fail)throw Error('Fictional retry error'); + 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;}}; +} +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.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(){}}); + assert.equal(f.calls.at(-1).input.text,'Hello guests');assert.equal(f.calls.at(-1).input.groups.length,5); + const n=f.calls.length;await f.$('guest-message-form').onsubmit({preventDefault(){}});assert.equal(f.calls.length,n); + assert.equal(f.$('guest-message-undo').hidden,false); + await f.$('guest-message-undo').onclick();assert.equal(f.calls.at(-1).path,'events/example/messages/undo'); + 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'});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..97610a2 --- /dev/null +++ b/test/guest-messages.test.js @@ -0,0 +1,52 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {Bot} from '../src/bot.js'; +import {broadcastRecipients,broadcastReceipt} 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('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); +}); From f9d660f0e93c59378e0c0ae9154e20039a4f35d4 Mon Sep 17 00:00:00 2001 From: Ehsan <15076484+ehsan0921@users.noreply.github.com> Date: Thu, 8 Oct 2026 17:45:15 +1100 Subject: [PATCH 07/14] Show invitation totals and response category counts in guest list --- src/bot.js | 19 +++++++++++++++---- test/bot.test.js | 28 ++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/src/bot.js b/src/bot.js index ce5895a..87f364c 100644 --- a/src/bot.js +++ b/src/bot.js @@ -677,10 +677,21 @@ 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${this.updatedAt(e, id)}\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('↻ Refresh', `g:${eid}`),button('Back to event', `v:${eid}`)])); diff --git a/test/bot.test.js b/test/bot.test.js index 191c3b0..41dec00 100644 --- a/test/bot.test.js +++ b/test/bot.test.js @@ -70,6 +70,34 @@ test('guest list and event card have refresh buttons and guest list refresh rech 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}`); From 847d3c3bd4b150cb24ab846b69dd65530435215d Mon Sep 17 00:00:00 2001 From: Ehsan <15076484+ehsan0921@users.noreply.github.com> Date: Thu, 8 Oct 2026 17:47:31 +1100 Subject: [PATCH 08/14] Keep send and cancel controls on empty message prompts --- src/guest-messages.js | 2 +- test/guest-messages.test.js | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/guest-messages.js b/src/guest-messages.js index c8f613e..2d4e9b7 100644 --- a/src/guest-messages.js +++ b/src/guest-messages.js @@ -57,7 +57,7 @@ export function collectBroadcast(bot,id,e,m) { 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,'Add a message or attachment first.'); + if(!s.items.length && !s.text)return bot.send(id,'Add a message or attachment first.',markup([[button('πŸ“¨ Send message',`bm-send:${e.id}:${s.token}`),button('βœ– Cancel',`bm-cancel:${e.id}:${s.token}`)]])); const pref=bot.db.preferences[id] ||= {},records=pref.broadcasts ||= {}; for(const [key,r] of Object.entries(records))if(r.undoUntilbot.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; + await f.cb(1,`bm-send:${f.e.id}:${token}`); + const prompt=f.calls.at(-1);assert.equal(prompt.text,'Add a message or attachment first.'); + assert.deepEqual(prompt.reply_markup.inline_keyboard[0].map(b=>b.callback_data),[`bm-send:${f.e.id}:${token}`,`bm-cancel:${f.e.id}:${token}`]); + await f.msg(1,'Example message');await f.cb(1,prompt.reply_markup.inline_keyboard[0][0].callback_data); + assert.equal(f.calls.filter(c=>c.method==='copyMessage').length,1); +}); + 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]); From d8b723fbc54aaf7683fd284c6418e17c916c2076 Mon Sep 17 00:00:00 2001 From: Ehsan <15076484+ehsan0921@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:03:33 +1100 Subject: [PATCH 09/14] Add message history and immediate delivery with deletion --- public/app.js | 2 +- public/guest-messages.js | 93 ++++++++++++++++++++++++-------- public/index.html | 6 ++- public/style.css | 1 + scripts/check-guest-messages.mjs | 22 ++++++-- src/guest-message-api.js | 18 +++++-- src/guest-messages.js | 46 ++++++++++++---- src/main.js | 2 +- src/mini-api.js | 2 +- src/worker.js | 33 ++++++++---- test/guest-messages-ui.test.js | 23 +++++--- test/guest-messages.test.js | 31 ++++++++++- 12 files changed, 213 insertions(+), 66 deletions(-) diff --git a/public/app.js b/public/app.js index 2a0fcd6..e427876 100644 --- a/public/app.js +++ b/public/app.js @@ -148,7 +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}); +const openGuestMessages=setupGuestMessages({$,document,api,notice,initData,zone:selectedZone}); async function openGuestList(id) { const navigation=pageNavigationGeneration; const {event:e}=await api(`events/${id}`); diff --git a/public/guest-messages.js b/public/guest-messages.js index bf1dae7..72923ea 100644 --- a/public/guest-messages.js +++ b/public/guest-messages.js @@ -1,46 +1,93 @@ -export function setupGuestMessages({$,document,api,notice,initData}) { +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; + 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'}; - $('guest-message-close').onclick=()=>dialog.close(); + 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)return;uploading=true;$('guest-message-send').disabled=true; + 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.'); - const item=document.createElement('li');item.textContent=file.name;$('guest-message-uploads').append(item); + $('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)return; - const button=$('guest-message-send');button.disabled=true; + 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-compose').hidden=true;button.hidden=true; - $('guest-message-status').textContent=`Queued for ${result.count} guests. You can undo until ${new Date(result.undoUntil).toLocaleTimeString([], {hour:'numeric',minute:'2-digit'})}.`; - const undo=$('guest-message-undo');undo.hidden=false;undo.disabled=false; - undo.onclick=async()=>{undo.disabled=true;try{await api(`events/${eventId}/messages/undo`,{token:result.token});$('guest-message-status').textContent='Undo requested. Queued messages stopped; delivered messages are being deleted.';undo.hidden=true;}catch(error){$('guest-message-status').textContent=error.message;undo.disabled=false;}}; - }catch(error){$('guest-message-status').textContent=error.message;} - finally{button.disabled=false;} + 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){notice('Wait for the attachment upload to finish.');return;} - const draft=await api(`events/${e.id}/messages/start`,{});eventId=e.id;token=draft.token;sent=false; - $('guest-message-event').textContent=e.title;$('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=document.createElement('label');row.className='check-option';const box=document.createElement('input');box.type='checkbox';box.value=key;box.checked=key==='yes';box.onchange=()=>{$('guest-message-all').checked=[...container.querySelectorAll('input')].every(b=>b.checked);}; - const name=document.createElement('span');name.textContent=`${label} (${draft.counts[key]})`;row.append(box,name);container.append(row); - } - dialog.showModal(); + 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 d505a41..ae0d7c8 100644 --- a/public/index.html +++ b/public/index.html @@ -130,7 +130,9 @@

    Message guests

    -
    +

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

    +

    +
    @@ -139,7 +141,7 @@

    Up to 20 attachments, 20 MB each.

      -
      +
      diff --git a/public/style.css b/public/style.css index af0f1ff..65c4d58 100644 --- a/public/style.css +++ b/public/style.css @@ -18,6 +18,7 @@ fieldset.panel{min-width:0}fieldset legend{font-size:18px;font-weight:700;letter #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 index 522c744..a8eea37 100644 --- a/scripts/check-guest-messages.mjs +++ b/scripts/check-guest-messages.mjs @@ -4,7 +4,7 @@ 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; + 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'}, @@ -12,6 +12,7 @@ export async function checkGuestMessages(){ const method=new URL(request.url).pathname.split('/').at(-1); const params=request.headers.get('Content-Type')?.includes('application/json')?await request.json():{}; 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}}); } @@ -32,13 +33,14 @@ export async function checkGuestMessages(){ }; const settle=async(kick)=>{ for(let i=0;i<100;i++){ - if((await db.prepare('SELECT COUNT(*) AS n FROM outbox').first()).n===0)return; + 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); @@ -47,15 +49,23 @@ export async function checkGuestMessages(){ 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); - assert.equal((await api('undo',{token:sent.data.token})).status,200);await settle(); + 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; @@ -64,9 +74,13 @@ export async function checkGuestMessages(){ 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(()=>api('undo',{token:next.data.token})); + 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); 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/src/guest-message-api.js b/src/guest-message-api.js index e1f4aa7..7cc932f 100644 --- a/src/guest-message-api.js +++ b/src/guest-message-api.js @@ -1,5 +1,5 @@ import { mutateState } from './worker-store.js'; -import { startBroadcast,broadcastRecipients,sendBroadcast,undoBroadcast } from './guest-messages.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'; @@ -30,7 +30,7 @@ export async function guestMessageApi(request,env,user,eventId,action,respond) { 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);}); + 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.'); @@ -38,12 +38,20 @@ export async function guestMessageApi(request,env,user,eventId,action,respond) { 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'){ + 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(Date.now()>r.undoUntil)throw new InputError('The five-minute undo window has ended.'); - if(!r.undone)await undoBroadcast(bot,user.id,input.token); + 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'){ diff --git a/src/guest-messages.js b/src/guest-messages.js index 2d4e9b7..dc2ad21 100644 --- a/src/guest-messages.js +++ b/src/guest-messages.js @@ -7,25 +7,50 @@ const groups = { yes: 'Accepted', no: 'Rejected', maybe: 'Maybe', later: 'Respon const markup = rows => ({ inline_keyboard: rows }); const button = (text, callback_data) => ({ text, callback_data }); 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))return; + if(!record || !Number.isSafeInteger(messageId) || messageId<=0)return; const key=chatId+':'+messageId; if(record.receipts.some(r=>r.key===key))return; - record.receipts.push({key,chatId,messageId}); - if(record.undone)await bot.api('deleteMessage',{chat_id:chatId,message_id:messageId}); + 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) { +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(Date.now()>record.undoUntil)return bot.send(id,'The five-minute undo window has ended.'); + 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; - for(const r of record.receipts)await bot.api('deleteMessage',{chat_id:r.chatId,message_id:r.messageId}); - return bot.send(id,'Undo requested. Queued messages are stopped; delivered messages are being deleted.'); + 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 function startBroadcast(bot,id,e,quiet=false) { bot.session(id,{step:'broadcast',event:e.id,token:randomBytes(6).toString('hex'),groups:['yes'],items:[]}); @@ -52,6 +77,8 @@ export function collectBroadcast(bot,id,e,m) { if(s.items.includes(m.message_id))return; if(s.items.length>=20)return bot.send(id,'Maximum 20 items. Tap Done to send.'); 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) { @@ -59,9 +86,8 @@ export async function sendBroadcast(bot,id,e) { if(!recipients.length)return bot.send(id,'No reachable guests selected. Choose another group.'); if(!s.items.length && !s.text)return bot.send(id,'Add a message or attachment first.',markup([[button('πŸ“¨ Send message',`bm-send:${e.id}:${s.token}`),button('βœ– Cancel',`bm-cancel:${e.id}:${s.token}`)]])); const pref=bot.db.preferences[id] ||= {},records=pref.broadcasts ||= {}; - for(const [key,r] of Object.entries(records))if(r.undoUntil { stopping = true; }); async function api(method, params = {}) { - const {__broadcast,...safeParams}=params; + const {__broadcast,__broadcastDelete,__broadcastNotice,...safeParams}=params; // Never log URLs or raw fetch errors: Telegram URLs contain the token. let response; try { diff --git a/src/mini-api.js b/src/mini-api.js index 0ec7521..5090f22 100644 --- a/src/mini-api.js +++ b/src/mini-api.js @@ -258,7 +258,7 @@ 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)$/); + 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); diff --git a/src/worker.js b/src/worker.js index 635a2e8..36a0754 100644 --- a/src/worker.js +++ b/src/worker.js @@ -1,5 +1,5 @@ import { mutateState, BusyError } from './worker-store.js'; -import {broadcastRecord,broadcastReceipt} from './guest-messages.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'; @@ -75,28 +75,34 @@ export function unavailablePhotoFallback(params, result) { ...(disable_notification !== undefined ? { disable_notification } : {}), ...(protect_content !== undefined ? { protect_content } : {}) }; } -export async function drainOutbox(env) { +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,true,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,'$.__broadcast') IS NOT NULL OR json_type(params,'$.__broadcastNotice') IS NOT NULL THEN 1 ELSE 2 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 {__broadcast:tracking,__broadcastDelivered:delivered,...deliveryParams}=JSON.parse(row.params); + const {__broadcast:tracking,__broadcastDelete:deletion,__broadcastNotice,__broadcastDelivered:delivered,...deliveryParams}=JSON.parse(row.params); 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 && event && !event.cancelled && event.owner===tracking.owner && event.guests[deliveryParams.chat_id] && invitationAvailable(event,deliveryParams.chat_id)); + 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)) { + 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(); @@ -116,14 +122,19 @@ export async function drainOutbox(env) { 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; } } + } while(immediate && Date.now()[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; - const open=setupGuestMessages({$,document:{createElement:tag=>new Node(tag)},initData:'fictional',notice(){},api:async(path,input)=>{ + 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;}}; + 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(){}}); - assert.equal(f.calls.at(-1).input.text,'Hello guests');assert.equal(f.calls.at(-1).input.groups.length,5); + 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); - assert.equal(f.$('guest-message-undo').hidden,false); - await f.$('guest-message-undo').onclick();assert.equal(f.calls.at(-1).path,'events/example/messages/undo'); + 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'});f.setFailure(true); + 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/); diff --git a/test/guest-messages.test.js b/test/guest-messages.test.js index 93e09a7..2166c16 100644 --- a/test/guest-messages.test.js +++ b/test/guest-messages.test.js @@ -1,7 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import {Bot} from '../src/bot.js'; -import {broadcastRecipients,broadcastReceipt} from '../src/guest-messages.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:[]}; @@ -59,3 +59,32 @@ test('undo is owner-scoped, expires after five minutes, and deletes deliveries r 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/); +}); From 333da423541d93d2757343bde26ca3c6dc832669 Mon Sep 17 00:00:00 2001 From: Ehsan <15076484+ehsan0921@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:07:31 +1100 Subject: [PATCH 10/14] Use reply keyboard for guest message composition --- src/bot.js | 7 ++++--- src/guest-messages.js | 15 ++++++++------- src/worker.js | 2 +- test/guest-messages.test.js | 15 +++++++++++++-- 4 files changed, 26 insertions(+), 13 deletions(-) diff --git a/src/bot.js b/src/bot.js index 87f364c..264caf1 100644 --- a/src/bot.js +++ b/src/bot.js @@ -322,8 +322,9 @@ export class Bot { 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.send(id,'This event is unavailable for messages.');} - if(text==='Done')return sendBroadcast(this,id,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); @@ -544,7 +545,7 @@ export class Bot { 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); - if(action==='bm-cancel'){this.session(id);return this.card(id,e);} + 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') { diff --git a/src/guest-messages.js b/src/guest-messages.js index dc2ad21..5ac85cb 100644 --- a/src/guest-messages.js +++ b/src/guest-messages.js @@ -6,6 +6,7 @@ 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])=>{ @@ -52,17 +53,16 @@ export async function undoBroadcast(bot,id,token,deleteOld=false) { 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 function startBroadcast(bot,id,e,quiet=false) { - bot.session(id,{step:'broadcast',event:e.id,token:randomBytes(6).toString('hex'),groups:['yes'],items:[]}); - if(!quiet)return broadcastMenu(bot,id,e); +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 bot.send(id,'Add text or attachments, then tap Send message below.',broadcastKeyboard());return broadcastMenu(bot,id,e);} } export function broadcastMenu(bot,id,e) { 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}`)); - return bot.send(id,`${e.title}\nMessage guests Β· ${broadcastRecipients(e,s.groups).length} recipients\nChoose groups, then send text, photos, videos or files here.\n${s.items.length} items added. Tap Done to send.`,markup([ + return bot.send(id,`${e.title}\nMessage guests Β· ${broadcastRecipients(e,s.groups).length} recipients\nChoose groups, then send text, photos, videos or files here.\n${s.items.length} items added. Tap Send message below.`,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)), - [button('βœ… Done Β· Send',`bm-send:${e.id}:${s.token}`),button('βœ– Cancel',`bm-cancel:${e.id}:${s.token}`)] ])); } export function toggleBroadcast(bot,id,e,key) { @@ -75,7 +75,7 @@ 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 Done to send.'); + 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')); @@ -84,7 +84,7 @@ export function collectBroadcast(bot,id,e,m) { 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,'Add a message or attachment first.',markup([[button('πŸ“¨ Send message',`bm-send:${e.id}:${s.token}`),button('βœ– Cancel',`bm-cancel:${e.id}:${s.token}`)]])); + if(!s.items.length && !s.text)return bot.send(id,'Add a message or attachment first.',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}; @@ -101,5 +101,6 @@ export async function sendBroadcast(bot,id,e) { await broadcastReceipt(bot,id,token,chatId,result?.message_id); } } + if(s.chatComposer)await bot.home(id,'Message composer closed.'); 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/worker.js b/src/worker.js index 36a0754..3609e1b 100644 --- a/src/worker.js +++ b/src/worker.js @@ -199,7 +199,7 @@ export default { if (!Number.isSafeInteger(update.update_id)) return new Response('Invalid update', { status: 400 }); try { const response = await processUpdate(env, update); - if (response.ok) ctx.waitUntil(drainOutbox(env,/^bm-(send|undo):/.test(update.callback_query?.data || ''))); + if (response.ok) ctx.waitUntil(drainOutbox(env,/^bm-(send|undo):/.test(update.callback_query?.data || '') || ['πŸ“¨ Send message','Done'].includes(update.message?.text?.trim()))); return response; } catch { console.error(JSON.stringify({ event: 'update_failed', update_id: update.update_id })); diff --git a/test/guest-messages.test.js b/test/guest-messages.test.js index 2166c16..b404867 100644 --- a/test/guest-messages.test.js +++ b/test/guest-messages.test.js @@ -15,9 +15,20 @@ 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; await f.cb(1,`bm-send:${f.e.id}:${token}`); const prompt=f.calls.at(-1);assert.equal(prompt.text,'Add a message or attachment first.'); - assert.deepEqual(prompt.reply_markup.inline_keyboard[0].map(b=>b.callback_data),[`bm-send:${f.e.id}:${token}`,`bm-cancel:${f.e.id}:${token}`]); - await f.msg(1,'Example message');await f.cb(1,prompt.reply_markup.inline_keyboard[0][0].callback_data); + 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); +}); + +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('messaging defaults to accepted and isolates each response group including unanswered',async()=>{ From 9f32d6cb1c387e7bd40ed0f2f901712ffe092434 Mon Sep 17 00:00:00 2001 From: Ehsan <15076484+ehsan0921@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:11:27 +1100 Subject: [PATCH 11/14] Add shared media link copying and optional QR code --- public/app.js | 1 + public/gallery.js | 13 +++++++++++-- public/index.html | 2 +- src/media-api.js | 10 ++++++++-- test/gallery-ux.test.js | 8 ++++++++ test/optional-qr.test.js | 15 +++++++++++++++ 6 files changed, 44 insertions(+), 5 deletions(-) diff --git a/public/app.js b/public/app.js index e427876..9aca2b5 100644 --- a/public/app.js +++ b/public/app.js @@ -1041,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/index.html b/public/index.html index ae0d7c8..5dcb654 100644 --- a/public/index.html +++ b/public/index.html @@ -120,7 +120,7 @@

      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
      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/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/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}); From f1e66317bf24a0bb95d50540b4833fd42e3d429b Mon Sep 17 00:00:00 2001 From: Ehsan <15076484+ehsan0921@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:14:43 +1100 Subject: [PATCH 12/14] Simplify event menu and send upload QR codes in chat --- scripts/check-guest-messages.mjs | 9 ++++++++- src/bot.js | 17 +++++++++++++---- src/main.js | 3 ++- src/telegram-upload.js | 24 ++++++++++++++++++++++++ src/worker.js | 3 ++- test/bot.test.js | 22 ++++++++++++++++++---- test/telegram-upload.test.js | 15 +++++++++++++++ 7 files changed, 82 insertions(+), 11 deletions(-) create mode 100644 src/telegram-upload.js create mode 100644 test/telegram-upload.test.js diff --git a/scripts/check-guest-messages.mjs b/scripts/check-guest-messages.mjs index a8eea37..ffe830a 100644 --- a/scripts/check-guest-messages.mjs +++ b/scripts/check-guest-messages.mjs @@ -10,7 +10,7 @@ export async function checkGuestMessages(){ 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():{}; + 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;});} @@ -81,6 +81,13 @@ export async function checkGuestMessages(){ 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'); 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/src/bot.js b/src/bot.js index 264caf1..22448e8 100644 --- a/src/bot.js +++ b/src/bot.js @@ -1,4 +1,5 @@ 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'; @@ -263,15 +264,18 @@ export class Bot { 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(e.cancelled ? button('Delete event', `delete:${e.id}`) : button('Cancel event', `x:${e.id}`)); rows.push(...paired(extras)); - if(!rsvpOnly)rows.push([button('↻ Refresh',`v:${e.id}`)],[button(menu.events, 'nav:events'), button(menu.home, 'nav:home')]); + 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.'; const location = canSeeLocation(e, id) ? e.location || 'Location to follow' : paidEvent(e) ? requiresApproval(e) ? 'Shared after approval and confirmed payment' : 'Shared after confirmed payment' : requiresApproval(e) ? 'Shared after organiser approval' : 'Shared after acceptance'; let text = `πŸŽ‰ ${e.title}${e.cancelled ? ' β€” CANCELLED' : ''}\n\nπŸ—“ ${this.time(e, id)}\nπŸ“ ${location}\n\n${priceText(e,this.db.preferences[id],this.pricing || this.db.preferences._pricing)}\n\n${[e.inviteMessage,e.description].filter(Boolean).join('\n\n')}\n\n${host ? (owner ? 'You’re the organiser.\n\n' : 'You’re the co-host.\n\n') : mode==='named' ? 'Personal invitation for '+e.guests[id].name+'\n\n'+(accepted ? 'βœ… Accepted\n\n' : '') : mode==='tickets' ? (accepted ? '🎟 Ticket '+(confirmed(e,e.guests[id]) ? 'confirmed' : 'requested')+'\n\n' : '') : accepted ? 'βœ… Accepted\n\n' : ''}${closed ? '⏰ Responses closed β€” deadline passed.\n\n' : ''}${e.responseDeadline ? 'Response deadline: ' + eventTime({ startsAt: e.responseDeadline, timezone: e.deadlineTimezone || e.timezone || 'UTC' }, this.db.preferences[id]?.timezone) + '\n\n' : ''}${counts}${host && mode!=='named' ? '\n\n'+(mode==='tickets' ? 'Ticket link:' : 'Invite people:')+'\n' + this.link(e) : ''}\n\n${visibility} `; @@ -539,6 +543,11 @@ export class Bot { 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); diff --git a/src/main.js b/src/main.js index d4eca3a..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'; @@ -24,7 +25,7 @@ async function api(method, params = {}) { let response; try { response = await fetch(`https://api.telegram.org/bot${token}/${method}`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(safeParams), 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/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 && rcall.text==='Use the event buttons below.'),false); }); -test('guest list and event card have refresh buttons and guest list refresh rechecks visibility',async()=>{ +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' && b.callback_data===`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}`); @@ -443,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); }); @@ -529,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/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'}); +}); From d0d50abe1a255be647d8de59873316981fc1b4e5 Mon Sep 17 00:00:00 2001 From: Ehsan <15076484+ehsan0921@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:17:48 +1100 Subject: [PATCH 13/14] Clarify guest message composition and update category controls in place --- scripts/check-guest-messages.mjs | 8 ++++++++ src/bot.js | 4 ++-- src/guest-messages.js | 23 +++++++++++++---------- src/worker.js | 2 +- test/guest-messages.test.js | 21 ++++++++++++++++++++- 5 files changed, 44 insertions(+), 14 deletions(-) diff --git a/scripts/check-guest-messages.mjs b/scripts/check-guest-messages.mjs index ffe830a..46986ce 100644 --- a/scripts/check-guest-messages.mjs +++ b/scripts/check-guest-messages.mjs @@ -88,6 +88,14 @@ export async function checkGuestMessages(){ 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); 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/src/bot.js b/src/bot.js index 22448e8..4eb12c3 100644 --- a/src/bot.js +++ b/src/bot.js @@ -85,7 +85,7 @@ 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 } }; } @@ -553,7 +553,7 @@ export class Bot { 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); + 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); } diff --git a/src/guest-messages.js b/src/guest-messages.js index 5ac85cb..5561965 100644 --- a/src/guest-messages.js +++ b/src/guest-messages.js @@ -55,21 +55,24 @@ export async function undoBroadcast(bot,id,token,deleteOld=false) { } 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 bot.send(id,'Add text or attachments, then tap Send message below.',broadcastKeyboard());return broadcastMenu(bot,id,e);} + 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) { +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}`)); - return bot.send(id,`${e.title}\nMessage guests Β· ${broadcastRecipients(e,s.groups).length} recipients\nChoose groups, then send text, photos, videos or files here.\n${s.items.length} items added. Tap Send message below.`,markup([ - [button(`${s.groups.length===Object.keys(groups).length?'β˜‘':'☐'} All` ,`bm-group:${e.id}:all:${s.token}`)], + 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) { +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); + return broadcastMenu(bot,id,e,message); } export function collectBroadcast(bot,id,e,m) { const s=bot.db.sessions[id]; @@ -84,7 +87,7 @@ export function collectBroadcast(bot,id,e,m) { 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,'Add a message or attachment first.',broadcastKeyboard()); + 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}; @@ -101,6 +104,6 @@ export async function sendBroadcast(bot,id,e) { await broadcastReceipt(bot,id,token,chatId,result?.message_id); } } - if(s.chatComposer)await bot.home(id,'Message composer closed.'); + 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/worker.js b/src/worker.js index 180ac58..628c2cd 100644 --- a/src/worker.js +++ b/src/worker.js @@ -200,7 +200,7 @@ export default { if (!Number.isSafeInteger(update.update_id)) return new Response('Invalid update', { status: 400 }); try { const response = await processUpdate(env, update); - if (response.ok) ctx.waitUntil(drainOutbox(env,/^bm-(send|undo):/.test(update.callback_query?.data || '') || ['πŸ“¨ Send message','Done'].includes(update.message?.text?.trim()))); + if (response.ok) ctx.waitUntil(drainOutbox(env,/^bm-(send|undo|group):/.test(update.callback_query?.data || '') || ['πŸ“¨ Send message','Done'].includes(update.message?.text?.trim()))); return response; } catch { console.error(JSON.stringify({ event: 'update_failed', update_id: update.update_id })); diff --git a/test/guest-messages.test.js b/test/guest-messages.test.js index b404867..1b4f90f 100644 --- a/test/guest-messages.test.js +++ b/test/guest-messages.test.js @@ -13,13 +13,18 @@ function fixture(){ } 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,'Add a message or attachment first.'); + 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()=>{ @@ -31,6 +36,20 @@ test('composer reply keyboard cancels without sending its button text',async()=> 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]); From d74765966c2b7989d3242346333621dd726bbe1f Mon Sep 17 00:00:00 2001 From: Ehsan <15076484+ehsan0921@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:25:23 +1100 Subject: [PATCH 14/14] Reduce bot delivery delays and recover brief database lock contention --- scripts/check-guest-messages.mjs | 13 +++++++++++++ src/worker-store.js | 7 ++++++- src/worker.js | 17 ++++++++++++++--- test/delivery-fallback.test.js | 2 +- test/delivery-queue.test.js | 11 +++++++++++ test/snapshot-mode.test.js | 2 +- 6 files changed, 46 insertions(+), 6 deletions(-) create mode 100644 test/delivery-queue.test.js diff --git a/scripts/check-guest-messages.mjs b/scripts/check-guest-messages.mjs index 46986ce..0d3fc61 100644 --- a/scripts/check-guest-messages.mjs +++ b/scripts/check-guest-messages.mjs @@ -96,6 +96,19 @@ export async function checkGuestMessages(){ 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/src/worker-store.js b/src/worker-store.js index fae4d09..73438af 100644 --- a/src/worker-store.js +++ b/src/worker-store.js @@ -7,7 +7,12 @@ export class BusyError extends Error {} export async function mutateState(env, action, updateId) { const owner = crypto.randomUUID(); - const lock = await env.DB.prepare('INSERT INTO lease (id, owner, expires) VALUES (1, ?, unixepoch()+60) ON CONFLICT(id) DO UPDATE SET owner=excluded.owner, expires=excluded.expires WHERE lease.expires < unixepoch() RETURNING owner').bind(owner).first(); + let lock; + for(let attempt=0;attempt<21;attempt++){ + lock=await env.DB.prepare('INSERT INTO lease (id, owner, expires) VALUES (1, ?, unixepoch()+60) ON CONFLICT(id) DO UPDATE SET owner=excluded.owner, expires=excluded.expires WHERE lease.expires < unixepoch() RETURNING owner').bind(owner).first(); + if(lock)break; + if(attempt<20)await new Promise(resolve=>setTimeout(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 628c2cd..b694404 100644 --- a/src/worker.js +++ b/src/worker.js @@ -80,13 +80,13 @@ 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,true,attempt+1);} + if(attempt<(immediate?25:5)){await new Promise(resolve=>setTimeout(resolve,200));return drainOutbox(env,immediate,attempt+1);} return; } const deadline = Date.now() + 20000; try { 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,'$.__broadcast') IS NOT NULL OR json_type(params,'$.__broadcastNotice') IS NOT NULL THEN 1 ELSE 2 END,rowid LIMIT 20" : 'SELECT id,due FROM outbox ORDER BY rowid LIMIT 20'; + 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) { @@ -94,6 +94,7 @@ export async function drainOutbox(env, immediate=false, attempt=0) { 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 {__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); @@ -134,8 +135,18 @@ export async function drainOutbox(env, immediate=false, attempt=0) { await env.DB.prepare('UPDATE outbox SET due=unixepoch()+? WHERE id=?').bind(seconds, id).run(); 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(immediate && Date.now(){ 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:[row]};},async run(){ + 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']); 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/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() {} };