diff --git a/README.md b/README.md
index 1229fb65..8d249c91 100644
--- a/README.md
+++ b/README.md
@@ -319,6 +319,22 @@ order info editing, messaging, member add, join approval. If one fails, the
error names the settings that were already applied. Removing participants
requires `--yes`.
+### Stored group capabilities
+
+Use `polymorfa group capabilities GROUP_ID --session SESSION` to read the
+linked number's stored group configuration. Add `--json` for the complete
+response. This read requires `groups:read`, current group membership, and
+number-capabilities beta enrollment. Unknown configuration stays `unknown`;
+the command does not open the group chat or fetch its configuration.
+
+The keys `polls.endTime`, `polls.hideVoters`, and `polls.creatorEdit` describe
+WhatsApp configuration. They do not grant poll-editing permissions. The API
+must include the group-capabilities contract.
+
+The capability extension is pinned in `contracts/abprops-capabilities.json` to
+API source `205369ff8f5ecb71dd085a190d8ec80aa2b9e483`. This is the same scoped revision consumed by the SDK;
+other command contracts and the published SDK dependency keep their recorded revisions.
+
## Messages
Send text and perform the complete set of source-defined Messaging actions:
diff --git a/contracts/abprops-capabilities.json b/contracts/abprops-capabilities.json
new file mode 100644
index 00000000..c3055b60
--- /dev/null
+++ b/contracts/abprops-capabilities.json
@@ -0,0 +1,605 @@
+{
+ "repository": "polymorfa/polymorfa",
+ "sourceCommit": "2f9efad933b3a7a455f73245a566c827ac004608",
+ "sourcePath": "apps/api/docs/openapi.json",
+ "sourceSha256": "53b2c76e344052152b3fc627a478adcd79130b7876d528aac8056b13e555b127",
+ "published": false,
+ "scope": "Group capability read and canonical session capability webhook only. Other SDK contracts retain their recorded source revisions.",
+ "openapi": "3.0.3",
+ "paths": {
+ "/messaging/{session}/groups/{id}/capabilities": {
+ "get": {
+ "operationId": "getGroupCapabilities",
+ "tags": ["Groups"],
+ "summary": "Read stored WhatsApp group capabilities",
+ "security": [
+ {
+ "BearerAuth": []
+ }
+ ],
+ "x-required-scope": "groups:read",
+ "parameters": [
+ {
+ "schema": {
+ "type": "string"
+ },
+ "required": true,
+ "name": "session",
+ "in": "path"
+ },
+ {
+ "schema": {
+ "type": "string",
+ "pattern": "^[1-9][0-9]{0,18}$"
+ },
+ "required": true,
+ "name": "id",
+ "in": "path"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Account-bound stored group capabilities. Unknown until successful number and group syncs for the linked account. Requires current group membership and the enrolled number-capabilities beta.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/GetGroupCapabilitiesResponse"
+ }
+ }
+ },
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/X-RateLimit-Limit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/X-RateLimit-Remaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/X-RateLimit-Reset"
+ }
+ }
+ },
+ "400": {
+ "description": "Bad request \u2014 request body or params failed validation",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicError"
+ },
+ "example": {
+ "error": {
+ "type": "invalid_request_error",
+ "code": "invalid_parameter",
+ "message": "Correct the invalid request field.",
+ "param": null,
+ "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30"
+ },
+ "data": null,
+ "docs": "https://docs.polymorfa.com/api/errors#invalid-parameter"
+ }
+ }
+ },
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/X-RateLimit-Limit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/X-RateLimit-Remaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/X-RateLimit-Reset"
+ }
+ }
+ },
+ "401": {
+ "description": "Missing or invalid authentication token",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicError"
+ },
+ "example": {
+ "error": {
+ "type": "authentication_error",
+ "code": "invalid_credential",
+ "message": "Supply a valid credential.",
+ "param": null,
+ "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30"
+ },
+ "data": null,
+ "docs": "https://docs.polymorfa.com/api/errors#invalid-credential"
+ }
+ }
+ },
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/X-RateLimit-Limit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/X-RateLimit-Remaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/X-RateLimit-Reset"
+ }
+ }
+ },
+ "403": {
+ "description": "Authenticated but not permitted (missing scope or rule violation)",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicError"
+ },
+ "example": {
+ "error": {
+ "type": "permission_error",
+ "code": "permission_denied",
+ "message": "The credential does not have access to this resource.",
+ "param": null,
+ "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30"
+ },
+ "data": null,
+ "docs": "https://docs.polymorfa.com/api/errors#permission-denied"
+ }
+ }
+ },
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/X-RateLimit-Limit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/X-RateLimit-Remaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/X-RateLimit-Reset"
+ }
+ }
+ },
+ "404": {
+ "description": "Resource not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicError"
+ },
+ "example": {
+ "error": {
+ "type": "invalid_request_error",
+ "code": "resource_not_found",
+ "message": "The resource is unavailable to this caller.",
+ "param": null,
+ "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30"
+ },
+ "data": null,
+ "docs": "https://docs.polymorfa.com/api/errors#resource-not-found"
+ }
+ }
+ },
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/X-RateLimit-Limit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/X-RateLimit-Remaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/X-RateLimit-Reset"
+ }
+ }
+ },
+ "500": {
+ "description": "Internal server error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicError"
+ },
+ "example": {
+ "error": {
+ "type": "api_error",
+ "code": "internal_error",
+ "message": "An unexpected service failure occurred. Keep the request ID for support.",
+ "param": null,
+ "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30"
+ },
+ "data": null,
+ "docs": "https://docs.polymorfa.com/api/errors#internal-error"
+ }
+ }
+ },
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/X-RateLimit-Limit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/X-RateLimit-Remaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/X-RateLimit-Reset"
+ }
+ }
+ },
+ "503": {
+ "description": "The stored capability reader, identity resolution or command dispatch is temporarily unavailable.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicError"
+ },
+ "example": {
+ "error": {
+ "type": "api_error",
+ "code": "service_unavailable",
+ "message": "A required service is unavailable. Check completion before repeating a write.",
+ "param": null,
+ "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30"
+ },
+ "data": null,
+ "docs": "https://docs.polymorfa.com/api/errors#service-unavailable"
+ }
+ }
+ },
+ "headers": {
+ "X-RateLimit-Limit": {
+ "$ref": "#/components/headers/X-RateLimit-Limit"
+ },
+ "X-RateLimit-Remaining": {
+ "$ref": "#/components/headers/X-RateLimit-Remaining"
+ },
+ "X-RateLimit-Reset": {
+ "$ref": "#/components/headers/X-RateLimit-Reset"
+ }
+ }
+ }
+ },
+ "x-mint": {
+ "content": "Linked Device API groups:read"
+ },
+ "x-protocols": ["Linked Device API"],
+ "description": "**Protocols:** Linked Device API\n\n**Required scope:** `groups:read`"
+ }
+ }
+ },
+ "components": {
+ "schemas": {
+ "GetGroupCapabilitiesResponse": {
+ "type": "object",
+ "properties": {
+ "success": {
+ "type": "boolean",
+ "enum": [true]
+ },
+ "data": {
+ "$ref": "#/components/schemas/GroupCapabilities"
+ }
+ },
+ "required": ["success", "data"]
+ },
+ "GroupCapabilities": {
+ "type": "object",
+ "properties": {
+ "status": {
+ "type": "string",
+ "enum": ["synced", "unknown"]
+ },
+ "syncedAt": {
+ "type": "string",
+ "nullable": true,
+ "format": "date-time"
+ },
+ "checkedAt": {
+ "type": "string",
+ "nullable": true,
+ "format": "date-time"
+ },
+ "capabilities": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "key": {
+ "type": "string",
+ "enum": [
+ "polls.endTime",
+ "polls.hideVoters",
+ "polls.creatorEdit"
+ ]
+ },
+ "kind": {
+ "type": "string",
+ "enum": ["feature"]
+ },
+ "unit": {
+ "nullable": true,
+ "type": "string",
+ "enum": [null]
+ },
+ "value": {
+ "type": "boolean",
+ "nullable": true
+ },
+ "source": {
+ "type": "string",
+ "nullable": true,
+ "enum": ["server", "client_default", null]
+ }
+ },
+ "required": ["key", "kind", "unit", "value", "source"],
+ "additionalProperties": false
+ }
+ }
+ },
+ "required": ["status", "syncedAt", "checkedAt", "capabilities"],
+ "additionalProperties": false
+ },
+ "PublicError": {
+ "type": "object",
+ "properties": {
+ "error": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": [
+ "invalid_request_error",
+ "authentication_error",
+ "permission_error",
+ "conflict_error",
+ "rate_limit_error",
+ "api_error",
+ "upstream_error"
+ ]
+ },
+ "code": {
+ "type": "string",
+ "enum": [
+ "invalid_parameter",
+ "invalid_credential",
+ "entitlement_required",
+ "cloud_template_authority_restricted",
+ "gate_limit_reached",
+ "method_not_allowed",
+ "permission_denied",
+ "missing_scope",
+ "resource_not_found",
+ "hms_not_enabled",
+ "media_fetch_failed",
+ "resource_gone",
+ "feature_unavailable",
+ "stream_cursor_invalid",
+ "stream_cursor_expired",
+ "stream_connection_limit_reached",
+ "state_conflict",
+ "hybrid_routing_paused",
+ "hybrid_choice_required",
+ "hybrid_transition_ineligible",
+ "hybrid_automation_unavailable",
+ "hybrid_transport_unavailable",
+ "hybrid_target_reference_unavailable",
+ "send_outcome_unknown",
+ "idempotency_conflict",
+ "idempotency_in_progress",
+ "idempotency_completed",
+ "idempotency_outcome_unknown",
+ "call_claimed",
+ "call_not_ringing",
+ "connection_limit",
+ "unsupported_for_connection",
+ "call_permission_required",
+ "invalid_sip_trunk",
+ "calls_disabled",
+ "call_recipient_opted_out",
+ "call_destination_blocked",
+ "call_permission_request_limited",
+ "call_permission_granted",
+ "order_status_transition_invalid",
+ "order_cancellation_failed",
+ "call_opt_out_limit",
+ "number_restricted",
+ "sip_trunk_in_use",
+ "sip_trunk_revision_conflict",
+ "sip_trunk_limit",
+ "sip_unavailable",
+ "voice_not_enabled",
+ "provider_credential_invalid",
+ "provider_unavailable",
+ "asset_not_ready",
+ "voice_asset_in_use",
+ "voice_asset_revision_conflict",
+ "voice_unavailable",
+ "rate_limit_exceeded",
+ "internal_error",
+ "operation_not_supported",
+ "upstream_failure",
+ "service_unavailable",
+ "credential_verification_unavailable",
+ "session_not_ready",
+ "command_dispatch_failed",
+ "result_unknown",
+ "recipient_not_on_whatsapp",
+ "conversation_window_closed",
+ "template_not_approved",
+ "media_too_large",
+ "whatsapp_rate_limited",
+ "new_chat_limit_reached",
+ "whatsapp_account_restricted",
+ "whatsapp_groups_ineligible",
+ "whatsapp_group_creation_paused",
+ "whatsapp_group_limit_reached",
+ "whatsapp_group_full",
+ "whatsapp_group_suspended",
+ "whatsapp_group_has_no_participants",
+ "bansafe_suspended",
+ "bansafe_org_suspended",
+ "bansafe_cold_blocked",
+ "bansafe_cold_held",
+ "bansafe_throttled",
+ "bansafe_daily_allowance_reached",
+ "bansafe_accounting_unavailable",
+ "bansafe_send_outcome_unknown",
+ "campaigns_not_entitled",
+ "campaign_throughput_capped",
+ "campaign_approval_required",
+ "campaign_approval_stale",
+ "campaign_variables_missing"
+ ]
+ },
+ "message": {
+ "type": "string"
+ },
+ "param": {
+ "type": "string",
+ "nullable": true
+ },
+ "request_id": {
+ "type": "string",
+ "description": "Matches the X-Request-Id response header. Include it when you contact support."
+ },
+ "request_log_url": {
+ "type": "string",
+ "format": "uri",
+ "description": "Console page that shows this request. Returned only to organization and project credentials whose request was logged."
+ }
+ },
+ "required": ["type", "code", "message", "param", "request_id"]
+ },
+ "data": {
+ "type": "string",
+ "nullable": true,
+ "enum": [null],
+ "description": "Always null for a failed request."
+ },
+ "docs": {
+ "type": "string",
+ "format": "uri"
+ }
+ },
+ "required": ["error", "data", "docs"]
+ },
+ "SessionCapabilitiesUpdatedEvent": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "Unique event identifier"
+ },
+ "session": {
+ "type": "string",
+ "description": "Session that produced this event"
+ },
+ "externalId": {
+ "type": "string",
+ "description": "Integrator reference from the QuickLink that created this session. Included on session lifecycle events when supplied."
+ },
+ "timestamp": {
+ "type": "string",
+ "description": "ISO 8601 timestamp"
+ },
+ "event": {
+ "type": "string",
+ "enum": ["session.capabilities_updated"],
+ "description": "Event type"
+ },
+ "payload": {
+ "$ref": "#/components/schemas/SessionCapabilitiesUpdatedPayload"
+ }
+ },
+ "required": ["id", "session", "timestamp", "event", "payload"]
+ },
+ "SessionCapabilitiesUpdatedPayload": {
+ "type": "object",
+ "properties": {
+ "session": {
+ "type": "string",
+ "maxLength": 256
+ },
+ "projectId": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "status": {
+ "type": "string",
+ "enum": ["synced", "unknown"]
+ },
+ "syncedAt": {
+ "type": "string",
+ "nullable": true,
+ "format": "date-time"
+ },
+ "checkedAt": {
+ "type": "string",
+ "nullable": true,
+ "format": "date-time"
+ },
+ "accountType": {
+ "type": "string",
+ "nullable": true,
+ "enum": ["business", "personal", null]
+ },
+ "capabilities": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "key": {
+ "type": "string",
+ "maxLength": 128
+ },
+ "kind": {
+ "type": "string",
+ "enum": ["feature", "limit"]
+ },
+ "unit": {
+ "type": "string",
+ "nullable": true,
+ "enum": ["seconds", "count", "characters", "members", null]
+ },
+ "value": {
+ "anyOf": [
+ {
+ "type": "boolean",
+ "nullable": true
+ },
+ {
+ "type": "integer",
+ "nullable": true
+ }
+ ],
+ "nullable": true
+ },
+ "source": {
+ "type": "string",
+ "nullable": true,
+ "enum": ["server", "client_default", "account_type", null]
+ }
+ },
+ "required": ["key", "kind", "unit", "value", "source"],
+ "additionalProperties": false
+ },
+ "maxItems": 100
+ },
+ "changedKeys": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "maxLength": 128
+ },
+ "maxItems": 100
+ }
+ },
+ "required": [
+ "session",
+ "projectId",
+ "status",
+ "syncedAt",
+ "checkedAt",
+ "accountType",
+ "capabilities",
+ "changedKeys"
+ ],
+ "additionalProperties": false
+ }
+ }
+ },
+ "family": "messaging"
+}
diff --git a/scripts/prepare-release.mjs b/scripts/prepare-release.mjs
index 271320d6..f124c2b2 100644
--- a/scripts/prepare-release.mjs
+++ b/scripts/prepare-release.mjs
@@ -5,7 +5,11 @@ import {createHash} from 'node:crypto'
import {execFileSync} from 'node:child_process'
import {mkdirSync, readFileSync, writeFileSync} from 'node:fs'
import {join, resolve} from 'node:path'
-import {registryEvidence, validateSdk} from './release-evidence.mjs'
+import {
+ registryEvidence,
+ validateSdk,
+ validateFeatureContractPins,
+} from './release-evidence.mjs'
try {
assert.equal(
@@ -24,12 +28,16 @@ try {
'SDK publication with matching API acceptance must finish first',
)
validateSdk(sdk.provenance, sdk.version, channel)
+ validateFeatureContractPins(sdk.provenance, [
+ JSON.parse(readFileSync('contracts/abprops-capabilities.json')),
+ ])
const files = execFileSync('git', [
'ls-files',
'-z',
'src',
'bin',
'scripts',
+ 'contracts',
'skills',
'package.json',
'package-lock.json',
diff --git a/scripts/release-evidence.mjs b/scripts/release-evidence.mjs
index dc5890aa..244daf85 100644
--- a/scripts/release-evidence.mjs
+++ b/scripts/release-evidence.mjs
@@ -57,6 +57,41 @@ export function validateSdk(provenance, version, channel) {
}
return provenance
}
+export function validateFeatureContractPins(provenance, pins) {
+ const receipt = provenance.acceptance
+ for (const pin of pins) {
+ assert.equal(
+ pin.repository,
+ receipt.repository,
+ 'Feature contract repository mismatch',
+ )
+ assert.ok(
+ ['messaging', 'platform'].includes(pin.family),
+ 'Feature contract family is required',
+ )
+ assert.notEqual(
+ pin.published,
+ false,
+ 'Feature contract is held from publication',
+ )
+ assert.equal(
+ pin.sourceCommit,
+ receipt.sourceSha,
+ 'Feature contract source is not accepted',
+ )
+ assert.equal(
+ pin.sourcePath,
+ receipt.contracts[pin.family]?.sourcePath,
+ 'Feature contract source path mismatch',
+ )
+ assert.equal(
+ pin.sourceSha256,
+ receipt.contracts[pin.family]?.sha256,
+ 'Feature contract hash mismatch',
+ )
+ }
+}
+
export async function registryEvidence(name, tag, output) {
assert.ok(['@polymorfa/sdk', '@polymorfa/cli'].includes(name))
const response = await fetch(
diff --git a/scripts/release-evidence.test.mjs b/scripts/release-evidence.test.mjs
index 12b863ce..251fdee7 100644
--- a/scripts/release-evidence.test.mjs
+++ b/scripts/release-evidence.test.mjs
@@ -1,6 +1,6 @@
import {test} from 'node:test'
import assert from 'node:assert/strict'
-import {validateSdk} from './release-evidence.mjs'
+import {validateSdk, validateFeatureContractPins} from './release-evidence.mjs'
const sdk = {
schemaVersion: 1,
repository: 'polymorfa/sdks',
@@ -61,3 +61,24 @@ test('an installed SDK or healthy API without native route acceptance is insuffi
),
)
})
+
+test('capability commands cannot publish against unrelated SDK acceptance', () => {
+ const pin = {
+ repository: sdk.acceptance.repository,
+ family: 'messaging',
+ published: true,
+ sourceCommit: sdk.acceptance.sourceSha,
+ sourcePath: sdk.acceptance.contracts.messaging.sourcePath,
+ sourceSha256: sdk.acceptance.contracts.messaging.sha256,
+ }
+ validateFeatureContractPins(sdk, [pin])
+ for (const delta of [
+ {family: 'platform'},
+ {family: undefined},
+ {published: false},
+ {sourceCommit: 'f'.repeat(40)},
+ {sourceSha256: sdk.acceptance.contracts.platform.sha256},
+ {sourcePath: sdk.acceptance.contracts.platform.sourcePath},
+ ])
+ assert.throws(() => validateFeatureContractPins(sdk, [{...pin, ...delta}]))
+})
diff --git a/src/commands/group/capabilities.ts b/src/commands/group/capabilities.ts
new file mode 100644
index 00000000..7097202f
--- /dev/null
+++ b/src/commands/group/capabilities.ts
@@ -0,0 +1,38 @@
+import {Args, Flags} from '@oclif/core'
+import {BaseCommand} from '../../base-command.js'
+import {
+ getGroupCapabilities,
+ renderGroupCapabilities,
+} from '../../services/groups.js'
+
+export default class GroupCapabilitiesCommand extends BaseCommand {
+ static override args = {
+ group: Args.string({
+ description: 'Polymorfa group identifier.',
+ required: true,
+ }),
+ }
+ static override description =
+ 'Read stored WhatsApp group capabilities. Requires current membership and enrollment in the number-capabilities beta.'
+ static override flags = {
+ session: Flags.string({
+ description: 'Messaging session ID.',
+ required: true,
+ }),
+ }
+ static override examples = [
+ '<%= config.bin %> group capabilities 9007199254740993 --session support',
+ '<%= config.bin %> group capabilities 9007199254740993 --session support --json',
+ ]
+ async run(): Promise {
+ const {args, flags} = await this.parse(GroupCapabilitiesCommand)
+ const result = await getGroupCapabilities(
+ await this.messagingClient(flags, {
+ serverApiKeyOnly: 'Group capabilities',
+ }),
+ flags.session,
+ args.group,
+ )
+ this.writeValue(result, renderGroupCapabilities(result.data), flags)
+ }
+}
diff --git a/src/services/groups.ts b/src/services/groups.ts
index 37281259..1a899b7b 100644
--- a/src/services/groups.ts
+++ b/src/services/groups.ts
@@ -255,3 +255,42 @@ function result(data: T, metadata: ResponseMetadata): ResourceResult {
},
}
}
+
+/** Wire contract shared with the SDK's GroupCapabilities type. Raw request is
+ * supported by the pinned SDK; this command needs no unpublished dependency. */
+export interface GroupCapabilities {
+ readonly status: 'synced' | 'unknown'
+ readonly syncedAt: string | null
+ readonly checkedAt: string | null
+ readonly capabilities: readonly {
+ readonly key: string
+ readonly kind: 'feature'
+ readonly unit: null
+ readonly value: boolean | null
+ readonly source: 'server' | 'client_default' | null
+ }[]
+}
+export async function getGroupCapabilities(
+ client: MessagingClient,
+ session: string,
+ group: string,
+): Promise> {
+ const response = await client.raw.request<{
+ success: true
+ data: GroupCapabilities
+ }>({
+ method: 'GET',
+ path: `/messaging/${encodeURIComponent(session)}/groups/${encodeURIComponent(group)}/capabilities`,
+ })
+ return result(response.data.data, response.metadata)
+}
+export function renderGroupCapabilities(view: GroupCapabilities): string {
+ return [
+ `Status\t${view.status}`,
+ `Synced\t${view.syncedAt ?? 'never'}`,
+ ...view.capabilities.map(
+ (c) =>
+ `${c.key}\t${c.value === null ? 'unknown' : String(c.value)}\t${c.source ?? 'unknown'}`,
+ ),
+ ].join('\n')
+}
diff --git a/test/commands/groups.test.ts b/test/commands/groups.test.ts
index fffde134..ee3946ff 100644
--- a/test/commands/groups.test.ts
+++ b/test/commands/groups.test.ts
@@ -53,6 +53,34 @@ describe('group commands', () => {
})
})
+ test('prints unknown group capabilities as JSON', async () => {
+ const data = {
+ status: 'unknown',
+ syncedAt: null,
+ checkedAt: null,
+ capabilities: [
+ {
+ key: 'polls.endTime',
+ kind: 'feature',
+ unit: null,
+ value: null,
+ source: null,
+ },
+ ],
+ }
+ server = await startHttpServer({body: {success: true, data}})
+ const result = await environment(() =>
+ runCommand(
+ 'group capabilities 9007199254740993 --session session_1 --json',
+ ),
+ )
+ expect(result.error).toBeUndefined()
+ expect(JSON.parse(result.stdout)).toMatchObject({data})
+ expect(server.requests[0]?.url).toBe(
+ '/messaging/session_1/groups/9007199254740993/capabilities',
+ )
+ })
+
test('requires explicit session scope before making a request', async () => {
const result = await runCommand('group list --json')
diff --git a/test/services/groups.test.ts b/test/services/groups.test.ts
index 2d3dc7f1..6306df2c 100644
--- a/test/services/groups.test.ts
+++ b/test/services/groups.test.ts
@@ -3,6 +3,8 @@ import {afterEach, describe, expect, test} from 'vitest'
import {createMessagingClient} from '../../src/sdk/client.js'
import {
getGroupInviteLink,
+ getGroupCapabilities,
+ renderGroupCapabilities,
listGroupParticipants,
listGroups,
viewGroup,
@@ -53,6 +55,36 @@ describe('group read services', () => {
).resolves.toMatchObject({data: group.participants})
})
+ test('reads group capabilities without replacing unknown values', async () => {
+ const data = {
+ status: 'unknown' as const,
+ syncedAt: null,
+ checkedAt: null,
+ capabilities: [
+ {
+ key: 'polls.endTime',
+ kind: 'feature' as const,
+ unit: null,
+ value: null,
+ source: null,
+ },
+ ],
+ }
+ server = await startHttpServer({body: {success: true, data}})
+ const response = await getGroupCapabilities(
+ client(server.baseUrl),
+ 'session/main',
+ '9007199254740993',
+ )
+ expect(response.data).toEqual(data)
+ expect(server.requests[0]?.url).toBe(
+ '/messaging/session%2Fmain/groups/9007199254740993/capabilities',
+ )
+ expect(renderGroupCapabilities(data)).toContain(
+ 'polls.endTime\tunknown\tunknown',
+ )
+ })
+
test('derives the canonical invite URL while retaining its source code', async () => {
server = await startHttpServer({
body: {data: {code: 'Invite_123'}, success: true},