posterly-mcp-server 0.33.2 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/RELEASING.md +103 -0
- package/dist/index.js +10 -0
- package/dist/lib/api-client.d.ts +41 -1
- package/dist/lib/api-client.js +13 -0
- package/dist/lib/version.d.ts +1 -1
- package/dist/lib/version.js +1 -1
- package/dist/tools/connect-account.d.ts +59 -0
- package/dist/tools/connect-account.js +75 -0
- package/dist/tools/create-post.d.ts +56 -56
- package/dist/tools/create-posts-batch.d.ts +24 -24
- package/dist/tools/get-connect-link.d.ts +2 -2
- package/dist/tools/get-connect-link.js +1 -1
- package/dist/tools/get-platform-schema.d.ts +2 -2
- package/dist/tools/list-google-business-reviews.d.ts +8 -0
- package/dist/tools/list-google-business-reviews.js +87 -7
- package/dist/tools/list-posts.d.ts +2 -2
- package/dist/tools/update-post.d.ts +20 -20
- package/dist/tools/validate-post.d.ts +32 -32
- package/package.json +1 -1
- package/server.json +2 -2
- package/src/index.ts +16 -0
- package/src/lib/api-client.ts +52 -1
- package/src/lib/version.ts +1 -1
- package/src/tools/connect-account.ts +91 -0
- package/src/tools/get-connect-link.ts +1 -1
- package/src/tools/list-google-business-reviews.ts +101 -8
- package/tool-annotations.json +3 -0
package/src/lib/api-client.ts
CHANGED
|
@@ -572,10 +572,33 @@ export interface GoogleBusinessReview {
|
|
|
572
572
|
account_id?: number;
|
|
573
573
|
}
|
|
574
574
|
|
|
575
|
+
export interface GoogleBusinessLocationReviewSummary {
|
|
576
|
+
id: number;
|
|
577
|
+
location_id: string | null;
|
|
578
|
+
name: string | null;
|
|
579
|
+
business_name: string | null;
|
|
580
|
+
workspace_id: string | null;
|
|
581
|
+
/** Google's own review count for this location, or null when Google didn't report one. */
|
|
582
|
+
total_review_count: number | null;
|
|
583
|
+
average_rating: number | null;
|
|
584
|
+
fetched_review_count: number;
|
|
585
|
+
/** Why this location's reviews are missing or incomplete, else null. */
|
|
586
|
+
error: string | null;
|
|
587
|
+
}
|
|
588
|
+
|
|
575
589
|
export interface GoogleBusinessReviewsResponse {
|
|
576
590
|
reviews: GoogleBusinessReview[];
|
|
591
|
+
/** Reviews matching the filters across every page, not just the ones returned. */
|
|
577
592
|
total: number;
|
|
578
|
-
|
|
593
|
+
returned?: number;
|
|
594
|
+
limit?: number;
|
|
595
|
+
offset?: number;
|
|
596
|
+
has_more?: boolean;
|
|
597
|
+
/** Reviews fetched from Google before rating/unanswered filters. */
|
|
598
|
+
fetched?: number;
|
|
599
|
+
/** Google's reported review count summed across the selected locations. */
|
|
600
|
+
total_review_count?: number | null;
|
|
601
|
+
locations: Array<GoogleBusinessLocationReviewSummary | Record<string, unknown>>;
|
|
579
602
|
}
|
|
580
603
|
|
|
581
604
|
export interface GoogleBusinessMediaItem {
|
|
@@ -737,6 +760,17 @@ export interface ConnectSession {
|
|
|
737
760
|
expires_at: string;
|
|
738
761
|
}
|
|
739
762
|
|
|
763
|
+
export interface ConnectAccountCredentialsResponse {
|
|
764
|
+
connected: boolean;
|
|
765
|
+
account: {
|
|
766
|
+
id: number;
|
|
767
|
+
platform: string;
|
|
768
|
+
username: string;
|
|
769
|
+
[extra: string]: unknown;
|
|
770
|
+
};
|
|
771
|
+
workspace_id: string;
|
|
772
|
+
}
|
|
773
|
+
|
|
740
774
|
export interface OAuthDeveloperClient {
|
|
741
775
|
id: string;
|
|
742
776
|
client_id: string;
|
|
@@ -1067,6 +1101,19 @@ export class PosterlyClient {
|
|
|
1067
1101
|
return this.request('GET', `/connect/sessions/${encodeURIComponent(sessionId)}`);
|
|
1068
1102
|
}
|
|
1069
1103
|
|
|
1104
|
+
async connectAccountCredentials(data: {
|
|
1105
|
+
platform: string;
|
|
1106
|
+
credentials: Record<string, string>;
|
|
1107
|
+
workspace_id?: string;
|
|
1108
|
+
connect_session_id?: string;
|
|
1109
|
+
}): Promise<ConnectAccountCredentialsResponse> {
|
|
1110
|
+
const { platform, credentials, workspace_id, connect_session_id } = data;
|
|
1111
|
+
const body: Record<string, unknown> = { credentials };
|
|
1112
|
+
if (workspace_id) body.workspace_id = workspace_id;
|
|
1113
|
+
if (connect_session_id) body.connect_session_id = connect_session_id;
|
|
1114
|
+
return this.request('POST', `/connect/${encodeURIComponent(platform)}/credentials`, body);
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1070
1117
|
async listOAuthClients(): Promise<{ clients: OAuthDeveloperClient[] }> {
|
|
1071
1118
|
return this.request('GET', '/oauth/clients');
|
|
1072
1119
|
}
|
|
@@ -1375,6 +1422,8 @@ export class PosterlyClient {
|
|
|
1375
1422
|
location_id?: string;
|
|
1376
1423
|
rating?: number;
|
|
1377
1424
|
unanswered?: boolean;
|
|
1425
|
+
limit?: number;
|
|
1426
|
+
offset?: number;
|
|
1378
1427
|
}): Promise<GoogleBusinessReviewsResponse> {
|
|
1379
1428
|
const searchParams = new URLSearchParams();
|
|
1380
1429
|
if (params?.workspace_id) searchParams.set('workspace_id', params.workspace_id);
|
|
@@ -1382,6 +1431,8 @@ export class PosterlyClient {
|
|
|
1382
1431
|
if (params?.location_id) searchParams.set('location_id', params.location_id);
|
|
1383
1432
|
if (params?.rating) searchParams.set('rating', String(params.rating));
|
|
1384
1433
|
if (params?.unanswered) searchParams.set('unanswered', 'true');
|
|
1434
|
+
if (params?.limit) searchParams.set('limit', String(params.limit));
|
|
1435
|
+
if (params?.offset) searchParams.set('offset', String(params.offset));
|
|
1385
1436
|
const qs = searchParams.toString();
|
|
1386
1437
|
return this.request('GET', `/google-business/reviews${qs ? `?${qs}` : ''}`);
|
|
1387
1438
|
}
|
package/src/lib/version.ts
CHANGED
|
@@ -5,4 +5,4 @@
|
|
|
5
5
|
// tool set (minus the intentional pre-auth signup tools that only this stdio
|
|
6
6
|
// package exposes). `npm run check:mcp-parity` enforces both the version match
|
|
7
7
|
// and the tool-list match, and runs in the pre-commit hook.
|
|
8
|
-
export const POSTERLY_MCP_VERSION = '0.
|
|
8
|
+
export const POSTERLY_MCP_VERSION = '0.34.0';
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { PosterlyClient } from '../lib/api-client.js';
|
|
3
|
+
|
|
4
|
+
const CREDENTIAL_PLATFORMS = [
|
|
5
|
+
'telegram',
|
|
6
|
+
'bluesky',
|
|
7
|
+
'discord',
|
|
8
|
+
'wordpress',
|
|
9
|
+
'devto',
|
|
10
|
+
'hashnode',
|
|
11
|
+
'lemmy',
|
|
12
|
+
] as const;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Per-platform credential field whitelist. Duplicated in the hosted tool
|
|
16
|
+
* (lib/mcp/http-tools.ts) on purpose: the platform manifest does not carry
|
|
17
|
+
* credential fields, and the v1 endpoint rejects unknown keys, so each tool
|
|
18
|
+
* must whitelist before building the credentials object. The MCP parity and
|
|
19
|
+
* payload checks guard the duplication.
|
|
20
|
+
*/
|
|
21
|
+
const PLATFORM_CREDENTIAL_FIELDS: Record<string, string[]> = {
|
|
22
|
+
telegram: ['bot_token', 'chat_id'],
|
|
23
|
+
bluesky: ['handle', 'app_password'],
|
|
24
|
+
discord: ['webhook_url'],
|
|
25
|
+
wordpress: ['site_url', 'username', 'app_password'],
|
|
26
|
+
devto: ['api_key'],
|
|
27
|
+
hashnode: ['pat'],
|
|
28
|
+
lemmy: ['instance', 'username', 'password'],
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const connectAccountTool = {
|
|
32
|
+
name: 'connect_account',
|
|
33
|
+
description:
|
|
34
|
+
'Connect a credential-based social account (telegram, bluesky, discord, wordpress, devto, hashnode, lemmy) directly, without a browser session. Call get_connect_link first to discover the exact credential fields the platform needs. Prefer scoped secrets: app passwords (Bluesky, WordPress), bot tokens (Telegram), webhook URLs (Discord), and API tokens (Dev.to, Hashnode) over primary passwords. Warn the user that any credential they share passes through this conversation. For OAuth platforms (Instagram, X, LinkedIn, ...) use create_connect_session instead.',
|
|
35
|
+
inputSchema: z.object({
|
|
36
|
+
platform: z
|
|
37
|
+
.enum(CREDENTIAL_PLATFORMS)
|
|
38
|
+
.describe('Credential-based connection target: telegram, bluesky, discord, wordpress, devto, hashnode, or lemmy.'),
|
|
39
|
+
bot_token: z.string().optional().describe('Telegram: bot token from BotFather.'),
|
|
40
|
+
chat_id: z.string().optional().describe('Telegram: target channel or group chat ID.'),
|
|
41
|
+
handle: z.string().optional().describe('Bluesky: handle, for example posterly.bsky.social.'),
|
|
42
|
+
app_password: z.string().optional().describe('Bluesky: app password from account settings. WordPress: application password from wp-admin Users, Profile, Application Passwords.'),
|
|
43
|
+
webhook_url: z.string().optional().describe('Discord: channel webhook URL from channel settings, Integrations, Webhooks.'),
|
|
44
|
+
site_url: z.string().optional().describe('WordPress: site URL, e.g. https://blog.example.com.'),
|
|
45
|
+
username: z.string().optional().describe('WordPress: username the application password belongs to. Lemmy: username or email.'),
|
|
46
|
+
password: z.string().optional().describe('Lemmy: account password (TOTP-enabled accounts are not supported).'),
|
|
47
|
+
api_key: z.string().optional().describe('Dev.to: API key from Settings, Extensions, DEV Community API Keys.'),
|
|
48
|
+
pat: z.string().optional().describe('Hashnode: personal access token from Account Settings, Developer.'),
|
|
49
|
+
instance: z.string().optional().describe('Lemmy: instance domain, for example lemmy.world.'),
|
|
50
|
+
workspace_id: z.string().optional().describe('Workspace to connect the account into. Workspace-scoped API keys ignore this.'),
|
|
51
|
+
connect_session_id: z.string().optional().describe('Optional connect session ID to mark connected or failed based on the outcome.'),
|
|
52
|
+
}),
|
|
53
|
+
|
|
54
|
+
async execute(
|
|
55
|
+
client: PosterlyClient,
|
|
56
|
+
input: {
|
|
57
|
+
platform: (typeof CREDENTIAL_PLATFORMS)[number];
|
|
58
|
+
workspace_id?: string;
|
|
59
|
+
connect_session_id?: string;
|
|
60
|
+
} & Record<string, string | undefined>,
|
|
61
|
+
) {
|
|
62
|
+
const allowedFields = PLATFORM_CREDENTIAL_FIELDS[input.platform] || [];
|
|
63
|
+
const credentials: Record<string, string> = {};
|
|
64
|
+
for (const field of allowedFields) {
|
|
65
|
+
const value = input[field];
|
|
66
|
+
if (typeof value === 'string' && value.trim()) {
|
|
67
|
+
credentials[field] = value;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const missing = allowedFields.filter((field) => !(field in credentials));
|
|
72
|
+
if (missing.length > 0) {
|
|
73
|
+
return `Missing credential field(s) for ${input.platform}: ${missing.join(', ')}. Call get_connect_link with platform=${input.platform} to see field descriptions.`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const result = await client.connectAccountCredentials({
|
|
77
|
+
platform: input.platform,
|
|
78
|
+
credentials,
|
|
79
|
+
workspace_id: input.workspace_id,
|
|
80
|
+
connect_session_id: input.connect_session_id,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const account = result.account || {};
|
|
84
|
+
const lines = [
|
|
85
|
+
`Connected ${input.platform} account: ${account.username || 'unknown'} (id ${account.id})`,
|
|
86
|
+
`Workspace: ${result.workspace_id}`,
|
|
87
|
+
'Reconnecting the same account updates it in place.',
|
|
88
|
+
];
|
|
89
|
+
return lines.join('\n');
|
|
90
|
+
},
|
|
91
|
+
};
|
|
@@ -5,7 +5,7 @@ import { CONNECT_INPUTS, PLANNED_PLATFORM_IDS } from '../generated/platform-mani
|
|
|
5
5
|
export const getConnectLinkTool = {
|
|
6
6
|
name: 'get_connect_link',
|
|
7
7
|
description:
|
|
8
|
-
'List dashboard handoff links/readiness for connecting social accounts, or get one platform connection URL. Use connection_url in a logged-in browser. Direct OAuth URLs are intentionally not exposed because provider callbacks rely on browser state.',
|
|
8
|
+
'List dashboard handoff links/readiness for connecting social accounts, or get one platform connection URL. Use connection_url in a logged-in browser. Direct OAuth URLs are intentionally not exposed because provider callbacks rely on browser state. Credential-based platforms (telegram, bluesky, discord, wordpress, devto, hashnode, lemmy) can instead be connected headlessly with connect_account; this tool shows their credential_fields.',
|
|
9
9
|
inputSchema: z.object({
|
|
10
10
|
platform: z
|
|
11
11
|
.enum(CONNECT_INPUTS)
|
|
@@ -1,16 +1,33 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import type { PosterlyClient } from '../lib/api-client.js';
|
|
2
|
+
import type { PosterlyClient, GoogleBusinessLocationReviewSummary } from '../lib/api-client.js';
|
|
3
|
+
import { formatDate } from '../lib/format.js';
|
|
4
|
+
|
|
5
|
+
/** Matches GOOGLE_BUSINESS_REVIEWS_MAX_LIMIT in lib/api-v1-google-business.ts. */
|
|
6
|
+
const MAX_LIMIT = 1000;
|
|
3
7
|
|
|
4
8
|
export const listGoogleBusinessReviewsTool = {
|
|
5
9
|
name: 'list_google_business_reviews',
|
|
6
10
|
description:
|
|
7
|
-
'List Google Business Profile reviews for one location/account or every accessible GBP location. Supports rating and unanswered-only filters.',
|
|
11
|
+
'List Google Business Profile reviews for one location/account or every accessible GBP location. Pages through all reviews Google has (not just the first 50), returns the full untruncated review text with its date, and reports each location\'s total review count. Supports rating and unanswered-only filters plus limit/offset paging.',
|
|
8
12
|
inputSchema: z.object({
|
|
9
13
|
workspace_id: z.string().optional().describe('Filter to a workspace ID from whoami.'),
|
|
10
14
|
account_id: z.number().int().positive().optional().describe('Google Business account ID from list_accounts.'),
|
|
11
15
|
location_id: z.string().optional().describe('Google Business numeric location id (the location_id/platform_user_id from list_accounts, e.g. "197940849675145390"). NOT the ChIJ... Place ID returned by get_google_business_review_link. Prefer account_id.'),
|
|
12
16
|
rating: z.number().int().min(1).max(5).optional().describe('Filter to a 1-5 star rating.'),
|
|
13
17
|
unanswered: z.boolean().optional().describe('Only return reviews without owner replies.'),
|
|
18
|
+
limit: z
|
|
19
|
+
.number()
|
|
20
|
+
.int()
|
|
21
|
+
.min(1)
|
|
22
|
+
.max(MAX_LIMIT)
|
|
23
|
+
.optional()
|
|
24
|
+
.describe(`Maximum reviews to return in this call (default 200, max ${MAX_LIMIT}). Every returned review is listed in full.`),
|
|
25
|
+
offset: z
|
|
26
|
+
.number()
|
|
27
|
+
.int()
|
|
28
|
+
.min(0)
|
|
29
|
+
.optional()
|
|
30
|
+
.describe('Skip this many reviews before returning results. Use with limit to page through a location with more reviews than one call returns.'),
|
|
14
31
|
}),
|
|
15
32
|
|
|
16
33
|
async execute(
|
|
@@ -21,23 +38,99 @@ export const listGoogleBusinessReviewsTool = {
|
|
|
21
38
|
location_id?: string;
|
|
22
39
|
rating?: number;
|
|
23
40
|
unanswered?: boolean;
|
|
41
|
+
limit?: number;
|
|
42
|
+
offset?: number;
|
|
24
43
|
},
|
|
25
44
|
) {
|
|
26
45
|
const result = await client.listGoogleBusinessReviews(input);
|
|
46
|
+
const locations = (result.locations || []) as GoogleBusinessLocationReviewSummary[];
|
|
47
|
+
const offset = result.offset ?? 0;
|
|
48
|
+
|
|
27
49
|
if (result.reviews.length === 0) {
|
|
28
|
-
|
|
50
|
+
// Distinguish "no reviews exist" from "we couldn't read them" — a silent
|
|
51
|
+
// zero used to be indistinguishable from a broken connection.
|
|
52
|
+
const lines = ['No Google Business reviews found matching your filters.'];
|
|
53
|
+
const locationLines = locations.map((location) => formatLocationLine(location));
|
|
54
|
+
if (locationLines.length > 0) {
|
|
55
|
+
lines.push('', 'Locations checked:', ...locationLines);
|
|
56
|
+
}
|
|
57
|
+
return lines.join('\n');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const header = [`Google Business reviews (showing ${result.reviews.length} of ${result.total} matching`];
|
|
61
|
+
if (offset > 0) header.push(`, from offset ${offset}`);
|
|
62
|
+
if (typeof result.total_review_count === 'number') {
|
|
63
|
+
header.push(`; ${result.total_review_count} total on Google`);
|
|
64
|
+
}
|
|
65
|
+
header.push('):');
|
|
66
|
+
|
|
67
|
+
const lines = [header.join('')];
|
|
68
|
+
|
|
69
|
+
for (const location of locations) {
|
|
70
|
+
if (location.error) {
|
|
71
|
+
lines.push(`! ${location.business_name || location.name || location.location_id || 'location'}: ${location.error}`);
|
|
72
|
+
}
|
|
29
73
|
}
|
|
30
74
|
|
|
31
|
-
|
|
32
|
-
|
|
75
|
+
lines.push('');
|
|
76
|
+
|
|
77
|
+
for (const review of result.reviews) {
|
|
33
78
|
const name = review.reviewer?.displayName || 'Anonymous';
|
|
34
79
|
const rating = review.starRating ? `${review.starRating} stars` : 'unrated';
|
|
35
80
|
const location = review.businessName || review.locationName || review.locationId || 'location';
|
|
36
|
-
const
|
|
37
|
-
const replied = review.reviewReply ? '
|
|
38
|
-
lines.push(`- ${name} (${rating}, ${location}
|
|
81
|
+
const date = formatReviewDate(review);
|
|
82
|
+
const replied = review.reviewReply ? 'replied' : 'unanswered';
|
|
83
|
+
lines.push(`- ${name} (${rating}, ${date}, ${location}, ${replied})`);
|
|
84
|
+
// Full review text, no truncation — collapse whitespace only so the
|
|
85
|
+
// bullet list stays readable for multi-paragraph reviews.
|
|
86
|
+
if (review.comment) {
|
|
87
|
+
lines.push(` ${review.comment.replace(/\s+/g, ' ').trim()}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (result.has_more) {
|
|
92
|
+
const nextOffset = offset + result.reviews.length;
|
|
93
|
+
lines.push('', `More reviews available — call again with offset: ${nextOffset} to continue.`);
|
|
39
94
|
}
|
|
40
95
|
|
|
96
|
+
lines.push('', 'Locations:', ...locations.map((location) => formatLocationLine(location)));
|
|
97
|
+
|
|
41
98
|
return lines.join('\n');
|
|
42
99
|
},
|
|
43
100
|
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* When a review was written, plus when it was last edited if that differs.
|
|
104
|
+
* The list is ordered by last activity (updateTime), so showing only the
|
|
105
|
+
* original date would make an edited review look out of order.
|
|
106
|
+
*/
|
|
107
|
+
function formatReviewDate(review: { createTime?: string; updateTime?: string }): string {
|
|
108
|
+
const created = review.createTime || review.updateTime;
|
|
109
|
+
const createdLabel = formatDate(created);
|
|
110
|
+
if (!review.updateTime || !review.createTime) return createdLabel;
|
|
111
|
+
|
|
112
|
+
const updatedLabel = formatDate(review.updateTime);
|
|
113
|
+
if (updatedLabel === createdLabel) return createdLabel;
|
|
114
|
+
return `${createdLabel}, edited ${updatedLabel}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function formatLocationLine(location: GoogleBusinessLocationReviewSummary): string {
|
|
118
|
+
const label = location.business_name || location.name || location.location_id || `account ${location.id}`;
|
|
119
|
+
const parts: string[] = [];
|
|
120
|
+
|
|
121
|
+
if (typeof location.total_review_count === 'number') {
|
|
122
|
+
parts.push(`${location.total_review_count} reviews on Google`);
|
|
123
|
+
} else if (!location.error) {
|
|
124
|
+
parts.push('review count not reported by Google');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (typeof location.average_rating === 'number') {
|
|
128
|
+
parts.push(`${location.average_rating.toFixed(1)} avg`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
parts.push(`${location.fetched_review_count} fetched`);
|
|
132
|
+
|
|
133
|
+
if (location.error) parts.push(`ERROR: ${location.error}`);
|
|
134
|
+
|
|
135
|
+
return `- ${label} (account_id ${location.id}): ${parts.join(', ')}`;
|
|
136
|
+
}
|
package/tool-annotations.json
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
"list_accounts",
|
|
9
9
|
"disconnect_account",
|
|
10
10
|
"get_connect_link",
|
|
11
|
+
"connect_account",
|
|
11
12
|
"create_connect_session",
|
|
12
13
|
"get_connect_session",
|
|
13
14
|
"create_api_key",
|
|
@@ -138,6 +139,7 @@
|
|
|
138
139
|
"delete_webhook"
|
|
139
140
|
],
|
|
140
141
|
"idempotentMutationTools": [
|
|
142
|
+
"connect_account",
|
|
141
143
|
"disconnect_account",
|
|
142
144
|
"delete_api_key",
|
|
143
145
|
"cancel_subscription",
|
|
@@ -163,6 +165,7 @@
|
|
|
163
165
|
"get_signup_session",
|
|
164
166
|
"get_mcp_status",
|
|
165
167
|
"disconnect_account",
|
|
168
|
+
"connect_account",
|
|
166
169
|
"create_connect_session",
|
|
167
170
|
"get_connect_session",
|
|
168
171
|
"get_credits",
|