@gobi-ai/cli 2.0.30 → 2.0.32

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.
@@ -4,12 +4,12 @@
4
4
  "name": "gobi-ai"
5
5
  },
6
6
  "description": "Claude Code plugin for the Gobi collaborative knowledge platform CLI",
7
- "version": "2.0.30",
7
+ "version": "2.0.32",
8
8
  "plugins": [
9
9
  {
10
10
  "name": "gobi",
11
11
  "description": "Manage the Gobi collaborative knowledge platform from the command line. Publish vault profiles, create posts and replies, generate images and videos.",
12
- "version": "2.0.30",
12
+ "version": "2.0.32",
13
13
  "author": {
14
14
  "name": "gobi-ai"
15
15
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "gobi",
3
3
  "description": "Manage the Gobi collaborative knowledge platform from the command line",
4
- "version": "2.0.30",
4
+ "version": "2.0.32",
5
5
  "author": {
6
6
  "name": "gobi-ai"
7
7
  },
@@ -7,8 +7,10 @@ import { apiPost } from "./client.js";
7
7
  import { normalizeSyncPattern } from "./commands/sync.js";
8
8
  // Best-effort extension → MIME mapping. Anything we don't recognize falls
9
9
  // back to `application/octet-stream`; the backend caps size per content-type
10
- // tier (5MB photos / 15MB GIFs / 512MB video) so it's the authority on what's
11
- // allowed. We're just trying to set a usable Content-Type for the S3 PUT.
10
+ // tier (10MB photos / 15MB GIFs / 512MB video / 250MB document files) so
11
+ // it's the authority on what's allowed. We're just trying to set a usable
12
+ // Content-Type for the S3 PUT — and for document files the declared MIME is
13
+ // what routes the post row into the 'file' kind, so it must be accurate.
12
14
  const POST_MEDIA_MIME_MAP = {
13
15
  ".jpg": "image/jpeg",
14
16
  ".jpeg": "image/jpeg",
@@ -26,20 +28,30 @@ const POST_MEDIA_MIME_MAP = {
26
28
  ".webm": "video/webm",
27
29
  ".m4v": "video/x-m4v",
28
30
  ".pdf": "application/pdf",
31
+ ".md": "text/markdown",
32
+ ".markdown": "text/markdown",
33
+ ".txt": "text/plain",
34
+ ".csv": "text/csv",
29
35
  };
30
- // X-style cap: 4 photos OR 1 GIF OR 1 video. Anything not photo/gif/video
31
- // is treated as "photo" for cap purposes so misclassified files still get
32
- // the 4-cap rather than slipping through unlimited.
36
+ const FILE_EXTENSIONS = [".pdf", ".md", ".markdown", ".txt", ".csv"];
37
+ const VIDEO_EXTENSIONS = [".mp4", ".mov", ".webm", ".m4v"];
38
+ // Mirrors the backend mix rule: up to 4 photos + up to 4 document files
39
+ // (pdf/md/txt/csv) together, OR 1 GIF, OR 1 video — GIF and video are
40
+ // exclusive with everything. Unknown extensions count against the photo cap
41
+ // (the backend's 'other' kind) so nothing slips through unlimited.
33
42
  export function assertPostAttachmentMix(paths) {
34
43
  let photos = 0;
35
44
  let gifs = 0;
36
45
  let videos = 0;
46
+ let files = 0;
37
47
  for (const p of paths) {
38
48
  const ext = extname(p).toLowerCase();
39
49
  if (ext === ".gif")
40
50
  gifs += 1;
41
- else if ([".mp4", ".mov", ".webm", ".m4v"].includes(ext))
51
+ else if (VIDEO_EXTENSIONS.includes(ext))
42
52
  videos += 1;
53
+ else if (FILE_EXTENSIONS.includes(ext))
54
+ files += 1;
43
55
  else
44
56
  photos += 1;
45
57
  }
@@ -49,10 +61,12 @@ export function assertPostAttachmentMix(paths) {
49
61
  throw new Error("Only 1 GIF allowed per post");
50
62
  if (photos > 4)
51
63
  throw new Error("Up to 4 photos allowed per post");
52
- if (videos > 0 && (gifs > 0 || photos > 0)) {
64
+ if (files > 4)
65
+ throw new Error("Up to 4 files allowed per post");
66
+ if (videos > 0 && (gifs > 0 || photos > 0 || files > 0)) {
53
67
  throw new Error("A video can't be combined with other media");
54
68
  }
55
- if (gifs > 0 && (videos > 0 || photos > 0)) {
69
+ if (gifs > 0 && (videos > 0 || photos > 0 || files > 0)) {
56
70
  throw new Error("A GIF can't be combined with other media");
57
71
  }
58
72
  }
@@ -90,7 +104,15 @@ export async function uploadPostAttachment(filePath) {
90
104
  if (!putRes.ok) {
91
105
  throw new Error(`Failed to PUT ${filePath} to S3: HTTP ${putRes.status}`);
92
106
  }
93
- return { mediaUrl, mediaKey };
107
+ const attachment = { mediaUrl, mediaKey };
108
+ // Document files carry name + MIME on the row: the declared mimeType is
109
+ // what the backend trusts for kind/preview routing, and the original
110
+ // filename only survives here (the S3 key is a UUID).
111
+ if (FILE_EXTENSIONS.includes(ext)) {
112
+ attachment.fileName = basename(abs);
113
+ attachment.mimeType = contentType;
114
+ }
115
+ return attachment;
94
116
  }
95
117
  export async function uploadPostAttachments(paths) {
96
118
  const out = [];
package/dist/client.js CHANGED
@@ -38,6 +38,9 @@ export function apiPost(path, body) {
38
38
  export function apiPatch(path, body) {
39
39
  return request("PATCH", path, { body });
40
40
  }
41
+ export function apiPut(path, body) {
42
+ return request("PUT", path, { body });
43
+ }
41
44
  export function apiDelete(path) {
42
45
  return request("DELETE", path);
43
46
  }
@@ -1,6 +1,6 @@
1
1
  import { WEB_BASE_URL } from "../constants.js";
2
- import { apiGet, apiPost, apiPatch, apiDelete } from "../client.js";
3
- import { isJsonMode, jsonOut, readStdin, unwrapResp, } from "./utils.js";
2
+ import { apiGet, apiPost, apiPatch, apiPut, apiDelete } from "../client.js";
3
+ import { formatAttachmentLines, formatAttachmentSummary, formatReactionChips, isJsonMode, jsonOut, readStdin, unwrapResp, } from "./utils.js";
4
4
  import { uploadPostAttachments, assertPostAttachmentMix, } from "../attachments.js";
5
5
  function readContent(value) {
6
6
  if (value === "-")
@@ -26,7 +26,11 @@ function formatFeedLine(m) {
26
26
  else {
27
27
  label = m.title || m.content || "";
28
28
  }
29
- return `${id} ${kind} ${author} "${label}" ${m.createdAt}`;
29
+ const chips = formatReactionChips(m);
30
+ const attachSummary = formatAttachmentSummary(m);
31
+ return (`${id} ${kind} ${author} "${label}" ${m.createdAt}` +
32
+ (attachSummary ? ` ${attachSummary}` : "") +
33
+ (chips ? ` ${chips}` : ""));
30
34
  }
31
35
  export function registerGlobalCommand(program) {
32
36
  const global = program
@@ -145,20 +149,28 @@ export function registerGlobalCommand(program) {
145
149
  `User ${r.authorId}`;
146
150
  const text = r.content || "";
147
151
  const truncated = opts.full || text.length <= 200 ? text : text.slice(0, 200) + "…";
148
- replyLines.push(` - ${rAuthor}: ${truncated} (${r.createdAt})`);
152
+ const rChips = formatReactionChips(r);
153
+ const rAttach = formatAttachmentSummary(r);
154
+ replyLines.push(` - [r:${r.id}] ${rAuthor}: ${truncated} (${r.createdAt})${rAttach ? ` ${rAttach}` : ""}${rChips ? ` ${rChips}` : ""}`);
149
155
  }
150
156
  const isReplyPost = post.parentPostId != null;
151
157
  const heading = isReplyPost
152
158
  ? `Reply [r:${post.id}]`
153
159
  : `Post: ${post.title || "(no title)"}`;
160
+ const postChips = formatReactionChips(post);
161
+ const attachmentLines = formatAttachmentLines(post);
154
162
  const output = [
155
163
  heading,
156
164
  `By: ${author} on ${post.createdAt}`,
165
+ ...(postChips ? [`Reactions: ${postChips}`] : []),
157
166
  ...(ancestorLines.length
158
167
  ? ["", `Ancestors (${ancestors.length} items, root first):`, ...ancestorLines]
159
168
  : []),
160
169
  "",
161
170
  post.content || "",
171
+ ...(attachmentLines.length
172
+ ? ["", `Attachments (${attachmentLines.length}):`, ...attachmentLines]
173
+ : []),
162
174
  "",
163
175
  `Replies (${replies.length} items):`,
164
176
  ...replyLines,
@@ -176,7 +188,7 @@ export function registerGlobalCommand(program) {
176
188
  .option("--content <content>", "Post content (markdown supported, use \"-\" for stdin)")
177
189
  .option("--rich-text <richText>", "Rich-text JSON array (mutually exclusive with --content)")
178
190
  .option("--artifact <artifactId>", "Attach an existing artifact to the post (repeatable). Create artifacts with `gobi artifact create`.", (value, prev = []) => [...prev, value], [])
179
- .option("--attach <file>", "Local media file to attach. Repeatable. X-style mix rule: up to 4 photos OR 1 GIF OR 1 video. Size ceilings: 5MB photos / 15MB GIFs / 512MB video.", (value, prev = []) => [...prev, value], [])
191
+ .option("--attach <file>", "Local media or document file to attach. Repeatable. Mix rule: up to 4 photos + up to 4 document files (pdf/md/txt/csv) OR 1 GIF OR 1 video. Size ceilings: 10MB photos / 15MB GIFs / 512MB video / 250MB files.", (value, prev = []) => [...prev, value], [])
180
192
  .option("--repost-post-id <postId>", "Wrap an existing top-level post as the embedded card on this new post. Composes with --content / --rich-text / --attach (the wrapping author's text + media render above the embedded card). Reposts-of-reposts are collapsed to the transitive root server-side. The referenced post must exist, not be deleted, and not itself be a reply.")
181
193
  .action(async (opts) => {
182
194
  if (!opts.content && !opts.richText) {
@@ -234,7 +246,7 @@ export function registerGlobalCommand(program) {
234
246
  .option("--title <title>", "New title")
235
247
  .option("--content <content>", "New content (markdown supported, use \"-\" for stdin)")
236
248
  .option("--rich-text <richText>", "Rich-text JSON array (mutually exclusive with --content)")
237
- .option("--attach <file>", "Replace the post's media attachments with the given files (existing attachments are removed). Repeatable. X-style mix rule: up to 4 photos OR 1 GIF OR 1 video. Size ceilings: 5MB photos / 15MB GIFs / 512MB video. Omit to leave attachments unchanged.", (value, prev = []) => [...prev, value], [])
249
+ .option("--attach <file>", "Replace the post's media attachments with the given files (existing attachments are removed). Repeatable. Mix rule: up to 4 photos + up to 4 document files (pdf/md/txt/csv) OR 1 GIF OR 1 video. Size ceilings: 10MB photos / 15MB GIFs / 512MB video / 250MB files. Omit to leave attachments unchanged.", (value, prev = []) => [...prev, value], [])
238
250
  .option("--artifact <artifactId>", "Replace the post's artifact attachments with the given artifact(s) (existing artifact attachments are removed). Repeatable. Omit to leave them unchanged. Create artifacts with `gobi artifact create`.", (value, prev = []) => [...prev, value], [])
239
251
  .action(async (postId, opts) => {
240
252
  const wantsAttachChange = !!(opts.attach && opts.attach.length > 0);
@@ -297,7 +309,7 @@ export function registerGlobalCommand(program) {
297
309
  .description("Create a reply to a post in the global feed.")
298
310
  .option("--content <content>", "Reply content (markdown supported, use \"-\" for stdin)")
299
311
  .option("--rich-text <richText>", "Rich-text JSON array (mutually exclusive with --content)")
300
- .option("--attach <file>", "Local media file to attach to this reply. Repeatable. X-style mix rule: up to 4 photos OR 1 GIF OR 1 video. Size ceilings: 5MB photos / 15MB GIFs / 512MB video.", (value, prev = []) => [...prev, value], [])
312
+ .option("--attach <file>", "Local media or document file to attach to this reply. Repeatable. Mix rule: up to 4 photos + up to 4 document files (pdf/md/txt/csv) OR 1 GIF OR 1 video. Size ceilings: 10MB photos / 15MB GIFs / 512MB video / 250MB files.", (value, prev = []) => [...prev, value], [])
301
313
  .action(async (postId, opts) => {
302
314
  if (!opts.content && !opts.richText) {
303
315
  throw new Error("Provide either --content or --rich-text.");
@@ -376,4 +388,34 @@ export function registerGlobalCommand(program) {
376
388
  }
377
389
  console.log(`Reply ${replyId} deleted.`);
378
390
  });
391
+ // ── Reactions (react, unreact) ──
392
+ global
393
+ .command("react <postId> <emoji>")
394
+ .description("Add an emoji reaction to a global-feed post or reply (idempotent). <postId> is the numeric id of a post OR a reply — the [p:N]/[r:N] ids shown in feed output.")
395
+ .action(async (postId, emoji) => {
396
+ const resp = (await apiPut(`/posts/${postId}/reactions`, {
397
+ emoji,
398
+ }));
399
+ const data = unwrapResp(resp);
400
+ if (isJsonMode(global)) {
401
+ jsonOut(data);
402
+ return;
403
+ }
404
+ const chips = formatReactionChips(data);
405
+ console.log(`Reacted ${emoji} to ${postId}.` + (chips ? `\n Now: ${chips}` : ""));
406
+ });
407
+ global
408
+ .command("unreact <postId> <emoji>")
409
+ .description("Remove your emoji reaction from a global-feed post or reply. <postId> is the numeric id of a post OR a reply.")
410
+ .action(async (postId, emoji) => {
411
+ const resp = (await apiDelete(`/posts/${postId}/reactions/${encodeURIComponent(emoji)}`));
412
+ const data = unwrapResp(resp);
413
+ if (isJsonMode(global)) {
414
+ jsonOut(data);
415
+ return;
416
+ }
417
+ const chips = formatReactionChips(data);
418
+ console.log(`Removed ${emoji} reaction from ${postId}.` +
419
+ (chips ? `\n Now: ${chips}` : ""));
420
+ });
379
421
  }
@@ -1,5 +1,5 @@
1
- import { apiGet, apiPost, apiPatch, apiDelete } from "../client.js";
2
- import { isJsonMode, jsonOut, readStdin, unwrapResp, } from "./utils.js";
1
+ import { apiGet, apiPost, apiPatch, apiPut, apiDelete } from "../client.js";
2
+ import { formatAttachmentLines, formatAttachmentSummary, formatReactionChips, isJsonMode, jsonOut, readStdin, unwrapResp, } from "./utils.js";
3
3
  import { uploadPostAttachments, assertPostAttachmentMix, } from "../attachments.js";
4
4
  function readContent(value) {
5
5
  if (value === "-")
@@ -22,7 +22,11 @@ function formatFeedLine(m) {
22
22
  else {
23
23
  label = m.title || m.content || "";
24
24
  }
25
- return `${id} ${kind} ${author} "${label}" ${m.createdAt}`;
25
+ const chips = formatReactionChips(m);
26
+ const attachSummary = formatAttachmentSummary(m);
27
+ return (`${id} ${kind} ${author} "${label}" ${m.createdAt}` +
28
+ (attachSummary ? ` ${attachSummary}` : "") +
29
+ (chips ? ` ${chips}` : ""));
26
30
  }
27
31
  export function registerPersonalCommand(program) {
28
32
  const personal = program
@@ -62,6 +66,40 @@ export function registerPersonalCommand(program) {
62
66
  lines.join("\n") +
63
67
  footer);
64
68
  });
69
+ // ── Search ──
70
+ personal
71
+ .command("search-posts <query>")
72
+ .description("Search your personal-space posts and replies (newest first). The query supports keywords " +
73
+ "plus from:<name> and topic:<tag> operators (quote multi-word values). " +
74
+ "Each result is an individual post or reply, not a whole thread.")
75
+ .option("--limit <number>", "Items per page", "20")
76
+ .option("--cursor <string>", "Pagination cursor from previous response")
77
+ .action(async (query, opts) => {
78
+ const params = {
79
+ q: query,
80
+ limit: parseInt(opts.limit, 10),
81
+ };
82
+ if (opts.cursor)
83
+ params.cursor = opts.cursor;
84
+ const resp = (await apiGet(`/posts/personal-space/search`, params));
85
+ if (isJsonMode(personal)) {
86
+ jsonOut({
87
+ items: resp.data || [],
88
+ pagination: resp.pagination || {},
89
+ mentions: resp.mentions || {},
90
+ });
91
+ return;
92
+ }
93
+ const items = (resp.data || []);
94
+ const pagination = (resp.pagination || {});
95
+ if (!items.length) {
96
+ console.log("No results found.");
97
+ return;
98
+ }
99
+ const lines = items.map(formatFeedLine);
100
+ const footer = pagination.hasMore ? `\n Next cursor: ${pagination.nextCursor}` : "";
101
+ console.log(`Search results (${items.length} items, newest first):\n` + lines.join("\n") + footer);
102
+ });
65
103
  // ── List posts ──
66
104
  //
67
105
  // No server-side roots-only endpoint exists for the personal-space lane;
@@ -147,20 +185,28 @@ export function registerPersonalCommand(program) {
147
185
  `User ${r.authorId}`;
148
186
  const text = r.content || "";
149
187
  const truncated = opts.full || text.length <= 200 ? text : text.slice(0, 200) + "…";
150
- replyLines.push(` - ${rAuthor}: ${truncated} (${r.createdAt})`);
188
+ const rChips = formatReactionChips(r);
189
+ const rAttach = formatAttachmentSummary(r);
190
+ replyLines.push(` - [r:${r.id}] ${rAuthor}: ${truncated} (${r.createdAt})${rAttach ? ` ${rAttach}` : ""}${rChips ? ` ${rChips}` : ""}`);
151
191
  }
152
192
  const isReplyPost = post.parentPostId != null;
153
193
  const heading = isReplyPost
154
194
  ? `Reply [r:${post.id}] (private)`
155
195
  : `Post: ${post.title || "(no title)"} (private)`;
196
+ const postChips = formatReactionChips(post);
197
+ const attachmentLines = formatAttachmentLines(post);
156
198
  const output = [
157
199
  heading,
158
200
  `By: ${author} on ${post.createdAt}`,
201
+ ...(postChips ? [`Reactions: ${postChips}`] : []),
159
202
  ...(ancestorLines.length
160
203
  ? ["", `Ancestors (${ancestors.length} items, root first):`, ...ancestorLines]
161
204
  : []),
162
205
  "",
163
206
  post.content || "",
207
+ ...(attachmentLines.length
208
+ ? ["", `Attachments (${attachmentLines.length}):`, ...attachmentLines]
209
+ : []),
164
210
  "",
165
211
  `Replies (${replies.length} items):`,
166
212
  ...replyLines,
@@ -184,7 +230,7 @@ export function registerPersonalCommand(program) {
184
230
  .option("--content <content>", "Post content (markdown supported, use \"-\" for stdin)")
185
231
  .option("--rich-text <richText>", "Rich-text JSON array (mutually exclusive with --content)")
186
232
  .option("--artifact <artifactId>", "Attach an existing artifact to the post (repeatable). Create artifacts with `gobi artifact create`.", (value, prev = []) => [...prev, value], [])
187
- .option("--attach <file>", "Local media file to attach. Repeatable. X-style mix rule: up to 4 photos OR 1 GIF OR 1 video. Size ceilings: 5MB photos / 15MB GIFs / 512MB video.", (value, prev = []) => [...prev, value], [])
233
+ .option("--attach <file>", "Local media or document file to attach. Repeatable. Mix rule: up to 4 photos + up to 4 document files (pdf/md/txt/csv) OR 1 GIF OR 1 video. Size ceilings: 10MB photos / 15MB GIFs / 512MB video / 250MB files.", (value, prev = []) => [...prev, value], [])
188
234
  .option("--repost-post-id <postId>", "Wrap an existing top-level post as the embedded card on this new private post. The referenced post must be visible to you (your own personal-space post, a global-feed post, or a post in a space you're a member of). Reposting someone else's personal-space post returns 404.")
189
235
  .action(async (opts) => {
190
236
  if (!opts.content && !opts.richText) {
@@ -245,7 +291,7 @@ export function registerPersonalCommand(program) {
245
291
  .option("--title <title>", "New title")
246
292
  .option("--content <content>", "New content (markdown supported, use \"-\" for stdin)")
247
293
  .option("--rich-text <richText>", "Rich-text JSON array (mutually exclusive with --content)")
248
- .option("--attach <file>", "Replace the post's media attachments with the given files (existing attachments are removed). Repeatable. X-style mix rule: up to 4 photos OR 1 GIF OR 1 video. Size ceilings: 5MB photos / 15MB GIFs / 512MB video. Omit to leave attachments unchanged.", (value, prev = []) => [...prev, value], [])
294
+ .option("--attach <file>", "Replace the post's media attachments with the given files (existing attachments are removed). Repeatable. Mix rule: up to 4 photos + up to 4 document files (pdf/md/txt/csv) OR 1 GIF OR 1 video. Size ceilings: 10MB photos / 15MB GIFs / 512MB video / 250MB files. Omit to leave attachments unchanged.", (value, prev = []) => [...prev, value], [])
249
295
  .option("--artifact <artifactId>", "Replace the post's artifact attachments with the given artifact(s) (existing artifact attachments are removed). Repeatable. Omit to leave them unchanged. Create artifacts with `gobi artifact create`.", (value, prev = []) => [...prev, value], [])
250
296
  .action(async (postId, opts) => {
251
297
  const wantsAttachChange = !!(opts.attach && opts.attach.length > 0);
@@ -313,7 +359,7 @@ export function registerPersonalCommand(program) {
313
359
  .description("Reply to a personal-space post. The reply inherits the parent's private scope automatically.")
314
360
  .option("--content <content>", "Reply content (markdown supported, use \"-\" for stdin)")
315
361
  .option("--rich-text <richText>", "Rich-text JSON array (mutually exclusive with --content)")
316
- .option("--attach <file>", "Local media file to attach to this reply. Repeatable. X-style mix rule: up to 4 photos OR 1 GIF OR 1 video. Size ceilings: 5MB photos / 15MB GIFs / 512MB video.", (value, prev = []) => [...prev, value], [])
362
+ .option("--attach <file>", "Local media or document file to attach to this reply. Repeatable. Mix rule: up to 4 photos + up to 4 document files (pdf/md/txt/csv) OR 1 GIF OR 1 video. Size ceilings: 10MB photos / 15MB GIFs / 512MB video / 250MB files.", (value, prev = []) => [...prev, value], [])
317
363
  .action(async (postId, opts) => {
318
364
  if (!opts.content && !opts.richText) {
319
365
  throw new Error("Provide either --content or --rich-text.");
@@ -392,4 +438,34 @@ export function registerPersonalCommand(program) {
392
438
  }
393
439
  console.log(`Reply ${replyId} deleted.`);
394
440
  });
441
+ // ── Reactions (react, unreact) ──
442
+ personal
443
+ .command("react <postId> <emoji>")
444
+ .description("Add an emoji reaction to a personal-space post or reply (idempotent). <postId> is the numeric id of a post OR a reply.")
445
+ .action(async (postId, emoji) => {
446
+ const resp = (await apiPut(`/posts/${postId}/reactions`, {
447
+ emoji,
448
+ }));
449
+ const data = unwrapResp(resp);
450
+ if (isJsonMode(personal)) {
451
+ jsonOut(data);
452
+ return;
453
+ }
454
+ const chips = formatReactionChips(data);
455
+ console.log(`Reacted ${emoji} to ${postId}.` + (chips ? `\n Now: ${chips}` : ""));
456
+ });
457
+ personal
458
+ .command("unreact <postId> <emoji>")
459
+ .description("Remove your emoji reaction from a personal-space post or reply. <postId> is the numeric id of a post OR a reply.")
460
+ .action(async (postId, emoji) => {
461
+ const resp = (await apiDelete(`/posts/${postId}/reactions/${encodeURIComponent(emoji)}`));
462
+ const data = unwrapResp(resp);
463
+ if (isJsonMode(personal)) {
464
+ jsonOut(data);
465
+ return;
466
+ }
467
+ const chips = formatReactionChips(data);
468
+ console.log(`Removed ${emoji} reaction from ${postId}.` +
469
+ (chips ? `\n Now: ${chips}` : ""));
470
+ });
395
471
  }