-
Notifications
You must be signed in to change notification settings - Fork 0
Push notification support #126
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
Open
mattheworiordan
wants to merge
20
commits into
main
Choose a base branch
from
push-notification-support
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
20 commits
Select commit
Hold shift + click to select a range
d788a00
feat(push): Add device registration commands for push notifications
mattheworiordan 638b905
feat(push): Add channel subscription commands for push notifications
mattheworiordan 19bdfba
feat(push): add publish and batch-publish commands
mattheworiordan c9e69ff
feat(push): add APNs configuration commands for push notifications
mattheworiordan e65949f
feat(push): add FCM configuration commands
mattheworiordan 03d70cb
Docs update + push fixtures
mattheworiordan f363506
fix(push): remove duplicate confirm() and fix JSON error exit codes
mattheworiordan b631503
test(push): add unit tests for push devices commands
mattheworiordan b272332
test(push): add unit tests for push channels commands
mattheworiordan af4f4e0
test(push): add unit tests for push publish commands
mattheworiordan 7b45f22
test(push): add unit tests for push config commands
mattheworiordan a565487
feat(push): update push config to use new Control API field names
mattheworiordan 5d412fa
fix(push): address PR #126 review feedback
mattheworiordan 25d77f7
feat(push): add --use-sandbox flag for APNs configuration
mattheworiordan 6ced92f
Update README to remove redundant apns apps command
mattheworiordan 0c44a3e
fix(push): address PR #126 review feedback for JSON mode and validation
mattheworiordan f08faff
fix(push): fix JSON output corruption and validate --payload flag con…
mattheworiordan 273ec14
fix(push): rename --client-id to --recipient-client-id and fix legacy…
mattheworiordan c3e5099
docs: add pattern discovery guidance to CLAUDE.md
mattheworiordan 73ce711
fix(push): address PR #126 review feedback for patterns and conventions
mattheworiordan File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file was deleted.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,242 @@ | ||
| import { Flags } from "@oclif/core"; | ||
| import { AblyBaseCommand } from "../../base-command.js"; | ||
| import chalk from "chalk"; | ||
| import * as fs from "node:fs"; | ||
| import * as path from "node:path"; | ||
|
|
||
| interface BatchItem { | ||
| recipient: { | ||
| deviceId?: string; | ||
| clientId?: string; | ||
| }; | ||
| payload: Record<string, unknown>; | ||
| } | ||
|
|
||
| export default class PushBatchPublish extends AblyBaseCommand { | ||
| static override description = | ||
| "Publish push notifications to multiple recipients in a single request (up to 10,000 notifications)"; | ||
|
|
||
| static override examples = [ | ||
| // From JSON file | ||
| "$ ably push batch-publish --payload ./batch-notifications.json", | ||
| // From inline JSON | ||
| '$ ably push batch-publish --payload \'[{"recipient":{"deviceId":"abc"},"payload":{"notification":{"title":"Hi"}}}]\'', | ||
| // From stdin | ||
| "$ cat batch.json | ably push batch-publish --payload -", | ||
| ]; | ||
|
|
||
| static override flags = { | ||
| ...AblyBaseCommand.globalFlags, | ||
| payload: Flags.string({ | ||
| description: | ||
| "Batch payload as JSON array, file path, or - for stdin (required)", | ||
| required: true, | ||
| }), | ||
| }; | ||
|
|
||
| async run(): Promise<void> { | ||
| const { flags } = await this.parse(PushBatchPublish); | ||
|
|
||
| try { | ||
| const rest = await this.createAblyRestClient(flags); | ||
| if (!rest) { | ||
| return; | ||
| } | ||
|
|
||
| // Parse batch payload | ||
| const batchItems = await this.parseBatchPayload(flags.payload); | ||
|
|
||
| // Validate batch items | ||
| this.validateBatchItems(batchItems); | ||
|
|
||
| if (batchItems.length > 10000) { | ||
| this.error("Batch size exceeds maximum of 10,000 notifications"); | ||
| } | ||
|
|
||
| if (batchItems.length === 0) { | ||
| this.error("Batch payload is empty"); | ||
| } | ||
|
|
||
| // Publish batch using REST API directly | ||
| // The SDK's push.admin.publish doesn't have a batch method, | ||
| // so we use the REST API directly | ||
| const response = await rest.request( | ||
| "POST", | ||
| "/push/batch/publish", | ||
| 4, // API version | ||
| {}, | ||
| batchItems, | ||
| {}, | ||
| ); | ||
|
|
||
| // Process response | ||
| const results = response.items || []; | ||
| const successful = results.filter( | ||
| (r: Record<string, unknown>) => !r.error, | ||
| ).length; | ||
| const failed = results.filter( | ||
| (r: Record<string, unknown>) => r.error, | ||
| ).length; | ||
|
|
||
| if (this.shouldOutputJson(flags)) { | ||
| this.log( | ||
| this.formatJsonOutput( | ||
| { | ||
| total: batchItems.length, | ||
| successful, | ||
| failed, | ||
| results: results.map((r: Record<string, unknown>, i: number) => ({ | ||
| index: i, | ||
| success: !r.error, | ||
| error: r.error, | ||
| })), | ||
| success: failed === 0, | ||
| timestamp: new Date().toISOString(), | ||
| }, | ||
| flags, | ||
| ), | ||
| ); | ||
| } else { | ||
| this.log(chalk.bold("Batch Push Results\n")); | ||
| this.log(`${chalk.dim("Total:")} ${batchItems.length}`); | ||
| this.log( | ||
| `${chalk.dim("Successful:")} ${chalk.green(successful.toString())}`, | ||
| ); | ||
|
|
||
| if (failed > 0) { | ||
| this.log( | ||
| `${chalk.dim("Failed:")} ${chalk.red(failed.toString())}`, | ||
| ); | ||
| this.log(""); | ||
| this.log(chalk.yellow("Failed Notifications:")); | ||
|
|
||
| results.forEach((r: Record<string, unknown>, i: number) => { | ||
| if (r.error) { | ||
| const error = r.error as Record<string, unknown>; | ||
| const recipient = batchItems[i]?.recipient; | ||
| const recipientStr = recipient?.deviceId | ||
| ? `deviceId=${recipient.deviceId}` | ||
| : recipient?.clientId | ||
| ? `clientId=${recipient.clientId}` | ||
| : "unknown"; | ||
|
|
||
| this.log(` - Recipient: ${recipientStr}`); | ||
| this.log( | ||
| ` Error: ${error.message || "Unknown error"} (code: ${error.code || "unknown"})`, | ||
| ); | ||
| } | ||
| }); | ||
| } else { | ||
| this.log(""); | ||
| this.log(chalk.green("All notifications sent successfully!")); | ||
| } | ||
| } | ||
| } catch (error) { | ||
| const errorMessage = | ||
| error instanceof Error ? error.message : String(error); | ||
| const errorCode = (error as { code?: number }).code; | ||
|
|
||
| if (this.shouldOutputJson(flags)) { | ||
| this.jsonError( | ||
| { | ||
| error: errorMessage, | ||
| code: errorCode, | ||
| success: false, | ||
| }, | ||
| flags, | ||
| ); | ||
| } else { | ||
| this.error(`Error publishing batch notifications: ${errorMessage}`); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private async parseBatchPayload(payload: string): Promise<BatchItem[]> { | ||
| let jsonString: string; | ||
|
|
||
| if (payload === "-") { | ||
| // Read from stdin | ||
| jsonString = await this.readStdin(); | ||
| } else if ( | ||
| !payload.trim().startsWith("[") && | ||
| !payload.trim().startsWith("{") | ||
| ) { | ||
| // It's a file path | ||
| const filePath = path.resolve(payload); | ||
|
|
||
| if (!fs.existsSync(filePath)) { | ||
| throw new Error(`File not found: ${filePath}`); | ||
| } | ||
|
|
||
| jsonString = fs.readFileSync(filePath, "utf8"); | ||
| } else { | ||
| jsonString = payload; | ||
| } | ||
|
|
||
| try { | ||
| const parsed = JSON.parse(jsonString); | ||
|
|
||
| // Ensure it's an array | ||
| if (!Array.isArray(parsed)) { | ||
| throw new TypeError("Batch payload must be a JSON array"); | ||
| } | ||
|
|
||
| return parsed as BatchItem[]; | ||
| } catch (error) { | ||
| if (error instanceof SyntaxError) { | ||
| throw new TypeError("Invalid JSON format in batch payload"); | ||
| } | ||
|
|
||
| throw error; | ||
| } | ||
| } | ||
|
|
||
| private async readStdin(): Promise<string> { | ||
| return new Promise((resolve, reject) => { | ||
| let data = ""; | ||
|
|
||
| process.stdin.setEncoding("utf8"); | ||
|
|
||
| process.stdin.on("data", (chunk) => { | ||
| data += chunk; | ||
| }); | ||
|
|
||
| process.stdin.on("end", () => { | ||
| resolve(data); | ||
| }); | ||
|
|
||
| process.stdin.on("error", (err) => { | ||
| reject(err); | ||
| }); | ||
|
|
||
| // Set a timeout for stdin reading | ||
| setTimeout(() => { | ||
| if (data === "") { | ||
| reject(new Error("No data received from stdin")); | ||
| } | ||
| }, 5000); | ||
| }); | ||
| } | ||
|
|
||
| private validateBatchItems(items: BatchItem[]): void { | ||
| items.forEach((item, index) => { | ||
| if (!item) { | ||
| throw new Error(`Item ${index}: invalid entry (null or undefined)`); | ||
| } | ||
|
|
||
| if (!item.recipient) { | ||
| throw new Error(`Item ${index}: missing 'recipient' field`); | ||
| } | ||
|
|
||
| if (!item.recipient.deviceId && !item.recipient.clientId) { | ||
| throw new Error( | ||
| `Item ${index}: recipient must have either 'deviceId' or 'clientId'`, | ||
| ); | ||
| } | ||
|
|
||
| if (!item.payload) { | ||
| throw new Error(`Item ${index}: missing 'payload' field`); | ||
| } | ||
| }); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| import { BaseTopicCommand } from "../../../base-topic-command.js"; | ||
|
|
||
| export default class PushChannels extends BaseTopicCommand { | ||
| protected topicName = "push:channels"; | ||
| protected commandGroup = "push channel subscription"; | ||
|
|
||
| static override description = | ||
| "Manage push notification channel subscriptions (maps to push.admin.channelSubscriptions)"; | ||
|
|
||
| static override examples = [ | ||
| "<%= config.bin %> <%= command.id %> save --channel alerts --device-id my-device", | ||
| "<%= config.bin %> <%= command.id %> list --channel alerts", | ||
| "<%= config.bin %> <%= command.id %> list-channels", | ||
| "<%= config.bin %> <%= command.id %> remove --channel alerts --device-id my-device", | ||
| ]; | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.