-
Notifications
You must be signed in to change notification settings - Fork 0
feat: text blast API endpoint — no more browser needed #37
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,123 @@ | ||
| # Partiful Text Blast API — Endpoint Research | ||
|
|
||
| **Date:** 2026-03-24 | ||
| **Method:** Browser interception on partiful.com (logged in as Kaleb Cole) | ||
| **Source file:** `6631.793447c2446d40ae.js` → `SendTextBlastModal.tsx` | ||
|
|
||
| --- | ||
|
|
||
| ## Endpoint | ||
|
|
||
| ``` | ||
| POST https://api.partiful.com/createTextBlast | ||
| ``` | ||
|
|
||
| Uses the same Firebase callable function pattern as all other Partiful API endpoints: | ||
| - Auth via Firebase access token in `Authorization: Bearer <token>` header | ||
| - Request body wrapped in standard `data.params` envelope | ||
| - Includes `amplitudeDeviceId`, `amplitudeSessionId`, `userId` metadata | ||
|
|
||
| ## Request Payload | ||
|
|
||
| ```json | ||
| { | ||
| "data": { | ||
| "params": { | ||
| "eventId": "<event-id>", | ||
| "message": { | ||
| "text": "<message-text>", | ||
| "to": ["GOING", "MAYBE", "DECLINED"], | ||
| "showOnEventPage": true, | ||
| "images": [ | ||
| { | ||
| "url": "<uploaded-image-url>", | ||
| "upload": { | ||
| "contentType": "image/jpeg", | ||
| "size": 123456 | ||
| } | ||
| } | ||
| ] | ||
| } | ||
| }, | ||
| "amplitudeDeviceId": "<device-id>", | ||
| "amplitudeSessionId": <timestamp>, | ||
| "userId": "<firebase-uid>" | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| ### Field Details | ||
|
|
||
| | Field | Type | Required | Notes | | ||
| |---|---|---|---| | ||
| | `eventId` | string | Yes | Partiful event ID | | ||
| | `message.text` | string | Yes | Message body, max 480 chars | | ||
| | `message.to` | string[] | Yes | Array of guest statuses to send to | | ||
| | `message.showOnEventPage` | boolean | Yes | Whether to show in activity feed | | ||
| | `message.images` | array | No | Uploaded image objects (max 1 image, max 5MB total) | | ||
|
|
||
| ### Valid `to` Values (Guest Status Enum — `LF`) | ||
|
|
||
| From module `73621` in the app bundle: | ||
|
|
||
| | Value | UI Label | Typical Use in Blasts | | ||
| |---|---|---| | ||
| | `GOING` | Going | ✅ Primary target | | ||
| | `MAYBE` | Maybe | ✅ Common target | | ||
| | `DECLINED` | Can't Go | ✅ Available | | ||
| | `SENT` | Invited | ⚠️ Only for small groups | | ||
| | `INTERESTED` | Interested | Available | | ||
| | `WAITLIST` | Waitlist | Available (if enabled) | | ||
| | `APPROVED` | Approved | Available (ticketed events) | | ||
| | `RESPONDED_TO_FIND_A_TIME` | Find-a-Time | Available | | ||
|
|
||
| **Note:** The UI shows "Texts to large groups of Invited guests are not allowed" — there's a server-side limit on blasting to `SENT` status guests. | ||
|
|
||
| ### Limits | ||
|
|
||
| - **Max 10 text blasts per event** (enforced client-side as `f=10`) | ||
| - **Max 480 characters** per message | ||
| - **Max 5MB** total image upload size | ||
| - **Max 1 image** per blast | ||
| - **Allowed image types:** Checked via `ee.V2` array (likely standard image MIME types) | ||
| - **Event must not be expired** (`EVENT_EXPIRED` check) | ||
| - **Must have guests** (`NO_GUESTS` check) | ||
|
|
||
| ## UI → API Mapping | ||
|
|
||
| | UI Element | API Field | | ||
| |---|---| | ||
| | "Going (9)" pill (selected) | `to: ["GOING"]` | | ||
| | "Maybe (1)" pill (selected) | `to` includes `"MAYBE"` | | ||
| | "Can't Go (1)" pill (selected) | `to` includes `"DECLINED"` | | ||
| | "Select all (11)" | `to: ["GOING", "MAYBE", "DECLINED"]` (all available) | | ||
| | "Also show in activity feed" checkbox | `showOnEventPage: true/false` | | ||
| | Message textarea | `message.text` | | ||
| | Image upload | `message.images` array | | ||
|
|
||
| ## Other Discovered Endpoints (Same Session) | ||
|
|
||
| | Endpoint | Method | Purpose | | ||
| |---|---|---| | ||
| | `getContacts` | POST | List user's contacts (paginated, 1000/page) | | ||
| | `getHostTicketTypes` | POST | Get ticket types for an event | | ||
| | `getEventDiscoverStatus` | POST | Check if event is discoverable | | ||
| | `getEventTicketingEligibility` | POST | Check ticketing eligibility | | ||
| | `getEventPermission` | POST | Check user's permissions on event | | ||
| | `getUsers` | POST | Batch lookup users by ID (batches of ~5-10) | | ||
|
|
||
| ## Previously Discovered Endpoints (Earlier Sessions) | ||
|
|
||
| | Endpoint | Method | Purpose | | ||
| |---|---|---| | ||
| | `addGuest` | POST | RSVP / add guest to event | | ||
| | `removeGuest` | POST | Remove guest from event | | ||
| | `getMutualsV2` | POST | Get mutual connections | | ||
| | `getInvitableContactsV2` | POST | Get invitable contacts for event | | ||
| | `getGuestsCsvV2` | POST | Server-side CSV export of guest list | | ||
|
|
||
| ## Auth Token for CLI | ||
|
|
||
| The CLI already has auth working via Firebase refresh token → access token exchange. | ||
| The `createTextBlast` endpoint uses the same auth pattern — just needs the access token | ||
| in the standard callable function request envelope. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,23 +1,118 @@ | ||
| /** | ||
| * Blasts commands: send (stub) | ||
| * Blasts commands: send, list | ||
| * | ||
| * Endpoint: POST https://api.partiful.com/createTextBlast | ||
| * Discovered via browser interception 2026-03-24. | ||
| * See docs/research/2026-03-24-text-blast-endpoint.md | ||
| */ | ||
|
|
||
| import { jsonError } from '../lib/output.js'; | ||
| import { loadConfig, getValidToken, wrapPayload } from '../lib/auth.js'; | ||
| import { apiRequest } from '../lib/http.js'; | ||
| import { jsonOutput, jsonError } from '../lib/output.js'; | ||
| import { PartifulError, ValidationError } from '../lib/errors.js'; | ||
| import readline from 'readline'; | ||
|
|
||
| const VALID_TO_VALUES = ['GOING', 'MAYBE', 'DECLINED', 'SENT', 'INTERESTED', 'WAITLIST', 'APPROVED', 'RESPONDED_TO_FIND_A_TIME']; | ||
| const MAX_MESSAGE_LENGTH = 480; | ||
| const MAX_BLASTS_PER_EVENT = 10; | ||
|
|
||
| async function confirm(question) { | ||
| const rl = readline.createInterface({ input: process.stdin, output: process.stderr }); | ||
| return new Promise(resolve => { | ||
| rl.question(question + ' [y/N]: ', answer => { | ||
| rl.close(); | ||
| resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes'); | ||
| }); | ||
| }); | ||
| } | ||
|
|
||
| export function registerBlastsCommands(program) { | ||
| const blasts = program.command('blasts').description('Text blasts to event guests'); | ||
|
|
||
| blasts | ||
| .command('send') | ||
| .description('Send a text blast to event guests (requires browser — stub)') | ||
| .description('Send a text blast to event guests') | ||
| .argument('<eventId>', 'Event ID') | ||
| .option('--message <msg>', 'Message to send') | ||
| .action(async (eventId) => { | ||
| jsonError( | ||
| `Text blasts require Firestore SDK (not available via REST). Use the web UI: https://partiful.com/e/${eventId}`, | ||
| 5, | ||
| 'not_implemented', | ||
| { workaround: `https://partiful.com/e/${eventId}` } | ||
| ); | ||
| .requiredOption('--message <msg>', 'Message to send (max 480 chars)') | ||
| .option('--to <statuses>', 'Comma-separated guest statuses to send to (default: GOING)', 'GOING') | ||
| .option('--show-on-event-page', 'Show blast in event activity feed (default: true)') | ||
| .option('--no-show-on-event-page', 'Hide blast from event activity feed') | ||
| .action(async (eventId, opts, cmd) => { | ||
| const globalOpts = cmd.optsWithGlobals(); | ||
|
|
||
| try { | ||
| // Validate message length | ||
| if (opts.message.length > MAX_MESSAGE_LENGTH) { | ||
| throw new ValidationError(`Message exceeds ${MAX_MESSAGE_LENGTH} char limit (got ${opts.message.length})`); | ||
| } | ||
|
|
||
| // Parse and validate 'to' statuses | ||
| const toStatuses = opts.to.split(',').map(s => s.trim().toUpperCase()); | ||
| for (const status of toStatuses) { | ||
| if (!VALID_TO_VALUES.includes(status)) { | ||
| throw new ValidationError( | ||
| `Invalid status "${status}". Valid: ${VALID_TO_VALUES.join(', ')}` | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| // Default showOnEventPage to true unless explicitly disabled | ||
| const showOnEventPage = opts.showOnEventPage !== false; | ||
|
|
||
| const config = loadConfig(); | ||
| const token = await getValidToken(config); | ||
|
|
||
| const payload = { | ||
| data: wrapPayload(config, { | ||
| params: { | ||
| eventId, | ||
| message: { | ||
| text: opts.message, | ||
| to: toStatuses, | ||
| showOnEventPage, | ||
| }, | ||
| }, | ||
| amplitudeSessionId: Date.now(), | ||
| userId: config.userId || null, | ||
| }), | ||
| }; | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| if (globalOpts.dryRun) { | ||
| jsonOutput({ dryRun: true, endpoint: '/createTextBlast', payload }, {}, globalOpts); | ||
| return; | ||
| } | ||
|
Comment on lines
+80
to
+83
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Missing The Proposed fix if (globalOpts.dryRun) {
- jsonOutput({ dryRun: true, endpoint: '/createTextBlast', payload });
+ jsonOutput({ dryRun: true, endpoint: '/createTextBlast', payload }, {}, globalOpts);
return;
} if (!ok) {
- jsonOutput({ cancelled: true, message: 'Blast not sent' });
+ jsonOutput({ cancelled: true, message: 'Blast not sent' }, {}, globalOpts);
return;
}- jsonOutput({
+ jsonOutput({
sent: true,
eventId,
to: toStatuses,
messageLength: opts.message.length,
showOnEventPage,
response: result?.result?.data || result?.result || result,
- });
+ }, {}, globalOpts);Also applies to: 95-96, 102-109 🤖 Prompt for AI Agents |
||
|
|
||
| // Safety confirmation unless --yes | ||
| if (!globalOpts.yes) { | ||
| console.error(`\nText Blast Preview:`); | ||
| console.error(` Event: ${eventId}`); | ||
| console.error(` To: ${toStatuses.join(', ')}`); | ||
| console.error(` Show on event page: ${showOnEventPage}`); | ||
| console.error(` Message: "${opts.message}"`); | ||
| console.error(''); | ||
| const ok = await confirm('Send this text blast? This will SMS real people'); | ||
| if (!ok) { | ||
| jsonOutput({ cancelled: true, message: 'Blast not sent' }, {}, globalOpts); | ||
| return; | ||
| } | ||
| } | ||
|
|
||
| const result = await apiRequest('POST', '/createTextBlast', token, payload, globalOpts.verbose); | ||
|
|
||
| jsonOutput({ | ||
| sent: true, | ||
| eventId, | ||
| to: toStatuses, | ||
| messageLength: opts.message.length, | ||
| showOnEventPage, | ||
| response: result?.result?.data || result?.result || result, | ||
| }, {}, globalOpts); | ||
| } catch (err) { | ||
| if (err instanceof PartifulError) { | ||
| jsonError(err.message, err.exitCode, err.type, err.details); | ||
| } else { | ||
| jsonError(err.message, 1, 'blast_error'); | ||
| } | ||
| } | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| }); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Guests special case doesn't skip global options, inconsistent with alias handling above.
The alias rewriting at lines 57-64 correctly scans past global options to find the command token. However, this special case checks
args[0]directly. If a user runspartiful --format json guests abc123, the deprecation rewrite won't trigger becauseargs[0]is'--format', not'guests'.🔧 Proposed fix to use cmdIndex for consistency
🤖 Prompt for AI Agents