Skip to content

Commit 31d85bd

Browse files
HugoGresseclaudefteychene
authored
WhatsApp track management (GreenAPI): setup + interactive ready/GO flow (#269)
* feat(whatsapp): Part 1 — GreenAPI setup + test-send page Track-management messaging will run over WhatsApp via GreenAPI. This first part wires the plumbing: - Event gains greenApiInstanceId / greenApiToken (persisted through mapEventDevSettingsFormToMutateObject, guarded so other forms don't wipe them) - New admin page /whatsapp: save GreenAPI credentials, and once set, send a single test message to validate the setup - Reachable from the Integration & API page via an "Open" link - Backend POST /v1/:eventId/whatsapp/send-test (apiKey-protected) calls GreenAPI server-side so the token never reaches the browser Part 2 will add the per-track "ready" -> overall GO flow on top of this. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(whatsapp): Part 2 — interactive track buttons, ready loop, GO Drive track management over GreenAPI interactive buttons: - SendInteractiveButtonsReply with a 3-button-per-message limit; tracks are split across as many messages as needed - A GreenAPI incoming webhook (/v1/whatsapp/webhook, mapped to the event by instance id) receives a button tap, marks that track ready, edits the now-button-less message to a status recap, and re-offers buttons for the tracks still pending in that group - When every track is ready, a GO message is sent to the same chat (once) - Readiness is persisted per event (events/{id}/whatsapp/current) so the admin page can poll a status endpoint and show progress Flow logic lives in trackFlow.ts with injected senders and is covered by unit tests (chunking, ready/edit/re-offer, single GO, idempotent press). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(whatsapp): guard undefined tracks in track management status status?.tracks could be undefined (optional chaining stopped at status), crashing on .filter. Default to an empty list before reading it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(whatsapp): correct webhook URL formatting + clarify chat format - normalise API_URL trailing slash so the webhook URL isn't mangled (was producing "...frv1/whatsapp/webhook") - guide the shared chat input toward a chatId ending with @c.us / @g.us Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(whatsapp): drop unsupported 'type' field on interactive buttons GreenAPI sendInteractiveButtonsReply rejects buttons[].type ("'buttons[0].type' is not allowed"); send only buttonId + buttonText. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(whatsapp): persist shared chat + authorize the webhook - Prefill the shared chat input from the stored session chatId so the operator doesn't retype it each time. - Require an Authorization header on the incoming GreenAPI webhook, matched against the event apiKey (raw or "Bearer <key>"); reject forged button presses with 401. The page shows the value to configure in GreenAPI alongside the webhook URL. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(whatsapp): make button-tap webhook actually reachable Presses did nothing because GreenAPI was never reliably calling our webhook (URL/token not set), and the new auth check then rejected any call lacking the token. - Add an "auto-configure" path: POST .../whatsapp/configure-webhook sets the instance webhookUrl + webhookUrlToken (= event apiKey, sent back as "Authorization: Bearer <key>") + incomingWebhook=yes via GreenAPI setSettings. Frontend button passes the computed webhook URL. - Log every webhook call (typeWebhook, typeMessage, extracted button id, auth presence) so "press did nothing" is traceable in function logs. - Keep manual URL + token display as a fallback. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(whatsapp): persist the shared chat id on the event The shared chat was only kept inside the session (written on send), so it looked unsaved. Store whatsappSharedChatId on the event doc, seed the input from it, add an explicit "Save chat" button, and also persist it when sending track buttons. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(whatsapp): status route returned {} due to Type.Any() reply schema fast-json-stringify with a bare Type.Any() response schema strips every field, so the status endpoint always returned an empty object. Give it an explicit schema (chatId/tracks/messages/goSent). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(whatsapp): use a poll instead of interactive buttons + event-scoped webhook Interactive/reply buttons don't reliably produce incoming webhooks on standard WhatsApp, so taps were never received. Switch to a WhatsApp poll (sendPoll), whose votes DO arrive as pollUpdateMessage webhooks. - One poll lists the tracks as options (up to 12 per poll, split beyond that). A vote marks the matching track ready (by option name, sticky); GO is sent once all are ready. - Webhook is now event-scoped: POST /v1/:eventId/whatsapp/webhook, so it starts with the API base + event id and needs no instance->event lookup. Auth via Authorization header == event apiKey (raw or Bearer). - configure-webhook enables pollMessageWebhook too; the page shows/sets the event-scoped URL. - Drop button send/edit/re-offer logic and findEventByInstanceId; tests updated for the poll flow. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat: Add automation of track panel after go * refacto: Rework the panels infos feedback on track mngt * Improve whatsapp UI, add schedule tasks and upcoming session date * Changew message if not all rdy * feta: Change panel configuration for track auto messages on GO * refacto: Set real production times for automatic panel talk configuration * Improve ui * Add cancel support --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: fteychene <francois.teychene@gmail.com>
1 parent 26bde64 commit 31d85bd

25 files changed

Lines changed: 2331 additions & 32 deletions

functions/package-lock.json

Lines changed: 469 additions & 27 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import fb from 'firebase-admin'
2+
import { PanelTaskPayload } from './whatsappPanelTask'
3+
4+
// The reminder tasks live in the Cloud Tasks queue Firebase creates for the onTaskDispatched function
5+
// (queue id === function name), in the same region it is deployed to. We talk to the Cloud Tasks REST
6+
// API directly (authenticated with the admin SDK's access token) to avoid pulling the heavy
7+
// @google-cloud/tasks client just for list/delete.
8+
const LOCATION = 'europe-west1'
9+
const QUEUE_ID = 'sendWhatsappPanel'
10+
const API_BASE = 'https://cloudtasks.googleapis.com/v2'
11+
12+
export type ScheduledPanel = { name: string; scheduleTime: string | null; message: string }
13+
14+
const projectId = (app: fb.app.App): string => {
15+
const id =
16+
app.options.projectId ||
17+
process.env.GCLOUD_PROJECT ||
18+
process.env.GCP_PROJECT ||
19+
process.env.GOOGLE_CLOUD_PROJECT
20+
if (!id) throw new Error('Project id not found in environment')
21+
return id
22+
}
23+
24+
// The admin SDK credential yields an OAuth token scoped for cloud-platform, which Cloud Tasks accepts.
25+
const accessToken = async (app: fb.app.App): Promise<string> => {
26+
const credential = app.options.credential ?? fb.credential.applicationDefault()
27+
const { access_token } = await credential.getAccessToken()
28+
return access_token
29+
}
30+
31+
const queuePath = (app: fb.app.App): string => `projects/${projectId(app)}/locations/${LOCATION}/queues/${QUEUE_ID}`
32+
33+
// `path` is a resource path (e.g. "projects/.../tasks/123"), optionally with a query string.
34+
const apiFetch = async (app: fb.app.App, path: string, init: RequestInit = {}): Promise<any> => {
35+
const res = await fetch(`${API_BASE}/${path}`, {
36+
...init,
37+
headers: {
38+
Authorization: `Bearer ${await accessToken(app)}`,
39+
'Content-Type': 'application/json',
40+
...(init.headers || {}),
41+
},
42+
})
43+
const text = await res.text()
44+
if (!res.ok) {
45+
throw new Error(`Cloud Tasks ${init.method || 'GET'} failed (${res.status}): ${text}`)
46+
}
47+
return text ? JSON.parse(text) : {}
48+
}
49+
50+
// Firebase enqueues the payload as the JSON body `{ data: <payload> }` (base64 in the REST response);
51+
// tolerate a bare payload too.
52+
const decodePayload = (bodyBase64: unknown): PanelTaskPayload | null => {
53+
if (typeof bodyBase64 !== 'string' || bodyBase64.length === 0) return null
54+
try {
55+
const parsed = JSON.parse(Buffer.from(bodyBase64, 'base64').toString('utf8'))
56+
return (parsed?.data ?? parsed) as PanelTaskPayload
57+
} catch {
58+
return null
59+
}
60+
}
61+
62+
// Lists the still-pending reminder tasks for one event, soonest first.
63+
export const listScheduledPanels = async (app: fb.app.App, eventId: string): Promise<ScheduledPanel[]> => {
64+
const out: ScheduledPanel[] = []
65+
let pageToken: string | undefined
66+
// Few tasks per queue, but page through to be correct.
67+
do {
68+
const query = `responseView=FULL&pageSize=100${pageToken ? `&pageToken=${encodeURIComponent(pageToken)}` : ''}`
69+
const data = await apiFetch(app, `${queuePath(app)}/tasks?${query}`)
70+
for (const task of data.tasks || []) {
71+
const payload = decodePayload(task?.httpRequest?.body)
72+
if (!payload || payload.eventId !== eventId) continue
73+
out.push({ name: String(task.name), scheduleTime: task.scheduleTime || null, message: payload.message })
74+
}
75+
pageToken = data.nextPageToken || undefined
76+
} while (pageToken)
77+
78+
return out.sort((a, b) => (a.scheduleTime || '').localeCompare(b.scheduleTime || ''))
79+
}
80+
81+
// Deletes a single reminder task, but only if it belongs to this event's queue (guards against
82+
// deleting another queue's / another event's task via a forged name).
83+
export const deleteScheduledPanel = async (app: fb.app.App, eventId: string, name: string): Promise<boolean> => {
84+
if (!name.startsWith(`${queuePath(app)}/tasks/`)) return false
85+
try {
86+
const task = await apiFetch(app, `${name}?responseView=FULL`)
87+
const payload = decodePayload(task?.httpRequest?.body)
88+
if (!payload || payload.eventId !== eventId) return false
89+
} catch {
90+
return false
91+
}
92+
await apiFetch(app, name, { method: 'DELETE' })
93+
return true
94+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
// Thin GreenAPI (WhatsApp) client. Each instance also has its own host, but the shared gateway
2+
// routes by instance id, so no per-event URL is needed.
3+
const GREEN_API_BASE = 'https://api.green-api.com'
4+
5+
export type GreenApiCreds = {
6+
instanceId: string
7+
token: string
8+
}
9+
10+
export const credsFromEvent = (event: {
11+
greenApiInstanceId?: string | null
12+
greenApiToken?: string | null
13+
}): GreenApiCreds | null => {
14+
if (!event.greenApiInstanceId || !event.greenApiToken) return null
15+
return { instanceId: event.greenApiInstanceId, token: event.greenApiToken }
16+
}
17+
18+
const call = async (creds: GreenApiCreds, method: string, payload: unknown): Promise<any> => {
19+
const url = `${GREEN_API_BASE}/waInstance${creds.instanceId}/${method}/${creds.token}`
20+
const res = await fetch(url, {
21+
method: 'POST',
22+
headers: { 'Content-Type': 'application/json' },
23+
body: JSON.stringify(payload),
24+
})
25+
const text = await res.text()
26+
if (!res.ok) {
27+
throw new Error(`GreenAPI ${method} failed (${res.status}): ${text}`)
28+
}
29+
return text ? JSON.parse(text) : {}
30+
}
31+
32+
const callGet = async (creds: GreenApiCreds, method: string): Promise<any> => {
33+
const url = `${GREEN_API_BASE}/waInstance${creds.instanceId}/${method}/${creds.token}`
34+
const res = await fetch(url)
35+
const text = await res.text()
36+
if (!res.ok) {
37+
throw new Error(`GreenAPI ${method} failed (${res.status}): ${text}`)
38+
}
39+
return text ? JSON.parse(text) : {}
40+
}
41+
42+
// A WhatsApp chatId for a person is the phone number (digits only, country code included) + "@c.us".
43+
// A value that already looks like a chatId (contains "@") is passed through untouched.
44+
export const toChatId = (raw: string): string => {
45+
if (raw.includes('@')) return raw
46+
return `${raw.replace(/[^0-9]/g, '')}@c.us`
47+
}
48+
49+
// A WhatsApp chat as exposed to the admin UI. `type` is 'group' for group chats (id ends @g.us)
50+
// and 'user' for single contacts (id ends @c.us).
51+
export type GreenApiContact = { id: string; name: string; type: 'group' | 'user' }
52+
53+
// https://green-api.com/en/docs/api/chats/GetChats/ — lists every chat and group the instance has.
54+
// Used so the operator can pick the shared chat / test recipient instead of pasting a raw chatId.
55+
export const getChats = async (creds: GreenApiCreds): Promise<GreenApiContact[]> => {
56+
const data = await callGet(creds, 'getChats')
57+
if (!Array.isArray(data)) return []
58+
return data
59+
.map((c: any) => {
60+
const id = String(c?.id ?? '')
61+
const type: 'group' | 'user' = id.endsWith('@g.us') ? 'group' : 'user'
62+
return { id, name: String(c?.name || c?.contactName || id), type }
63+
})
64+
.filter((c) => c.id.length > 0)
65+
}
66+
67+
export const sendMessage = async (creds: GreenApiCreds, chatId: string, message: string): Promise<string> => {
68+
const data = await call(creds, 'sendMessage', { chatId, message })
69+
return data.idMessage
70+
}
71+
72+
// https://green-api.com/en/docs/api/sending/SendPoll/ — WhatsApp polls allow up to 12 options.
73+
// Unlike interactive buttons, poll votes DO produce incoming (pollUpdateMessage) webhooks.
74+
export const sendPoll = async (
75+
creds: GreenApiCreds,
76+
chatId: string,
77+
question: string,
78+
options: string[],
79+
multipleAnswers = true
80+
): Promise<string> => {
81+
const data = await call(creds, 'sendPoll', {
82+
chatId,
83+
message: question,
84+
options: options.slice(0, 12).map((name) => ({ optionName: name })),
85+
multipleAnswers,
86+
})
87+
return data.idMessage
88+
}
89+
90+
// Configure the instance to call our webhook for incoming messages (poll votes included). GreenAPI
91+
// sends webhookUrlToken back as the "Authorization: Bearer <token>" header. NB: changing settings
92+
// reboots the instance (it can be unavailable for a few minutes afterwards).
93+
export const setSettings = async (
94+
creds: GreenApiCreds,
95+
settings: { webhookUrl: string; webhookUrlToken: string }
96+
): Promise<void> => {
97+
await call(creds, 'setSettings', {
98+
webhookUrl: settings.webhookUrl,
99+
webhookUrlToken: settings.webhookUrlToken,
100+
incomingWebhook: 'yes',
101+
pollMessageWebhook: 'yes',
102+
})
103+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { describe, expect, test, vi } from 'vitest'
2+
import { applyPollVotes, sendGoMessage, startTrackSession, WhatsappSenders } from './trackFlow'
3+
4+
const makeSenders = () => {
5+
let n = 0
6+
const sendPoll = vi.fn(async () => `poll-${++n}`)
7+
const sendMessage = vi.fn(async () => `txt-${++n}`)
8+
const senders: WhatsappSenders = { sendPoll, sendMessage }
9+
return { senders, sendPoll, sendMessage }
10+
}
11+
12+
const tracks = (n: number) => Array.from({ length: n }, (_, i) => ({ id: `t${i + 1}`, name: `Track ${i + 1}` }))
13+
14+
describe('startTrackSession', () => {
15+
test('sends one poll for up to 12 tracks', async () => {
16+
const { senders, sendPoll } = makeSenders()
17+
const session = await startTrackSession(tracks(5), '123@c.us', senders)
18+
19+
expect(sendPoll).toHaveBeenCalledTimes(1)
20+
expect(sendPoll.mock.calls[0][2]).toEqual(['Track 1', 'Track 2', 'Track 3', 'Track 4', 'Track 5'])
21+
expect(session.pollMessageIds).toEqual(['poll-1'])
22+
expect(session.tracks.every((t) => !t.ready)).toBe(true)
23+
expect(session.goSent).toBe(false)
24+
})
25+
26+
test('splits into multiple polls beyond 12 options', async () => {
27+
const { senders, sendPoll } = makeSenders()
28+
const session = await startTrackSession(tracks(13), 'c@c.us', senders)
29+
expect(sendPoll).toHaveBeenCalledTimes(2) // 13 -> [12, 1]
30+
expect(session.pollMessageIds).toHaveLength(2)
31+
})
32+
})
33+
34+
describe('applyPollVotes', () => {
35+
test('marks voted tracks ready (matched by option name)', async () => {
36+
const { senders } = makeSenders()
37+
const session = await startTrackSession(tracks(3), 'c@c.us', senders)
38+
39+
applyPollVotes(session, [
40+
{ name: 'Track 1', ready: true },
41+
{ name: 'Track 2', ready: false },
42+
{ name: 'Track 3', ready: false },
43+
])
44+
45+
expect(session.tracks.find((t) => t.id === 't1')!.ready).toBe(true)
46+
expect(session.tracks.find((t) => t.id === 't2')!.ready).toBe(false)
47+
})
48+
49+
test('cancelling a vote un-readies the track and never sends GO on its own', async () => {
50+
const { senders, sendMessage } = makeSenders()
51+
const session = await startTrackSession(tracks(2), 'c@c.us', senders)
52+
53+
applyPollVotes(session, [
54+
{ name: 'Track 1', ready: true },
55+
{ name: 'Track 2', ready: false },
56+
])
57+
expect(session.tracks.find((t) => t.id === 't1')!.ready).toBe(true)
58+
59+
// The voter removes their vote: the same poll now reports Track 1 with no voters.
60+
applyPollVotes(session, [
61+
{ name: 'Track 1', ready: false },
62+
{ name: 'Track 2', ready: false },
63+
])
64+
expect(session.tracks.find((t) => t.id === 't1')!.ready).toBe(false)
65+
expect(sendMessage).not.toHaveBeenCalled()
66+
expect(session.goSent).toBe(false)
67+
})
68+
69+
test('only updates tracks that are options in this poll (multi-poll split)', async () => {
70+
const { senders } = makeSenders()
71+
const session = await startTrackSession(tracks(2), 'c@c.us', senders)
72+
73+
// Each track voted ready via its own poll message.
74+
applyPollVotes(session, [{ name: 'Track 1', ready: true }])
75+
applyPollVotes(session, [{ name: 'Track 2', ready: true }])
76+
expect(session.tracks.every((t) => t.ready)).toBe(true)
77+
78+
// An update for the first poll must not touch Track 2 (a different poll message).
79+
applyPollVotes(session, [{ name: 'Track 1', ready: false }])
80+
expect(session.tracks.find((t) => t.id === 't1')!.ready).toBe(false)
81+
expect(session.tracks.find((t) => t.id === 't2')!.ready).toBe(true)
82+
})
83+
})
84+
85+
describe('sendGoMessage', () => {
86+
test('sends the GO message and flags the session as sent', async () => {
87+
const { senders, sendMessage } = makeSenders()
88+
const session = await startTrackSession(tracks(2), 'c@c.us', senders)
89+
applyPollVotes(session, [
90+
{ name: 'Track 1', ready: true },
91+
{ name: 'Track 2', ready: true },
92+
])
93+
94+
const updated = await sendGoMessage(session, senders)
95+
96+
expect(sendMessage).toHaveBeenCalledTimes(1)
97+
expect(sendMessage).toHaveBeenCalledWith('c@c.us', expect.stringContaining('GO'))
98+
expect(updated.goSent).toBe(true)
99+
})
100+
})
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { chunk, goMessage, OPTIONS_PER_POLL, POLL_QUESTION, TrackSession, TrackState } from './trackSession'
2+
3+
// Side effects are injected so the flow is unit-testable without Firebase or the network.
4+
export type WhatsappSenders = {
5+
sendPoll: (chatId: string, question: string, options: string[]) => Promise<string>
6+
sendMessage: (chatId: string, message: string) => Promise<string>
7+
}
8+
9+
// Start a track-management session: send one poll per group of up to 12 tracks.
10+
export const startTrackSession = async (
11+
tracks: { id: string; name: string }[],
12+
chatId: string,
13+
senders: WhatsappSenders
14+
): Promise<TrackSession> => {
15+
const trackStates: TrackState[] = tracks.map((t) => ({ id: t.id, name: t.name, ready: false }))
16+
const pollMessageIds: string[] = []
17+
18+
for (const group of chunk(trackStates, OPTIONS_PER_POLL)) {
19+
const idMessage = await senders.sendPoll(
20+
chatId,
21+
POLL_QUESTION,
22+
group.map((t) => t.name)
23+
)
24+
pollMessageIds.push(idMessage)
25+
}
26+
27+
return { chatId, tracks: trackStates, pollMessageIds, goSent: false, panelsSent: [] }
28+
}
29+
30+
// Apply a poll vote snapshot for one poll message. GreenAPI sends the full current vote state, so each
31+
// track that is an option in this poll is set ready iff it currently has at least one voter — meaning
32+
// cancelling a vote un-readies the track. Tracks that aren't options in this poll (when >12 tracks were
33+
// split across several poll messages) are left untouched. GO is never sent automatically — see
34+
// sendGoMessage, triggered manually from the admin UI.
35+
export const applyPollVotes = (
36+
session: TrackSession,
37+
optionStates: { name: string; ready: boolean }[]
38+
): TrackSession => {
39+
const readyByName = new Map(optionStates.map((o) => [o.name, o.ready]))
40+
41+
for (const track of session.tracks) {
42+
const ready = readyByName.get(track.name)
43+
if (ready !== undefined) {
44+
track.ready = ready
45+
}
46+
}
47+
48+
return session
49+
}
50+
51+
// Manually broadcast the GO message. Caller is responsible for checking allReady(session.tracks)
52+
// and !session.goSent first (the admin route does, guarding against early/duplicate sends).
53+
export const sendGoMessage = async (session: TrackSession, senders: WhatsappSenders): Promise<TrackSession> => {
54+
await senders.sendMessage(session.chatId, goMessage(session.tracks))
55+
session.goSent = true
56+
return session
57+
}

0 commit comments

Comments
 (0)