rootr-cli 0.24.0 → 0.25.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.ko.md +33 -14
- package/README.md +32 -12
- package/bin/rootr.js +179 -28
- package/lib/client.js +65 -11
- package/lib/context.js +21 -0
- package/lib/mcp-tools/doc-mail.js +149 -27
- package/lib/mcp-tools/pages.js +2 -1
- package/lib/mcp-tools/public.js +8 -1
- package/lib/mcp.js +43 -5
- package/package.json +1 -1
package/README.ko.md
CHANGED
|
@@ -307,20 +307,30 @@ rootr sign ledger # 워크스페이스 원장 전체 재계산
|
|
|
307
307
|
|
|
308
308
|
### 문서 보내기 · 문서함 (send · mail)
|
|
309
309
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
310
|
+
어떤 노드든(폴더 포함) 이메일 1~20명에게 보낼 수 있습니다. **복사본이 아니라 원본 그 문서**를 그 이메일에
|
|
311
|
+
열어 주는 것입니다 — `--access read`(보기, 기본) · `comment`(댓글) · `write`(편집). 받는 사람은 언제나 최신
|
|
312
|
+
내용을 보고, 편집을 받았으면 원본을 직접 고칩니다. 그 이메일(인증된)의 루터 계정은 우리 워크스페이스 구성원이
|
|
313
|
+
아니어도 문서함에서 그 문서만 엽니다(트리·구성원·검색은 안 보임). 계정이 없으면 메일 링크에서 받은 이메일을
|
|
314
|
+
넣어야 읽기 전용으로 보입니다. 보내려면 그 노드에 구성원으로서 쓰기(WRITE) 권한이 있어야 하고(공유와 같은
|
|
315
|
+
문턱), 내 권한까지만 줄 수 있습니다.
|
|
314
316
|
|
|
315
317
|
```bash
|
|
316
|
-
rootr send "/보고서/3분기 실적" --to partner@example.com,cfo@example.com --message "말씀드린 자료입니다"
|
|
318
|
+
rootr send "/보고서/3분기 실적" --to partner@example.com,cfo@example.com --access comment --message "말씀드린 자료입니다"
|
|
317
319
|
rootr mail # 받은 문서 (안 읽은 수 함께)
|
|
318
|
-
rootr mail sent # 보낸 문서 —
|
|
319
|
-
rootr mail show <id> #
|
|
320
|
-
rootr mail
|
|
320
|
+
rootr mail sent # 보낸 문서 — 권한·읽었는지·메일 실패
|
|
321
|
+
rootr mail show <id> # 지금 내용 (문서는 마크다운 본문, 그 밖은 --json 으로 전부, 폴더 안은 --node <아래 id>)
|
|
322
|
+
rootr mail access <id> write # 받는 사람 권한 바꾸기
|
|
323
|
+
rootr mail revoke <id> # 회수 — 받는 사람이 바로 못 열고 링크는 410
|
|
321
324
|
rootr mail hide <id> # 내 문서함에서만 지우기
|
|
325
|
+
rootr mail webhook add https://example.com/hooks/rootr # 내 계정 웹훅: doc_mail.received / revoked / read
|
|
326
|
+
rootr mail webhook list | rm <id> | test <id>
|
|
322
327
|
```
|
|
323
328
|
|
|
329
|
+
다른 명령을 쓰고 나면 새로 받은 문서가 있을 때 stderr 로 한 줄이 나옵니다(`📬 받은 문서 2건 — … · rootr mail`).
|
|
330
|
+
stdout 과 `--json` 출력은 그대로입니다. 끄려면 `ROOTR_MAIL_NOTICE=0`. MCP 서버는 같은 소식을 아무 도구 응답 끝에
|
|
331
|
+
키마다 한 번 붙입니다. 웹훅 요청은 서명되어 있습니다 — `add` 때 한 번 보이는 비밀값으로
|
|
332
|
+
`X-Rootr-Signature: sha256=HMAC_SHA256(secret, 원문)` 을 확인하세요. 몸통에는 문서 내용이 없습니다(`apiUrl` 을 부르세요).
|
|
333
|
+
|
|
324
334
|
### 제보 보내기 (feedback)
|
|
325
335
|
|
|
326
336
|
```bash
|
|
@@ -368,7 +378,7 @@ rootr memory forget <id> # 폐기(--hard면 행 삭제)
|
|
|
368
378
|
|
|
369
379
|
## MCP 서버로 사용하기
|
|
370
380
|
|
|
371
|
-
`rootr mcp`는 stdio로 MCP(Model Context Protocol) 서버를 구동합니다. 제공 도구
|
|
381
|
+
`rootr mcp`는 stdio로 MCP(Model Context Protocol) 서버를 구동합니다. 제공 도구 202종:
|
|
372
382
|
|
|
373
383
|
노드를 가리키는 인자(`target`, `databaseId`, `spreadsheetId`, `formId`, `crmId`, `trackerId`,
|
|
374
384
|
`recordingId`, `presentationId`, `whiteboardId`, `logId`, `parentId` 등)는 **노드 id와 경로를 모두**
|
|
@@ -651,7 +661,7 @@ rootr db view <표> <뷰> --scale year # 달력 보기를 주·달·해
|
|
|
651
661
|
|
|
652
662
|
| 도구 | 설명 |
|
|
653
663
|
|---|---|
|
|
654
|
-
| `rootr_publish_node` | **아무 노드나** 공개 —
|
|
664
|
+
| `rootr_publish_node` | **아무 노드나** 공개 — 문서·표·폼·시트·화이트보드·덱·페이지, 하위까지. **페이지(PAGE)는 Team 플랜부터**(무료는 403 `PLAN_UPGRADE_REQUIRED`, 하위 포함 시 페이지는 건너뛰고 개수를 알려 줌) |
|
|
655
665
|
| `rootr_set_node_public` | 이미 공개한 것의 설정 변경(하위 포함 여부·검색 노출·만료) |
|
|
656
666
|
| `rootr_node_public_status` | 지금 공개돼 있는지, 주소는 무엇인지 |
|
|
657
667
|
| `rootr_publish_document` | 문서 전용 발행(구형 — 새로 짤 때는 `rootr_publish_node`) |
|
|
@@ -819,10 +829,15 @@ rootr db view <표> <뷰> --scale year # 달력 보기를 주·달·해
|
|
|
819
829
|
|
|
820
830
|
| 도구 | 설명 |
|
|
821
831
|
|---|---|
|
|
822
|
-
| `rootr_send_document` | 노드 하나를 이메일 1~20명에게
|
|
823
|
-
| `rootr_list_doc_mail` | 내 문서함 — `box`: inbox(받은, 기본) / sent(보낸), 안 읽은
|
|
824
|
-
| `rootr_read_doc_mail` | 한 통 열기 —
|
|
825
|
-
| `
|
|
832
|
+
| `rootr_send_document` | 노드 하나를 이메일 1~20명에게 보내기 — 복사본이 아니라 원본을 `access`(read 보기·comment 댓글·write 편집)만큼 열어 줌 |
|
|
833
|
+
| `rootr_list_doc_mail` | 내 문서함 — `box`: inbox(받은, 기본) / sent(보낸), 안 읽은 수·권한·원본 상태 함께 |
|
|
834
|
+
| `rootr_read_doc_mail` | 한 통 열기 — 원본의 지금 내용(읽기 전용)·권한·원본 주소, 폴더면 `node` 로 아래 것, 받은 것은 읽음 처리 |
|
|
835
|
+
| `rootr_set_doc_mail_access` | 보낸 문서의 받는 사람 권한 바꾸기 (보낸 사람만, 내 권한까지) |
|
|
836
|
+
| `rootr_revoke_doc_mail` | 보낸 문서 회수 — 받는 사람이 바로 못 연다 (보낸 사람만) |
|
|
837
|
+
| `rootr_add_doc_mail_webhook` | 내 계정 받은 문서 웹훅 만들기 — doc_mail.received·revoked·read, HMAC 서명, 비밀값은 한 번만 |
|
|
838
|
+
| `rootr_list_doc_mail_webhooks` | 내 받은 문서 웹훅 목록(마지막 전달 상태) |
|
|
839
|
+
| `rootr_remove_doc_mail_webhook` | 내 받은 문서 웹훅 지우기 |
|
|
840
|
+
| `rootr_test_doc_mail_webhook` | 시험 전송(doc_mail.test) 후 전달 결과 |
|
|
826
841
|
|
|
827
842
|
문서함은 워크스페이스가 아니라 **사람(이메일)**에 붙습니다. 사람 없는 워크스페이스 키(원격 커넥터)는
|
|
828
843
|
그 키를 만든 구성원으로 동작합니다.
|
|
@@ -899,6 +914,10 @@ DATABASE·SPREADSHEET·LOG 노드이고, 칸은 **이름**으로 가리킵니다
|
|
|
899
914
|
페이지에서 조용히 빠집니다(일부러 그렇습니다).
|
|
900
915
|
|
|
901
916
|
공개는 여느 노드와 같습니다: `POST /v1/nodes/:id/publish` → `rootr.io/p/{slug}`.
|
|
917
|
+
단 **페이지를 웹에 게시하는 것은 Team 플랜부터**입니다 — 무료 워크스페이스는 403 `PLAN_UPGRADE_REQUIRED`
|
|
918
|
+
(아무것도 바뀌지 않음). 무료에서도 페이지를 만들고 편집하고 워크스페이스 안에서 보는 것은 그대로 됩니다.
|
|
919
|
+
Team 에서 공개한 뒤 무료로 내려가면 공개 설정은 남아 있지만 방문자에게는 "게시되어 있지 않습니다" 화면이
|
|
920
|
+
나가고, 다시 Team 으로 올리면 같은 주소가 다시 발행하지 않아도 열립니다.
|
|
902
921
|
|
|
903
922
|
#### 프레젠테이션
|
|
904
923
|
|
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ citations back to the exact paragraph.
|
|
|
11
11
|
*(한국어 문서: [README.ko.md](https://github.com/gwmage/rootr-cli/blob/main/README.ko.md))*
|
|
12
12
|
|
|
13
13
|
- `rootr` CLI — documents, databases, forms, signed documents, publishing, permissions, memory
|
|
14
|
-
- `rootr mcp` — stdio MCP server, **
|
|
14
|
+
- `rootr mcp` — stdio MCP server, **202 tools** (see [MCP tools](#mcp-tools))
|
|
15
15
|
|
|
16
16
|
Plain Node.js ESM, no build step. Node 18+ (uses the built-in `fetch`).
|
|
17
17
|
|
|
@@ -269,7 +269,15 @@ rootr publish /handbook --status # is it public, at what URL, chi
|
|
|
269
269
|
rootr publish /projects/tasks --layout board --skin paper # 9 layouts × 6 skins
|
|
270
270
|
rootr publish /handbook/policy.md --comments --expires 2026-12-31T00:00:00Z
|
|
271
271
|
rootr publish /handbook --off
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
Every node type publishes on the Free plan **except PAGE** (the block-built web page): publishing a
|
|
275
|
+
page to the web needs the **Team plan**. On Free, `rootr publish <page>` fails with
|
|
276
|
+
`PLAN_UPGRADE_REQUIRED` and nothing changes; `--children` skips pages underneath and says how many.
|
|
277
|
+
A page published on Team stays saved if the workspace later drops to Free — visitors see a
|
|
278
|
+
"not published" screen until it is upgraded again, and `--status` says so.
|
|
272
279
|
|
|
280
|
+
```bash
|
|
273
281
|
rootr access /handbook --private # or --public / --inherit
|
|
274
282
|
rootr access /handbook --grant <userId> --level WRITE
|
|
275
283
|
rootr access /handbook --revoke <userId>
|
|
@@ -329,21 +337,32 @@ regulations require both the original and the trace of the change to remain visi
|
|
|
329
337
|
|
|
330
338
|
### Sending a document to someone (mailbox)
|
|
331
339
|
|
|
332
|
-
Any node
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
340
|
+
Any node — a folder too — can be sent to 1–20 email addresses. It is **not a copy**: the address
|
|
341
|
+
gets access to the **original** at `--access read` (view, default) · `comment` · `write` (edit), so
|
|
342
|
+
they always see the latest version and, with `write`, edit the original itself. A Rootr account on
|
|
343
|
+
that (verified) email opens it from its inbox even if it is not in your workspace — it sees that
|
|
344
|
+
node only, never your tree, members or search. Without an account the email link asks for the
|
|
345
|
+
address and then shows it read-only. Sending needs WRITE on the node as a member (the same bar as
|
|
346
|
+
sharing); you can give at most your own level.
|
|
337
347
|
|
|
338
348
|
```bash
|
|
339
|
-
rootr send "/reports/Q3 numbers" --to partner@example.com,cfo@example.com --message "As discussed"
|
|
349
|
+
rootr send "/reports/Q3 numbers" --to partner@example.com,cfo@example.com --access comment --message "As discussed"
|
|
340
350
|
rootr mail # received (with the unread count)
|
|
341
|
-
rootr mail sent # what you sent — read or not, email failures
|
|
342
|
-
rootr mail show <id> #
|
|
343
|
-
rootr mail
|
|
351
|
+
rootr mail sent # what you sent — access, read or not, email failures
|
|
352
|
+
rootr mail show <id> # the current content (a document prints its markdown; --json for any type; --node <child> inside a folder)
|
|
353
|
+
rootr mail access <id> write # change what the recipient can do
|
|
354
|
+
rootr mail revoke <id> # withdraw: the recipient loses access at once, the link answers 410
|
|
344
355
|
rootr mail hide <id> # remove from your own mailbox only
|
|
356
|
+
rootr mail webhook add https://example.com/hooks/rootr # your own webhook: doc_mail.received / revoked / read
|
|
357
|
+
rootr mail webhook list | rm <id> | test <id>
|
|
345
358
|
```
|
|
346
359
|
|
|
360
|
+
After any other command, newly received documents print one line to stderr
|
|
361
|
+
(`📬 2 new documents received — … · rootr mail`); stdout and `--json` stay clean. Turn it off with
|
|
362
|
+
`ROOTR_MAIL_NOTICE=0`. The MCP server appends the same news to the end of any tool result, once
|
|
363
|
+
per key. Webhook requests are signed: verify `X-Rootr-Signature: sha256=HMAC_SHA256(secret, raw body)`
|
|
364
|
+
with the secret shown once at `add`. The body never contains the document — GET its `apiUrl`.
|
|
365
|
+
|
|
347
366
|
### Team memory
|
|
348
367
|
|
|
349
368
|
```bash
|
|
@@ -425,7 +444,7 @@ is refused and says when the grace was used.
|
|
|
425
444
|
|
|
426
445
|
## MCP tools
|
|
427
446
|
|
|
428
|
-
`rootr mcp` speaks MCP over stdio.
|
|
447
|
+
`rootr mcp` speaks MCP over stdio. 202 tools:
|
|
429
448
|
|
|
430
449
|
| Group | Count | Tools |
|
|
431
450
|
|---|---|---|
|
|
@@ -543,7 +562,8 @@ takes `width: half | third | twoThirds` so a page becomes a **live dashboard**
|
|
|
543
562
|
published separately. Tables and forms that live under a PRIVATE subtree are silently dropped
|
|
544
563
|
from a public page, on purpose.
|
|
545
564
|
|
|
546
|
-
Publishing works like any other node: `POST /v1/nodes/:id/publish` → `rootr.io/p/{slug}
|
|
565
|
+
Publishing works like any other node: `POST /v1/nodes/:id/publish` → `rootr.io/p/{slug}` —
|
|
566
|
+
**Team plan and up** for pages (Free → `403 PLAN_UPGRADE_REQUIRED`; pages still work inside the workspace).
|
|
547
567
|
|
|
548
568
|
### Embedding a table inside a document
|
|
549
569
|
|
package/bin/rootr.js
CHANGED
|
@@ -6,6 +6,7 @@ import { dirname, join, basename, extname } from 'node:path';
|
|
|
6
6
|
import { loadConfig, saveConfig, keyForWorkspace, maskKey, upsertKey, CONFIG_PATH, DEFAULT_BASE_URL } from '../lib/config.js';
|
|
7
7
|
import { candidateClients, rememberWorkspace, resolveWorkspaceSwitch, surveyWorkspaces } from '../lib/credentials.js';
|
|
8
8
|
import { RootrClient, RootrApiError, imageMarkdown } from '../lib/client.js';
|
|
9
|
+
import { setGlobalDocMailSink } from '../lib/context.js';
|
|
9
10
|
import { resolveTarget, requireResolved, resolveMoveDestination } from '../lib/resolve.js';
|
|
10
11
|
import { t as tr, lang } from '../lib/i18n.js';
|
|
11
12
|
|
|
@@ -95,11 +96,16 @@ const USAGE_KO = `Rootr CLI v${pkg.version}
|
|
|
95
96
|
rootr sign list [--pending] [--status <S>] [--mode <M>] [--json] 서명문서 목록 (--pending: 내 차례)
|
|
96
97
|
rootr sign bulk <id…> --code <6자리> 여러 건 한 번에 올리기+서명 (코드 하나로, 초안 포함)
|
|
97
98
|
rootr sign ledger [--json] 워크스페이스 원장(해시 체인) 전체 검증
|
|
98
|
-
rootr send <path|id> --to a@x.com[,b@y.com] [--message "..."]
|
|
99
|
+
rootr send <path|id> --to a@x.com[,b@y.com] [--access read|comment|write] [--message "..."]
|
|
100
|
+
문서(어떤 타입이든)를 이메일로 보내기 — 복사본이 아니라 원본을 그 이메일에 열어 줌(기본 보기)
|
|
99
101
|
rootr mail [inbox|sent] [--limit N] [--q <찾기>] [--json] 문서함 — 받은 문서(기본)·보낸 문서
|
|
100
|
-
rootr mail show <id> [--json]
|
|
101
|
-
rootr mail
|
|
102
|
-
rootr mail
|
|
102
|
+
rootr mail show <id> [--node <아래 노드 id>] [--json] 받은/보낸 문서의 지금 내용(문서면 본문, 그 밖은 요약) — 받은 것은 읽음 처리
|
|
103
|
+
rootr mail access <id> read|comment|write 보낸 문서의 받는 사람 권한 바꾸기
|
|
104
|
+
rootr mail revoke <id> 보낸 문서 회수 — 받는 사람이 원본을 더 못 연다
|
|
105
|
+
rootr mail hide <id> [--box inbox|sent] 내 문서함에서만 지우기(받은 것을 지우면 그 계정으로는 더 안 열린다)
|
|
106
|
+
rootr mail webhook add <url> [--events …] | list | rm <id> | test <id>
|
|
107
|
+
받은 문서·회수·읽음을 내 주소로 알려 주는 웹훅(내 계정 것, HMAC 서명)
|
|
108
|
+
※ 다른 명령 뒤 새로 받은 문서가 있으면 stderr 로 한 줄 알림(끄기: ROOTR_MAIL_NOTICE=0)
|
|
103
109
|
rootr config [--api-key <k>] [--workspace <w>] [--base-url <u>] 설정 조회/저장
|
|
104
110
|
rootr config --add-key <k> [--label <name>] [--workspace <w>] 워크스페이스별 키 추가 등록
|
|
105
111
|
rootr feedback "<무엇이 안 되는지>" [--kind bug|friction|idea|praise] [--email <주소>]
|
|
@@ -206,11 +212,13 @@ usage:
|
|
|
206
212
|
rootr sign list [--pending] [--status <S>] [--mode <M>] [--json] list signed documents (--pending: waiting on you)
|
|
207
213
|
rootr sign bulk <id…> --code <6 digits> submit+sign many at once (one code, drafts included)
|
|
208
214
|
rootr sign ledger [--json] verify the whole workspace ledger (hash chain)
|
|
209
|
-
rootr send <path|id> --to a@x.com[,b@y.com] [--message "..."]
|
|
215
|
+
rootr send <path|id> --to a@x.com[,b@y.com] [--access read|comment|write] [--message "..."]
|
|
216
|
+
give email addresses access to any node — the original itself, not a copy (default: read)
|
|
210
217
|
rootr mail [inbox|sent] [--limit N] [--q <text>] [--json] your document mailbox — received (default) or sent
|
|
211
|
-
rootr mail show <id> [--json]
|
|
212
|
-
rootr mail
|
|
213
|
-
rootr mail
|
|
218
|
+
rootr mail show <id> [--node <child id>] [--json] the document's current content (body for docs, a summary otherwise); marks received ones read
|
|
219
|
+
rootr mail access <id> read|comment|write change what the recipient of a document you sent can do
|
|
220
|
+
rootr mail revoke <id> withdraw a document you sent — the recipient can no longer open the original
|
|
221
|
+
rootr mail hide <id> [--box inbox|sent] remove from your own mailbox (a received one then stops opening for your account)
|
|
214
222
|
rootr config [--api-key <k>] [--workspace <w>] [--base-url <u>] read/save configuration
|
|
215
223
|
rootr config --add-key <k> [--label <name>] [--workspace <w>] register an extra per-workspace key
|
|
216
224
|
rootr feedback "<what is not working>" [--kind bug|friction|idea|praise] [--email <address>]
|
|
@@ -1749,6 +1757,18 @@ async function cmdPublish(args) {
|
|
|
1749
1757
|
console.log(tr('이 종류는 공개할 수 없습니다(폴더).', `This node type cannot be published (folder).`));
|
|
1750
1758
|
return;
|
|
1751
1759
|
}
|
|
1760
|
+
if (st.closed === 'PAGE_PUBLISH_REQUIRES_TEAM') {
|
|
1761
|
+
// PAGE-PUBLISH-TEAM — 공개는 켜져 있지만 워크스페이스가 무료라 방문자에게 안 보인다. Team 으로 올리면 같은 주소가 다시 열린다.
|
|
1762
|
+
console.log(tr(`공개가 켜져 있지만 지금은 방문자에게 보이지 않습니다(주소: /p/${st.slug}).`, `Publishing is on, but visitors cannot see it right now (address: /p/${st.slug}).`));
|
|
1763
|
+
console.log(
|
|
1764
|
+
' ' +
|
|
1765
|
+
tr(
|
|
1766
|
+
'페이지를 웹에 게시하는 기능은 Team 플랜에서만 됩니다. Team 으로 올리면 다시 발행하지 않아도 같은 주소로 열립니다.',
|
|
1767
|
+
'Publishing pages to the web needs the Team plan. Upgrade to Team and the same address opens again — no need to publish again.',
|
|
1768
|
+
),
|
|
1769
|
+
);
|
|
1770
|
+
return;
|
|
1771
|
+
}
|
|
1752
1772
|
if (st.closed) {
|
|
1753
1773
|
// 공개를 끈 것과 다르다 — 설정은 켜져 있고, "본인이 만든 행만" 표의 행에서 나온 것이라 지금 닫혀 있다(서버 응답의 closed)
|
|
1754
1774
|
console.log(tr(`공개가 켜져 있지만 지금은 닫혀 있습니다(주소: /p/${st.slug}).`, `Publishing is on, but it is closed right now (address: /p/${st.slug}).`));
|
|
@@ -1833,6 +1853,15 @@ async function cmdPublish(args) {
|
|
|
1833
1853
|
});
|
|
1834
1854
|
console.log(tr(`공개했습니다: /p/${res.slug}`, `Published: /p/${res.slug}`));
|
|
1835
1855
|
if (args.children) console.log(tr(' 이 문서 아래 전부(손자·증손자 포함)도 각자 주소로 공개됩니다.', ` Everything under this document (grandchildren included) gets its own public address.`));
|
|
1856
|
+
if (res.skippedPagesRequireTeam) {
|
|
1857
|
+
// PAGE-PUBLISH-TEAM — 무료 워크스페이스에서는 아래의 페이지(PAGE)는 따라 나가지 않는다.
|
|
1858
|
+
console.log(
|
|
1859
|
+
tr(
|
|
1860
|
+
` 단, 아래의 페이지 ${res.skippedPagesRequireTeam}개는 공개하지 않았습니다 — 페이지를 웹에 게시하는 기능은 Team 플랜부터입니다.`,
|
|
1861
|
+
` ${res.skippedPagesRequireTeam} page(s) underneath were NOT published — publishing pages to the web needs the Team plan.`,
|
|
1862
|
+
),
|
|
1863
|
+
);
|
|
1864
|
+
}
|
|
1836
1865
|
if (Object.keys(design).length) {
|
|
1837
1866
|
console.log(tr(` 보이는 방식: ${res.design?.layout ?? 'table'} / ${res.design?.skin ?? 'clean'}`, ` looks like: ${res.design?.layout ?? 'table'} / ${res.design?.skin ?? 'clean'}`));
|
|
1838
1867
|
}
|
|
@@ -3074,7 +3103,7 @@ async function cmdSign(args) {
|
|
|
3074
3103
|
// ---- doc mail (DOC-MAIL — 문서 송수신) ------------------------------------
|
|
3075
3104
|
|
|
3076
3105
|
const MAIL_USAGE =
|
|
3077
|
-
usage(`rootr mail [inbox|sent] | show <id> | revoke <id> | hide <id
|
|
3106
|
+
usage(`rootr mail [inbox|sent] | show <id> | access <id> <read|comment|write> | revoke <id> | hide <id> | webhook …\n`) +
|
|
3078
3107
|
tr(' (자세한 건 rootr --help)', ` (see rootr --help)`);
|
|
3079
3108
|
|
|
3080
3109
|
const NODE_TYPE_KO = {
|
|
@@ -3092,23 +3121,34 @@ const NODE_TYPE_KO = {
|
|
|
3092
3121
|
SIGNED: tr('서명문서', 'signed document'),
|
|
3093
3122
|
};
|
|
3094
3123
|
|
|
3124
|
+
const ACCESS_LABEL = {
|
|
3125
|
+
READ: tr('보기', 'view'),
|
|
3126
|
+
COMMENT: tr('댓글', 'comment'),
|
|
3127
|
+
WRITE: tr('편집', 'edit'),
|
|
3128
|
+
};
|
|
3129
|
+
|
|
3095
3130
|
async function cmdSend(args) {
|
|
3096
3131
|
const target = args._[0];
|
|
3097
3132
|
const toArg = typeof args.to === 'string' ? args.to : '';
|
|
3098
3133
|
const to = toArg.split(/[,;\s]+/).map((s) => s.trim()).filter(Boolean);
|
|
3099
|
-
|
|
3100
|
-
|
|
3134
|
+
const SEND_USAGE = `rootr send <path|id> --to a@x.com[,b@y.com] [--access read|comment|write] [--message "..."]`;
|
|
3135
|
+
if (!target || !to.length) throw new Error(usage(SEND_USAGE));
|
|
3136
|
+
const access = typeof args.access === 'string' ? args.access.trim().toLowerCase() : undefined;
|
|
3137
|
+
if (access && !['read', 'comment', 'write', 'view', 'edit'].includes(access)) {
|
|
3138
|
+
throw new Error(tr(`--access 는 read(보기)·comment(댓글)·write(편집) 중 하나입니다.`, `--access must be read, comment or write.`));
|
|
3101
3139
|
}
|
|
3102
3140
|
const client = makeClient();
|
|
3103
3141
|
const nodeId = requireResolved(await resolveTarget(client, target));
|
|
3104
|
-
const out = await client.sendNode(nodeId, { to, message: typeof args.message === 'string' ? args.message : undefined });
|
|
3142
|
+
const out = await client.sendNode(nodeId, { to, access, message: typeof args.message === 'string' ? args.message : undefined });
|
|
3105
3143
|
if (args.json) return console.log(JSON.stringify(out, null, 2));
|
|
3106
3144
|
const items = out.items ?? [];
|
|
3107
|
-
|
|
3145
|
+
const lvl = ACCESS_LABEL[out.access] ?? out.access;
|
|
3146
|
+
console.log(tr(`${items.length}명에게 보냈습니다 — 원본을 ${lvl} 권한으로 열어 줌 (묶음 ${out.batchId})`, `Sent to ${items.length} ${items.length === 1 ? 'person' : 'people'} — they can ${lvl} the original (batch ${out.batchId})`));
|
|
3108
3147
|
for (const it of items) {
|
|
3109
3148
|
console.log(
|
|
3110
3149
|
` ${it.to}\t${it.id}\t` +
|
|
3111
|
-
(it.emailed ? tr('메일 보냄', 'emailed') : tr(`메일 실패${it.error ? `: ${it.error}` : ''} —
|
|
3150
|
+
(it.emailed ? tr('메일 보냄', 'emailed') : tr(`메일 실패${it.error ? `: ${it.error}` : ''} — 권한은 그대로 있습니다(받은 문서함)`, `email failed${it.error ? `: ${it.error}` : ''} — the access still stands (their inbox)`)) +
|
|
3151
|
+
(it.resent ? tr(' (다시 보냄 — 권한·메시지만 바꿈)', ' (re-sent — access/message updated)') : ''),
|
|
3112
3152
|
);
|
|
3113
3153
|
}
|
|
3114
3154
|
}
|
|
@@ -3132,34 +3172,41 @@ async function cmdMail(args) {
|
|
|
3132
3172
|
const type = NODE_TYPE_KO[m.nodeType] ?? m.nodeType;
|
|
3133
3173
|
const state = m.revoked
|
|
3134
3174
|
? tr('[회수됨]', '[withdrawn]')
|
|
3175
|
+
: m.originalDeleted
|
|
3176
|
+
? tr('[원본 삭제됨]', '[original deleted]')
|
|
3135
3177
|
: box === 'inbox'
|
|
3136
3178
|
? m.readAt ? '' : tr('[새 문서]', '[new]')
|
|
3137
3179
|
: m.readAt ? tr(`[읽음 ${mailWhen(m.readAt)}]`, `[read ${mailWhen(m.readAt)}]`) : tr('[안 읽음]', '[unread]');
|
|
3138
3180
|
const who = box === 'inbox' ? `${m.senderName} <${m.senderEmail}>` : `→ ${m.recipientEmail}`;
|
|
3139
3181
|
const mailFail = box === 'sent' && m.emailed === false ? tr(' [메일 실패]', ' [email failed]') : '';
|
|
3140
|
-
|
|
3182
|
+
const lvl = ACCESS_LABEL[m.access] ? ` (${ACCESS_LABEL[m.access]})` : '';
|
|
3183
|
+
console.log(`${m.id}\t${mailWhen(m.createdAt)}\t${type}${lvl}\t${m.title}\t${who}${state ? ` ${state}` : ''}${mailFail}`);
|
|
3141
3184
|
if (m.message) console.log(` ${String(m.message).split('\n')[0].slice(0, 100)}`);
|
|
3142
3185
|
}
|
|
3143
3186
|
if (out.nextCursor) console.log(tr(`\n다음 쪽: rootr mail ${box} --cursor ${out.nextCursor}`, `\nNext page: rootr mail ${box} --cursor ${out.nextCursor}`));
|
|
3144
3187
|
return;
|
|
3145
3188
|
}
|
|
3189
|
+
if (sub === 'webhook' || sub === 'webhooks') return cmdMailWebhook(client, args);
|
|
3146
3190
|
const id = args._[1];
|
|
3147
3191
|
if (!id) throw new Error(MAIL_USAGE);
|
|
3148
3192
|
switch (sub) {
|
|
3149
3193
|
case 'show':
|
|
3150
3194
|
case 'read': {
|
|
3151
|
-
const m = await client.getDocMail(id);
|
|
3195
|
+
const m = await client.getDocMail(id, { node: typeof args.node === 'string' ? args.node : undefined });
|
|
3152
3196
|
if (args.json) return console.log(JSON.stringify(m, null, 2));
|
|
3153
|
-
console.log(`${m.title} (${NODE_TYPE_KO[m.nodeType] ?? m.nodeType})`);
|
|
3197
|
+
console.log(`${m.title} (${NODE_TYPE_KO[m.nodeType] ?? m.nodeType} · ${tr('권한', 'access')} ${ACCESS_LABEL[m.access] ?? m.access})`);
|
|
3154
3198
|
console.log(tr(`보낸 사람: ${m.senderName} <${m.senderEmail}> · ${m.workspaceName}`, `From: ${m.senderName} <${m.senderEmail}> · ${m.workspaceName}`));
|
|
3155
3199
|
console.log(tr(`받는 사람: ${m.recipientEmail} · 보낸 때 ${mailWhen(m.createdAt)}${m.readAt ? ` · 읽음 ${mailWhen(m.readAt)}` : ''}`, `To: ${m.recipientEmail} · sent ${mailWhen(m.createdAt)}${m.readAt ? ` · read ${mailWhen(m.readAt)}` : ''}`));
|
|
3156
3200
|
if (m.message) console.log(tr(`메시지: ${m.message}`, `Message: ${m.message}`));
|
|
3157
|
-
if (m.revoked &&
|
|
3158
|
-
if (m.
|
|
3201
|
+
if (m.revoked && m.viewer !== 'sender') return console.log(tr('\n보낸 사람이 이 문서를 회수했습니다.', '\nThe sender withdrew this document.'));
|
|
3202
|
+
if (m.originalDeleted) return console.log(tr('\n원본이 삭제되었습니다.', '\nThe original has been deleted.'));
|
|
3203
|
+
if (m.revoked) console.log(tr('(회수함 — 받는 사람은 더 못 엽니다)', '(withdrawn — the recipient can no longer open it)'));
|
|
3204
|
+
if (m.openUrl) console.log(tr(`원본 열기(편집기): ${m.openUrl}`, `Open the original (editor): ${m.openUrl}`));
|
|
3205
|
+
if (m.picked) console.log(tr(`보고 있는 것: ${m.picked.name} (${m.picked.id})`, `Showing: ${m.picked.name} (${m.picked.id})`));
|
|
3159
3206
|
const p = m.payload ?? {};
|
|
3160
3207
|
console.log('');
|
|
3161
|
-
if (p.kind === 'doc')
|
|
3162
|
-
if (p.kind === 'database') {
|
|
3208
|
+
if (typeof m.contentMd === 'string' || p.kind === 'doc') console.log(m.contentMd ?? p.content ?? '');
|
|
3209
|
+
else if (p.kind === 'database') {
|
|
3163
3210
|
const props = p.properties ?? [];
|
|
3164
3211
|
const rows = p.rows ?? [];
|
|
3165
3212
|
console.log(props.map((c) => c.name).join('\t'));
|
|
@@ -3169,30 +3216,107 @@ async function cmdMail(args) {
|
|
|
3169
3216
|
return v === null || v === undefined ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v);
|
|
3170
3217
|
}).join('\t'));
|
|
3171
3218
|
}
|
|
3172
|
-
if (p.truncated) console.log(tr(`… ${p.truncated.total}행 중 ${p.truncated.shown}행만
|
|
3173
|
-
|
|
3219
|
+
if (p.truncated) console.log(tr(`… ${p.truncated.total}행 중 ${p.truncated.shown}행만 보입니다`, `… showing ${p.truncated.shown} of ${p.truncated.total} rows`));
|
|
3220
|
+
if (p.rowsHidden === 'booking') console.log(tr('(예약을 받는 표라 행은 안 보입니다)', '(a booking table — rows are not shown)'));
|
|
3221
|
+
} else if (p.kind) {
|
|
3222
|
+
const keys = Object.keys(p).filter((k) => k !== 'kind');
|
|
3223
|
+
console.log(tr(`[${p.kind}] 담긴 것: ${keys.join(', ')} — 전부 보려면 --json`, `[${p.kind}] contains: ${keys.join(', ')} — use --json for everything`));
|
|
3224
|
+
}
|
|
3225
|
+
if (m.children?.length) {
|
|
3226
|
+
console.log(tr('\n안에 든 것 (rootr mail show <id> --node <아래 id>):', '\nInside (rootr mail show <id> --node <child id>):'));
|
|
3227
|
+
for (const c of m.children) console.log(` ${c.id}\t${NODE_TYPE_KO[c.type] ?? c.type}\t${c.name}`);
|
|
3174
3228
|
}
|
|
3175
|
-
const keys = Object.keys(p).filter((k) => k !== 'kind');
|
|
3176
|
-
console.log(tr(`[${p.kind}] 담긴 것: ${keys.join(', ')} — 전부 보려면 --json`, `[${p.kind}] contains: ${keys.join(', ')} — use --json for everything`));
|
|
3177
3229
|
return;
|
|
3178
3230
|
}
|
|
3231
|
+
case 'access': {
|
|
3232
|
+
const level = args._[2];
|
|
3233
|
+
if (!level || !['read', 'comment', 'write', 'view', 'edit'].includes(String(level).toLowerCase())) throw new Error(MAIL_USAGE);
|
|
3234
|
+
const out = await client.setDocMailAccess(id, String(level).toLowerCase());
|
|
3235
|
+
if (args.json) return console.log(JSON.stringify(out, null, 2));
|
|
3236
|
+
return console.log(tr(`받는 사람 권한을 ${ACCESS_LABEL[out.access] ?? out.access}(으)로 바꿨습니다.`, `The recipient can now ${ACCESS_LABEL[out.access] ?? out.access}.`));
|
|
3237
|
+
}
|
|
3179
3238
|
case 'revoke': {
|
|
3180
3239
|
const out = await client.revokeDocMail(id);
|
|
3181
3240
|
if (args.json) return console.log(JSON.stringify(out, null, 2));
|
|
3182
|
-
return console.log(tr(`회수했습니다 (${mailWhen(out.revokedAt)}) — 받는
|
|
3241
|
+
return console.log(tr(`회수했습니다 (${mailWhen(out.revokedAt)}) — 받는 사람이 원본을 더 못 엽니다.`, `Withdrawn (${mailWhen(out.revokedAt)}) — the recipient can no longer open the original.`));
|
|
3183
3242
|
}
|
|
3184
3243
|
case 'hide':
|
|
3185
3244
|
case 'rm': {
|
|
3186
3245
|
const box = args.box === 'inbox' || args.box === 'sent' ? args.box : undefined;
|
|
3187
3246
|
const out = await client.hideDocMail(id, { box });
|
|
3188
3247
|
if (args.json) return console.log(JSON.stringify(out, null, 2));
|
|
3189
|
-
return console.log(tr(`내 문서함에서 지웠습니다 (${out.hidden}). 상대 화면은
|
|
3248
|
+
return console.log(tr(`내 문서함에서 지웠습니다 (${out.hidden}). 상대 화면은 그대로입니다(받은 것을 지웠으면 내 계정으로는 더 안 열립니다).`, `Removed from your mailbox (${out.hidden}). The other side still has it (a removed received document no longer opens for your account).`));
|
|
3190
3249
|
}
|
|
3191
3250
|
default:
|
|
3192
3251
|
throw new Error(MAIL_USAGE);
|
|
3193
3252
|
}
|
|
3194
3253
|
}
|
|
3195
3254
|
|
|
3255
|
+
const WEBHOOK_USAGE = usage(
|
|
3256
|
+
tr(
|
|
3257
|
+
`rootr mail webhook add <url> [--events doc_mail.received,doc_mail.revoked,doc_mail.read] [--name <이름>]\n` +
|
|
3258
|
+
` rootr mail webhook list | rm <id> | test <id>`,
|
|
3259
|
+
`rootr mail webhook add <url> [--events doc_mail.received,doc_mail.revoked,doc_mail.read] [--name <name>]\n` +
|
|
3260
|
+
` rootr mail webhook list | rm <id> | test <id>`,
|
|
3261
|
+
),
|
|
3262
|
+
);
|
|
3263
|
+
|
|
3264
|
+
/** DOC-MAIL-HOOK — 내(계정) 받은 문서 웹훅. 워크스페이스 웹훅과 따로 — 받은 문서는 사람의 것이다. */
|
|
3265
|
+
async function cmdMailWebhook(client, args) {
|
|
3266
|
+
const op = args._[1];
|
|
3267
|
+
const arg = args._[2];
|
|
3268
|
+
switch (op) {
|
|
3269
|
+
case 'add': {
|
|
3270
|
+
if (!arg) throw new Error(WEBHOOK_USAGE);
|
|
3271
|
+
const events = typeof args.events === 'string' ? args.events.split(',').map((x) => x.trim()).filter(Boolean) : undefined;
|
|
3272
|
+
const out = await client.addDocMailWebhook({ url: arg, events, name: typeof args.name === 'string' ? args.name : undefined });
|
|
3273
|
+
if (args.json) return console.log(JSON.stringify(out, null, 2));
|
|
3274
|
+
console.log(tr(`웹훅을 만들었습니다: ${out.id}`, `Webhook created: ${out.id}`));
|
|
3275
|
+
console.log(` url: ${out.url}`);
|
|
3276
|
+
console.log(` events: ${out.events.join(', ')}`);
|
|
3277
|
+
console.log(tr(` 비밀값(지금 한 번만 보입니다 — 서명 X-Rootr-Signature 검증용): ${out.secret}`, ` secret (shown once — verify X-Rootr-Signature with it): ${out.secret}`));
|
|
3278
|
+
return;
|
|
3279
|
+
}
|
|
3280
|
+
case 'list':
|
|
3281
|
+
case 'ls':
|
|
3282
|
+
case undefined: {
|
|
3283
|
+
const out = await client.listDocMailWebhooks();
|
|
3284
|
+
if (args.json) return console.log(JSON.stringify(out, null, 2));
|
|
3285
|
+
if (!out.items?.length) return console.log(tr('웹훅이 없습니다. rootr mail webhook add <url>', 'No webhooks. rootr mail webhook add <url>'));
|
|
3286
|
+
for (const h of out.items) {
|
|
3287
|
+
const last = h.lastDelivery ? ` · ${h.lastDelivery.event} ${h.lastDelivery.status}${h.lastDelivery.responseCode ? ` ${h.lastDelivery.responseCode}` : ''}` : '';
|
|
3288
|
+
console.log(`${h.id}\t${h.active ? 'on' : 'off'}\t${h.url}\t${h.events.join(',')}${last}`);
|
|
3289
|
+
}
|
|
3290
|
+
return;
|
|
3291
|
+
}
|
|
3292
|
+
case 'rm':
|
|
3293
|
+
case 'remove':
|
|
3294
|
+
case 'delete': {
|
|
3295
|
+
if (!arg) throw new Error(WEBHOOK_USAGE);
|
|
3296
|
+
const out = await client.removeDocMailWebhook(arg);
|
|
3297
|
+
if (args.json) return console.log(JSON.stringify(out, null, 2));
|
|
3298
|
+
return console.log(tr(`웹훅을 지웠습니다: ${arg}`, `Webhook removed: ${arg}`));
|
|
3299
|
+
}
|
|
3300
|
+
case 'test': {
|
|
3301
|
+
if (!arg) throw new Error(WEBHOOK_USAGE);
|
|
3302
|
+
const queued = await client.testDocMailWebhook(arg);
|
|
3303
|
+
let d = null;
|
|
3304
|
+
for (let i = 0; i < 10; i++) {
|
|
3305
|
+
await new Promise((r) => setTimeout(r, 750));
|
|
3306
|
+
d = (await client.docMailWebhookDeliveries(arg)).items?.find((x) => x.id === queued.deliveryId) ?? null;
|
|
3307
|
+
if (d && d.status !== 'PENDING') break;
|
|
3308
|
+
}
|
|
3309
|
+
if (args.json) return console.log(JSON.stringify({ ...queued, delivery: d }, null, 2));
|
|
3310
|
+
if (!d || d.status === 'PENDING') return console.log(tr(`시험 전송을 큐에 넣었습니다(${queued.deliveryId}) — 아직 결과가 없습니다.`, `Test queued (${queued.deliveryId}) — no result yet.`));
|
|
3311
|
+
if (d.status === 'SUCCESS') return console.log(tr(`시험 전송 성공 (HTTP ${d.responseCode})`, `Test delivered (HTTP ${d.responseCode})`));
|
|
3312
|
+
process.exitCode = 1;
|
|
3313
|
+
return console.log(tr(`시험 전송 실패: ${d.error ?? d.responseCode} — 다시 시도합니다(최대 5번).`, `Test failed: ${d.error ?? d.responseCode} — it will be retried (up to 5 times).`));
|
|
3314
|
+
}
|
|
3315
|
+
default:
|
|
3316
|
+
throw new Error(WEBHOOK_USAGE);
|
|
3317
|
+
}
|
|
3318
|
+
}
|
|
3319
|
+
|
|
3196
3320
|
async function main() {
|
|
3197
3321
|
const argv = process.argv.slice(2);
|
|
3198
3322
|
const args = parseArgs(argv);
|
|
@@ -3277,4 +3401,31 @@ async function main() {
|
|
|
3277
3401
|
}
|
|
3278
3402
|
}
|
|
3279
3403
|
|
|
3280
|
-
|
|
3404
|
+
/**
|
|
3405
|
+
* DOC-MAIL-NOTIFY — 아무 명령이든 끝난 뒤, 그 명령이 부른 API 응답 머리줄에 **새로 받은 문서**가 있었으면 stderr 로 한 줄
|
|
3406
|
+
* (stdout·--json 출력은 더럽히지 않는다). 따로 묻는 호출은 없다. 서버가 키마다 한 번만 알려 준다(channel 'cli').
|
|
3407
|
+
* `rootr mail`·`rootr mcp` 에서는 안 낸다(이미 문서함을 보고 있다 / 표준 입출력이 MCP 통로다).
|
|
3408
|
+
* 끄기: `ROOTR_MAIL_NOTICE=0`.
|
|
3409
|
+
*/
|
|
3410
|
+
const docMailSink = { channel: 'cli', items: [] };
|
|
3411
|
+
const firstArg = process.argv.slice(2).find((a) => !a.startsWith('-'));
|
|
3412
|
+
const docMailNoticeOn = process.env.ROOTR_MAIL_NOTICE !== '0' && firstArg !== 'mail' && firstArg !== 'mcp';
|
|
3413
|
+
if (docMailNoticeOn) setGlobalDocMailSink(docMailSink);
|
|
3414
|
+
function printDocMailNotice() {
|
|
3415
|
+
if (!docMailNoticeOn || !docMailSink.items.length) return;
|
|
3416
|
+
const n = Math.max(docMailSink.items.length, docMailSink.newCount || 0);
|
|
3417
|
+
const first = docMailSink.items[0];
|
|
3418
|
+
console.error(
|
|
3419
|
+
tr(
|
|
3420
|
+
`📬 받은 문서 ${n}건 — 「${first.title}」 ${first.sender}${n > 1 ? ' 외' : ''} · rootr mail`,
|
|
3421
|
+
`📬 ${n} new document${n > 1 ? 's' : ''} received — "${first.title}" from ${first.sender}${n > 1 ? ' and more' : ''} · rootr mail`,
|
|
3422
|
+
),
|
|
3423
|
+
);
|
|
3424
|
+
}
|
|
3425
|
+
|
|
3426
|
+
main()
|
|
3427
|
+
.then(printDocMailNotice)
|
|
3428
|
+
.catch((err) => {
|
|
3429
|
+
printDocMailNotice();
|
|
3430
|
+
printErrorAndExit(err);
|
|
3431
|
+
});
|
package/lib/client.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* can render consistent, helpful messages.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { currentRateLimitSink } from './context.js';
|
|
9
|
+
import { currentDocMailSink, currentRateLimitSink } from './context.js';
|
|
10
10
|
import { t as tr } from './i18n.js';
|
|
11
11
|
|
|
12
12
|
/** Copy the API's X-RateLimit-* headers into the ambient per-call sink, if any. */
|
|
@@ -23,6 +23,25 @@ function captureRateLimit(res) {
|
|
|
23
23
|
sink.upgradeUrl = res.headers.get('x-ratelimit-upgrade-url') || undefined;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/** DOC-MAIL-NOTIFY — 응답의 안 읽은 받은 문서 머리줄을 그릇에 모은다(그릇이 없으면 아무 일도 안 한다). */
|
|
27
|
+
function captureDocMail(res, sink) {
|
|
28
|
+
if (!sink) return;
|
|
29
|
+
const unread = Number(res.headers.get('x-rootr-doc-mail-unread'));
|
|
30
|
+
if (Number.isFinite(unread) && res.headers.get('x-rootr-doc-mail-unread') !== null) sink.unread = unread;
|
|
31
|
+
const raw = res.headers.get('x-rootr-doc-mail-new-items');
|
|
32
|
+
if (!raw) return;
|
|
33
|
+
try {
|
|
34
|
+
const items = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8'));
|
|
35
|
+
sink.items = sink.items || [];
|
|
36
|
+
for (const it of Array.isArray(items) ? items : []) {
|
|
37
|
+
if (it && it.id && !sink.items.some((x) => x.id === it.id)) sink.items.push(it);
|
|
38
|
+
}
|
|
39
|
+
sink.newCount = Math.max(sink.newCount || 0, Number(res.headers.get('x-rootr-doc-mail-new')) || 0);
|
|
40
|
+
} catch {
|
|
41
|
+
/* 알림만 빠진다 */
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
26
45
|
export class RootrApiError extends Error {
|
|
27
46
|
constructor(message, { status, body, hint } = {}) {
|
|
28
47
|
super(message);
|
|
@@ -154,6 +173,8 @@ export class RootrClient {
|
|
|
154
173
|
...this.defaultHeaders,
|
|
155
174
|
...(opts.headers || {}),
|
|
156
175
|
};
|
|
176
|
+
const docMailSink = currentDocMailSink();
|
|
177
|
+
if (docMailSink?.channel) headers['X-Rootr-Doc-Mail-Channel'] = docMailSink.channel;
|
|
157
178
|
|
|
158
179
|
let body = opts.body;
|
|
159
180
|
if (body !== undefined && typeof body !== 'string' && !(body instanceof Uint8Array)) {
|
|
@@ -173,6 +194,7 @@ export class RootrClient {
|
|
|
173
194
|
}
|
|
174
195
|
|
|
175
196
|
captureRateLimit(res);
|
|
197
|
+
if (res.ok) captureDocMail(res, docMailSink);
|
|
176
198
|
|
|
177
199
|
if (opts.returnResponse) {
|
|
178
200
|
if (!res.ok) {
|
|
@@ -2051,15 +2073,16 @@ export class RootrClient {
|
|
|
2051
2073
|
|
|
2052
2074
|
// ---- Doc mail (DOC-MAIL — 문서 송수신) ---------------------------------
|
|
2053
2075
|
//
|
|
2054
|
-
// 노드 하나를
|
|
2055
|
-
//
|
|
2056
|
-
//
|
|
2076
|
+
// 노드 하나를 이메일 1~20명에게 **보낸다 = 원본을 그 이메일에 열어 준다**(복사본이 아니다 — DOC-MAIL-LIVE).
|
|
2077
|
+
// 받는 사람은 원본을 실시간으로 보고, 받은 권한(read 보기·comment 댓글·write 편집)만큼 쓴다. 계정이 없으면 메일 링크에서
|
|
2078
|
+
// 받은 이메일을 넣어 읽기 전용으로 열고, 그 이메일로 가입하면 문서함에서 원본이 열린다. 문서함은 사람(이메일)에 붙으므로
|
|
2079
|
+
// 워크스페이스 주소 밑이 아니다.
|
|
2057
2080
|
|
|
2058
|
-
/** 보내기 — WRITE 이상. 응답 `{batchId, items:[{id,to,emailed}]}`. */
|
|
2059
|
-
async sendNode(nodeId, { to, message } = {}) {
|
|
2081
|
+
/** 보내기 — 구성원 규칙으로 WRITE 이상. access: read|comment|write(기본 read). 응답 `{batchId, access, items:[{id,to,emailed,resent}]}`. */
|
|
2082
|
+
async sendNode(nodeId, { to, message, access } = {}) {
|
|
2060
2083
|
return this.request(`/nodes/${encodeURIComponent(nodeId)}/send`, {
|
|
2061
2084
|
method: 'POST',
|
|
2062
|
-
body: { to, ...(message ? { message } : {}) },
|
|
2085
|
+
body: { to, ...(message ? { message } : {}), ...(access ? { access } : {}) },
|
|
2063
2086
|
});
|
|
2064
2087
|
}
|
|
2065
2088
|
|
|
@@ -2074,16 +2097,47 @@ export class RootrClient {
|
|
|
2074
2097
|
return this.request(`/doc-mail${s ? `?${s}` : ''}`);
|
|
2075
2098
|
}
|
|
2076
2099
|
|
|
2077
|
-
/**
|
|
2078
|
-
|
|
2079
|
-
|
|
2100
|
+
/**
|
|
2101
|
+
* 한 통 열기 — 원본의 **지금** 내용(읽기 전용: 문서면 contentMd, 그 밖은 payload)·받은 권한(access)·원본 주소(openUrl).
|
|
2102
|
+
* 폴더를 받았으면 children 이 오고, node 로 그 아래 것을 골라 읽는다. 받는 사람이 열면 읽음으로 바뀐다.
|
|
2103
|
+
*/
|
|
2104
|
+
async getDocMail(id, { node } = {}) {
|
|
2105
|
+
return this.request(`/doc-mail/${encodeURIComponent(id)}${node ? `?node=${encodeURIComponent(node)}` : ''}`);
|
|
2080
2106
|
}
|
|
2081
2107
|
|
|
2082
|
-
/** 회수 — 보낸 사람만.
|
|
2108
|
+
/** 회수 — 보낸 사람만. 받는 사람의 원본 접근이 바로 닫힌다. */
|
|
2083
2109
|
async revokeDocMail(id) {
|
|
2084
2110
|
return this.request(`/doc-mail/${encodeURIComponent(id)}/revoke`, { method: 'POST', body: {} });
|
|
2085
2111
|
}
|
|
2086
2112
|
|
|
2113
|
+
// ---- Doc mail webhooks (DOC-MAIL-HOOK — 사람 단위) ---------------------
|
|
2114
|
+
/** 내 받은 문서 웹훅 만들기 — events: doc_mail.received|doc_mail.revoked|doc_mail.read(안 주면 셋 다). 비밀값은 이 응답에만. */
|
|
2115
|
+
async addDocMailWebhook({ url, events, name } = {}) {
|
|
2116
|
+
return this.request('/doc-mail-webhooks', { method: 'POST', body: { url, ...(events?.length ? { events } : {}), ...(name ? { name } : {}) } });
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
async listDocMailWebhooks() {
|
|
2120
|
+
return this.request('/doc-mail-webhooks');
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
async removeDocMailWebhook(id) {
|
|
2124
|
+
return this.request(`/doc-mail-webhooks/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
/** 시험 전송 한 통(doc_mail.test). 결과는 docMailWebhookDeliveries(id). */
|
|
2128
|
+
async testDocMailWebhook(id) {
|
|
2129
|
+
return this.request(`/doc-mail-webhooks/${encodeURIComponent(id)}/test`, { method: 'POST', body: {} });
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
async docMailWebhookDeliveries(id) {
|
|
2133
|
+
return this.request(`/doc-mail-webhooks/${encodeURIComponent(id)}/deliveries`);
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
/** 받은 사람의 권한 바꾸기 — 보낸 사람만, 내 수준까지. */
|
|
2137
|
+
async setDocMailAccess(id, access) {
|
|
2138
|
+
return this.request(`/doc-mail/${encodeURIComponent(id)}`, { method: 'PATCH', body: { access } });
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2087
2141
|
/** 내 문서함에서만 지우기(상대 화면은 그대로). box 로 나에게 보낸 것의 한쪽만 고를 수 있다. */
|
|
2088
2142
|
async hideDocMail(id, { box } = {}) {
|
|
2089
2143
|
return this.request(`/doc-mail/${encodeURIComponent(id)}${box ? `?box=${encodeURIComponent(box)}` : ''}`, {
|
package/lib/context.js
CHANGED
|
@@ -36,3 +36,24 @@ export function withRateLimitSink(sink, fn) {
|
|
|
36
36
|
export function currentRateLimitSink() {
|
|
37
37
|
return rateLimitStorage.getStore();
|
|
38
38
|
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* DOC-MAIL-NOTIFY — 안 읽은 받은 문서 알림을 모으는 그릇. `RootrClient.request` 가 그릇이 있으면 요청에
|
|
42
|
+
* `X-Rootr-Doc-Mail-Channel: <channel>` 을 붙이고, 응답의 `X-Rootr-Doc-Mail-Unread/New/New-Items` 를 여기 모은다(서버가
|
|
43
|
+
* 그 채널에 한 번만 알린다). MCP 는 도구 호출마다 async-local 그릇(channel 'mcp'), CLI 명령은 프로세스 전체 그릇(channel 'cli').
|
|
44
|
+
* 원격 MCP(API 프로세스 안)는 async-local 만 쓴다 — 전역 그릇은 CLI 실행 파일만 놓는다(여러 사람이 한 프로세스를 나눠 쓴다).
|
|
45
|
+
*/
|
|
46
|
+
const docMailStorage = new AsyncLocalStorage();
|
|
47
|
+
let globalDocMailSink = null;
|
|
48
|
+
|
|
49
|
+
export function withDocMailSink(sink, fn) {
|
|
50
|
+
return docMailStorage.run(sink, fn);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function setGlobalDocMailSink(sink) {
|
|
54
|
+
globalDocMailSink = sink;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function currentDocMailSink() {
|
|
58
|
+
return docMailStorage.getStore() ?? globalDocMailSink;
|
|
59
|
+
}
|
|
@@ -3,40 +3,44 @@ import { z } from 'zod';
|
|
|
3
3
|
import { jsonResult, errorResult, makeClient, resolveNodeId, NODE_TARGET_HINT } from './shared.js';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* DOC-MAIL — 문서 송수신(창업자 지시 2026-10-20).
|
|
6
|
+
* DOC-MAIL — 문서 송수신(창업자 지시 2026-10-20, 같은 날 정정 "문서자체를 보내란 뜻임").
|
|
7
7
|
*
|
|
8
|
-
* 어떤 타입이든(
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* 어떤 타입이든(문서·폴더·표·시트·화이트보드·덱·폼·이슈·로그·CRM·녹음·페이지·서명문서) 노드 하나를 특정 이메일에게
|
|
9
|
+
* **보낸다 = 원본 그 자체를 그 이메일에 열어 준다**(보기·댓글·편집). 복사본이 아니라서 보낸 사람이 고치면 받는 사람에게
|
|
10
|
+
* 바로 보이고, 편집을 받았으면 받는 사람이 고친 것이 원본에 남는다. 받은 문서는 받는 사람의 **이메일 주소**에 붙는다 —
|
|
11
|
+
* 계정이 없어도 메일 링크(받은 이메일을 넣어야 열림, 읽기 전용)로 열리고, 그 이메일로 가입하면 문서함에서 원본이 열린다.
|
|
12
12
|
*/
|
|
13
13
|
export function registerDocMailTools(server) {
|
|
14
14
|
server.registerTool(
|
|
15
15
|
'rootr_send_document',
|
|
16
16
|
{
|
|
17
|
-
title: 'Send a Rootr node to email addresses',
|
|
17
|
+
title: 'Send a Rootr node to email addresses (give them access to the original)',
|
|
18
18
|
description:
|
|
19
|
-
'Send
|
|
20
|
-
'
|
|
21
|
-
'
|
|
22
|
-
'
|
|
23
|
-
'
|
|
24
|
-
'
|
|
25
|
-
'
|
|
26
|
-
'
|
|
27
|
-
'
|
|
19
|
+
'Send any Rootr node (document, folder, table, spreadsheet, whiteboard, deck, form, issues, log, CRM, recording, ' +
|
|
20
|
+
'page, signed document) to 1–20 email addresses. This is NOT a copy: each address gets access to the ORIGINAL ' +
|
|
21
|
+
'node (and everything under it, if it is a folder) at `access` = read (view, default) | comment | write (edit). ' +
|
|
22
|
+
'They always see the latest version; with write their edits land in the original. People with a Rootr account ' +
|
|
23
|
+
'on that (verified) email open it from their inbox (rootr_list_doc_mail) with that access, even if they are not ' +
|
|
24
|
+
'members of your workspace — they never see your workspace tree, members or search. People without an account ' +
|
|
25
|
+
'get an email link where they must type their email address to read it (read-only). ' +
|
|
26
|
+
'Needs WRITE on the node as a workspace member (the same bar as sharing); you can give at most your own level, ' +
|
|
27
|
+
'never manage. Re-sending to the same address updates access/message instead of adding a second grant. ' +
|
|
28
|
+
'Withdraw with rootr_revoke_doc_mail; change access with rootr_set_doc_mail_access. ' +
|
|
29
|
+
'The result lists each recipient with `emailed` (false = the email failed; the access still stands). ' +
|
|
30
|
+
'Only send to addresses the user asked for — this emails real people and opens the document to them.',
|
|
28
31
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
29
32
|
inputSchema: {
|
|
30
33
|
node: z.string().describe(`보낼 노드 id${NODE_TARGET_HINT}`),
|
|
31
34
|
to: z.array(z.string()).min(1).max(20).describe('받는 사람 이메일 주소들(1~20개). 대소문자만 다른 주소는 같은 사람'),
|
|
32
35
|
message: z.string().max(2000).optional().describe('보내며 함께 적을 말(메일과 문서함에 보인다, 2000자까지)'),
|
|
36
|
+
access: z.enum(['read', 'comment', 'write']).optional().describe('받는 사람이 원본에서 할 수 있는 것: read=보기(기본) · comment=댓글 · write=편집. 내 권한까지만'),
|
|
33
37
|
workspace: z.string().optional().describe('경로로 줄 때 찾을 워크스페이스 id (생략하면 설정값)'),
|
|
34
38
|
},
|
|
35
39
|
},
|
|
36
|
-
async ({ node, to, message, workspace }) => {
|
|
40
|
+
async ({ node, to, message, access, workspace }) => {
|
|
37
41
|
try {
|
|
38
42
|
const client = makeClient(workspace);
|
|
39
|
-
return jsonResult(await client.sendNode(await resolveNodeId(client, node), { to, message }));
|
|
43
|
+
return jsonResult(await client.sendNode(await resolveNodeId(client, node), { to, message, access }));
|
|
40
44
|
} catch (err) {
|
|
41
45
|
return errorResult(err);
|
|
42
46
|
}
|
|
@@ -51,7 +55,9 @@ export function registerDocMailTools(server) {
|
|
|
51
55
|
"List the caller's document mailbox: box=inbox (documents sent TO the caller's account email — default) or " +
|
|
52
56
|
'box=sent (documents the caller sent). Summaries only (title, type, sender, recipient, message, sentAt, ' +
|
|
53
57
|
'readAt, revoked); open one with rootr_read_doc_mail. `unread` is the number of unread received documents. ' +
|
|
54
|
-
'Page with `cursor` = the previous `nextCursor`. Belongs to the person (their email), not to a workspace.'
|
|
58
|
+
'Page with `cursor` = the previous `nextCursor`. Belongs to the person (their email), not to a workspace. ' +
|
|
59
|
+
'Each item has `access` (READ/COMMENT/WRITE), `original` (live | trashed | deleted) and, for live received ones, ' +
|
|
60
|
+
'`nodeId`/`workspaceId` of the original.',
|
|
55
61
|
annotations: { readOnlyHint: true },
|
|
56
62
|
inputSchema: {
|
|
57
63
|
box: z.enum(['inbox', 'sent']).optional().describe('inbox=받은 문서(기본), sent=보낸 문서'),
|
|
@@ -74,18 +80,21 @@ export function registerDocMailTools(server) {
|
|
|
74
80
|
{
|
|
75
81
|
title: 'Read a received or sent Rootr document',
|
|
76
82
|
description:
|
|
77
|
-
'Open one document from the mailbox (id from rootr_list_doc_mail). Returns the summary plus
|
|
78
|
-
'
|
|
79
|
-
'
|
|
80
|
-
'
|
|
83
|
+
'Open one document from the mailbox (id from rootr_list_doc_mail). Returns the summary plus the CURRENT content ' +
|
|
84
|
+
'of the original, read-only, in the same shape a public page uses: `contentMd` for documents, `payload.kind` for ' +
|
|
85
|
+
'the rest (database → `properties` + `rows`; spreadsheet → `data`; …), `children` (items inside — pass one as ' +
|
|
86
|
+
'`node` to read it), `access` (what the recipient may do), `openUrl` (the original in the Rootr editor). ' +
|
|
87
|
+
'Opening a received one marks it read. Withdrawn → `revoked` true and no content; original deleted → ' +
|
|
88
|
+
'`originalDeleted` true. This works with any of the recipient\'s keys, even one bound to another workspace.',
|
|
81
89
|
annotations: { readOnlyHint: false },
|
|
82
90
|
inputSchema: {
|
|
83
91
|
id: z.string().describe('문서함 항목 id (rootr_list_doc_mail 의 items[].id)'),
|
|
92
|
+
node: z.string().optional().describe('폴더(부모)를 받았을 때 그 아래 노드 id (앞 응답의 children[].id)'),
|
|
84
93
|
},
|
|
85
94
|
},
|
|
86
|
-
async ({ id }) => {
|
|
95
|
+
async ({ id, node }) => {
|
|
87
96
|
try {
|
|
88
|
-
return jsonResult(await makeClient().getDocMail(id));
|
|
97
|
+
return jsonResult(await makeClient().getDocMail(id, { node }));
|
|
89
98
|
} catch (err) {
|
|
90
99
|
return errorResult(err);
|
|
91
100
|
}
|
|
@@ -97,9 +106,9 @@ export function registerDocMailTools(server) {
|
|
|
97
106
|
{
|
|
98
107
|
title: 'Withdraw a sent Rootr document',
|
|
99
108
|
description:
|
|
100
|
-
'Withdraw a document the caller sent (id from rootr_list_doc_mail box=sent).
|
|
101
|
-
'only the title and "withdrawn"
|
|
102
|
-
'it cannot be undone (send it again instead).
|
|
109
|
+
'Withdraw a document the caller sent (id from rootr_list_doc_mail box=sent). The recipient loses access to the ' +
|
|
110
|
+
'original immediately (open editors are disconnected), sees only the title and "withdrawn", and the email link ' +
|
|
111
|
+
'answers 410. Only the sender can do this; it cannot be undone (send it again instead).',
|
|
103
112
|
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
104
113
|
inputSchema: {
|
|
105
114
|
id: z.string().describe('보낸 문서함 항목 id'),
|
|
@@ -113,4 +122,117 @@ export function registerDocMailTools(server) {
|
|
|
113
122
|
}
|
|
114
123
|
},
|
|
115
124
|
);
|
|
125
|
+
|
|
126
|
+
server.registerTool(
|
|
127
|
+
'rootr_set_doc_mail_access',
|
|
128
|
+
{
|
|
129
|
+
title: 'Change what the recipient of a sent Rootr document can do',
|
|
130
|
+
description:
|
|
131
|
+
'Change the access of one recipient of a document the caller sent (id from rootr_list_doc_mail box=sent): ' +
|
|
132
|
+
'read (view) | comment | write (edit). Only the sender; at most the sender\'s own level on the node. Lowering it ' +
|
|
133
|
+
'takes effect immediately. No email is sent.',
|
|
134
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
135
|
+
inputSchema: {
|
|
136
|
+
id: z.string().describe('보낸 문서함 항목 id'),
|
|
137
|
+
access: z.enum(['read', 'comment', 'write']).describe('read=보기 · comment=댓글 · write=편집'),
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
async ({ id, access }) => {
|
|
141
|
+
try {
|
|
142
|
+
return jsonResult(await makeClient().setDocMailAccess(id, access));
|
|
143
|
+
} catch (err) {
|
|
144
|
+
return errorResult(err);
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
// ── DOC-MAIL-HOOK — 사람 단위 웹훅 ──────────────────────────────────────
|
|
150
|
+
server.registerTool(
|
|
151
|
+
'rootr_add_doc_mail_webhook',
|
|
152
|
+
{
|
|
153
|
+
title: 'Get a webhook when documents arrive for me',
|
|
154
|
+
description:
|
|
155
|
+
"Register a webhook on the caller's own account (not a workspace) for their document mailbox. Rootr POSTs JSON to " +
|
|
156
|
+
'`url` on doc_mail.received (a document was sent to the account\'s verified email), doc_mail.revoked (a received one ' +
|
|
157
|
+
'was withdrawn) and doc_mail.read (a recipient opened one the caller sent). The body has id, title, nodeType, sender, ' +
|
|
158
|
+
'message, access, apiUrl (GET it with your key for the content) and appUrl — never the document content. Each request ' +
|
|
159
|
+
'is signed: X-Rootr-Signature: sha256=HMAC_SHA256(secret, raw body). The `secret` is returned ONCE, here — store it. ' +
|
|
160
|
+
'Only public http(s) URLs (private/loopback addresses are refused). Max 5 per account.',
|
|
161
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
162
|
+
inputSchema: {
|
|
163
|
+
url: z.string().describe('받을 주소(공인 http/https)'),
|
|
164
|
+
events: z.array(z.enum(['doc_mail.received', 'doc_mail.revoked', 'doc_mail.read'])).optional().describe('받을 사건(안 주면 셋 다)'),
|
|
165
|
+
name: z.string().max(100).optional().describe('알아보기 쉬운 이름'),
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
async ({ url, events, name }) => {
|
|
169
|
+
try {
|
|
170
|
+
return jsonResult(await makeClient().addDocMailWebhook({ url, events, name }));
|
|
171
|
+
} catch (err) {
|
|
172
|
+
return errorResult(err);
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
server.registerTool(
|
|
178
|
+
'rootr_list_doc_mail_webhooks',
|
|
179
|
+
{
|
|
180
|
+
title: "List my document webhooks",
|
|
181
|
+
description: "List the caller's document-mailbox webhooks (url, events, active, last delivery status). Secrets are not shown.",
|
|
182
|
+
annotations: { readOnlyHint: true },
|
|
183
|
+
inputSchema: {},
|
|
184
|
+
},
|
|
185
|
+
async () => {
|
|
186
|
+
try {
|
|
187
|
+
return jsonResult(await makeClient().listDocMailWebhooks());
|
|
188
|
+
} catch (err) {
|
|
189
|
+
return errorResult(err);
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
server.registerTool(
|
|
195
|
+
'rootr_remove_doc_mail_webhook',
|
|
196
|
+
{
|
|
197
|
+
title: 'Remove one of my document webhooks',
|
|
198
|
+
description: 'Delete a document-mailbox webhook of the caller (id from rootr_list_doc_mail_webhooks). Its delivery log goes with it.',
|
|
199
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
200
|
+
inputSchema: { id: z.string().describe('웹훅 id') },
|
|
201
|
+
},
|
|
202
|
+
async ({ id }) => {
|
|
203
|
+
try {
|
|
204
|
+
return jsonResult(await makeClient().removeDocMailWebhook(id));
|
|
205
|
+
} catch (err) {
|
|
206
|
+
return errorResult(err);
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
server.registerTool(
|
|
212
|
+
'rootr_test_doc_mail_webhook',
|
|
213
|
+
{
|
|
214
|
+
title: 'Send a test event to one of my document webhooks',
|
|
215
|
+
description:
|
|
216
|
+
'Queue a signed doc_mail.test delivery to the webhook (re-enables it if it was turned off), then report the latest ' +
|
|
217
|
+
'deliveries a few seconds later (status, HTTP code, error).',
|
|
218
|
+
annotations: { readOnlyHint: false, openWorldHint: true },
|
|
219
|
+
inputSchema: { id: z.string().describe('웹훅 id') },
|
|
220
|
+
},
|
|
221
|
+
async ({ id }) => {
|
|
222
|
+
try {
|
|
223
|
+
const client = makeClient();
|
|
224
|
+
const queued = await client.testDocMailWebhook(id);
|
|
225
|
+
let deliveries = null;
|
|
226
|
+
for (let i = 0; i < 8; i++) {
|
|
227
|
+
await new Promise((r) => setTimeout(r, 750));
|
|
228
|
+
deliveries = await client.docMailWebhookDeliveries(id);
|
|
229
|
+
const d = deliveries?.items?.find((x) => x.id === queued.deliveryId);
|
|
230
|
+
if (d && d.status !== 'PENDING') break;
|
|
231
|
+
}
|
|
232
|
+
return jsonResult({ ...queued, deliveries: deliveries?.items?.slice(0, 5) ?? [] });
|
|
233
|
+
} catch (err) {
|
|
234
|
+
return errorResult(err);
|
|
235
|
+
}
|
|
236
|
+
},
|
|
237
|
+
);
|
|
116
238
|
}
|
package/lib/mcp-tools/pages.js
CHANGED
|
@@ -109,7 +109,8 @@ export function registerPageTools(server) {
|
|
|
109
109
|
title: 'Create a Rootr page (block-assembled web page)',
|
|
110
110
|
description:
|
|
111
111
|
'Create a new PAGE node in a Rootr (루터) workspace — a web page assembled from safe typed blocks, which ' +
|
|
112
|
-
'can then be published to the web at a public address (see rootr_publish_node
|
|
112
|
+
'can then be published to the web at a public address (see rootr_publish_node — web publishing of pages needs ' +
|
|
113
|
+
'the Team plan; on Free the page still works inside the workspace). Use this when someone asks ' +
|
|
113
114
|
'for a DASHBOARD / report / KPI board over their tables, spreadsheets or logs (46 live chart kinds, numbers stay ' +
|
|
114
115
|
'current), or for a landing page, an announcement page, an event page, a product/pricing page or a signup page. ' +
|
|
115
116
|
'Omit `blocks` to start from a hero/text/footer skeleton. ' +
|
package/lib/mcp-tools/public.js
CHANGED
|
@@ -29,9 +29,13 @@ export function registerPublicTools(server) {
|
|
|
29
29
|
description:
|
|
30
30
|
'Publish ANY Rootr (루터) node at a public read-only URL (rootr.io/p/{slug}) — a DATABASE (visitors get a ' +
|
|
31
31
|
'searchable table and can clone it into their own workspace), SPREADSHEET, WHITEBOARD, FORM (questions only), ' +
|
|
32
|
-
'ISSUE_TRACKER, LOG, CRM, RECORDING (transcript only), SIGNED or
|
|
32
|
+
'ISSUE_TRACKER, LOG, CRM, RECORDING (transcript only), SIGNED, DOCUMENT or PAGE. Use this instead of ' +
|
|
33
33
|
'rootr_publish_document unless you specifically want the document-only extras (skins, edit proposals). ' +
|
|
34
34
|
'FOLDER cannot be published. ' +
|
|
35
|
+
'PAGE (the block-assembled web page) can be published only on the Team plan and up — a Free workspace gets ' +
|
|
36
|
+
'403 PLAN_UPGRADE_REQUIRED (nothing is changed; tell the user to upgrade the workspace to Team). Every other type ' +
|
|
37
|
+
'publishes on Free as before. With `includeChildren` on Free, PAGE nodes underneath are skipped and the response ' +
|
|
38
|
+
'says how many (`skippedPagesRequireTeam`). ' +
|
|
35
39
|
'Set `includeChildren: true` to publish everything underneath as well — the WHOLE subtree, grandchildren ' +
|
|
36
40
|
'included, each getting its own URL and listed on the parent page. Leave it off and only this one node goes ' +
|
|
37
41
|
'out (its sub-page names do not even appear). Turning it back off, closing the parent, moving a child out, ' +
|
|
@@ -90,6 +94,7 @@ export function registerPublicTools(server) {
|
|
|
90
94
|
'PLAN_UPGRADE_REQUIRED; a password-locked page is always hidden and cannot be re-exposed until unlocked); ' +
|
|
91
95
|
'`{"design": {"layout": "board", "skin": "paper"}}` changes how a published TABLE looks — it does not ' +
|
|
92
96
|
'have to be a table (cards, gallery, list, board, timeline, calendar, chart, ranking). ' +
|
|
97
|
+
'`{"isPublic": true}` on a PAGE needs the Team plan (Free → 403 PLAN_UPGRADE_REQUIRED); closing it works on any plan. ' +
|
|
93
98
|
'For DOCUMENT nodes use rootr_set_document_public instead — documents keep their own settings (skins, ' +
|
|
94
99
|
'edit proposals) on the older path. Owner-level action (MANAGE): use an account key (PAT).',
|
|
95
100
|
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
@@ -132,6 +137,8 @@ export function registerPublicTools(server) {
|
|
|
132
137
|
'`publishable: false` means this type cannot be published at all (FOLDER). ' +
|
|
133
138
|
'`closed: "PUBLIC_OWN_ROW_PAGE"` (with `isPublic: false` and a `closedReason`) means publishing is switched on but the ' +
|
|
134
139
|
'address is closed right now — do NOT hand that URL out; tell the user why and how to reopen it. ' +
|
|
140
|
+
'`closed: "PAGE_PUBLISH_REQUIRES_TEAM"` means a PAGE was published but its workspace is on the Free plan, so ' +
|
|
141
|
+
'visitors see a "not published" screen — upgrading to Team reopens the same URL without publishing again. ' +
|
|
135
142
|
'Call this before publishing so you do not tell the user a page is new when it already had a URL.',
|
|
136
143
|
annotations: { readOnlyHint: true, destructiveHint: false },
|
|
137
144
|
inputSchema: {
|
package/lib/mcp.js
CHANGED
|
@@ -25,7 +25,7 @@ import { registerSignedDocTools } from './mcp-tools/signed.js';
|
|
|
25
25
|
import { registerFeedbackTools } from './mcp-tools/feedback.js';
|
|
26
26
|
import { registerMemoryTools } from './mcp-tools/memory.js';
|
|
27
27
|
import { registerDocMailTools } from './mcp-tools/doc-mail.js';
|
|
28
|
-
import { withRateLimitSink } from './context.js';
|
|
28
|
+
import { withDocMailSink, withRateLimitSink } from './context.js';
|
|
29
29
|
import { makeClient } from './mcp-tools/shared.js';
|
|
30
30
|
import { t as tr } from './i18n.js';
|
|
31
31
|
|
|
@@ -77,6 +77,39 @@ function withQuotaWarnings(server) {
|
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
|
|
80
|
+
/**
|
|
81
|
+
* DOC-MAIL-NOTIFY — 어떤 루터 도구를 부르든, 아직 알리지 않은 **새 받은 문서**가 있으면 응답 끝에 한 줄을 붙인다(창업자 지시
|
|
82
|
+
* 2026-10-20 — 클라이언트와 상관없이 루터를 쓰는 순간 알게). 도구가 부른 API 응답의 머리줄(`X-Rootr-Doc-Mail-New-Items`)을
|
|
83
|
+
* 그릇에 모아 쓴다 — 따로 묻는 호출이 없다. 서버가 키마다·채널(mcp)마다 한 번만 알려 준다(같은 문서를 두 번 알리지 않는다).
|
|
84
|
+
* 문서함 도구 자신(`*doc_mail*`)의 응답에는 붙이지 않는다 — 이미 문서함을 보고 있다.
|
|
85
|
+
*/
|
|
86
|
+
function docMailNotice(items) {
|
|
87
|
+
if (!items?.length) return null;
|
|
88
|
+
const shown = items.slice(0, 5);
|
|
89
|
+
const list = shown.map((i) => tr(`「${i.title}」 — ${i.sender} (id: ${i.id})`, `"${i.title}" from ${i.sender} (id: ${i.id})`)).join(', ');
|
|
90
|
+
const more = items.length > shown.length ? tr(` 외 ${items.length - shown.length}건`, ` and ${items.length - shown.length} more`) : '';
|
|
91
|
+
return tr(
|
|
92
|
+
`\n\n📬 새로 받은 문서 (${items.length}): ${list}${more} — rootr_read_doc_mail {id} 로 읽으세요. 사용자에게 알려 주세요.`,
|
|
93
|
+
`\n\n📬 New documents received (${items.length}): ${list}${more} — read with rootr_read_doc_mail {id}. Tell the user.`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function withDocMailNotices(server) {
|
|
98
|
+
const original = server.registerTool.bind(server);
|
|
99
|
+
server.registerTool = (name, spec, handler) =>
|
|
100
|
+
original(name, spec, async (...args) => {
|
|
101
|
+
const sink = { channel: 'mcp', items: [] };
|
|
102
|
+
const result = await withDocMailSink(sink, () => handler(...args));
|
|
103
|
+
if (/doc_mail/.test(name)) return result;
|
|
104
|
+
const notice = docMailNotice(sink.items);
|
|
105
|
+
if (!notice || !result || !Array.isArray(result.content)) return result;
|
|
106
|
+
const last = [...result.content].reverse().find((c) => c && c.type === 'text');
|
|
107
|
+
if (!last) return { ...result, content: [...result.content, { type: 'text', text: notice.trim() }] };
|
|
108
|
+
return { ...result, content: result.content.map((c) => (c === last ? { ...c, text: `${c.text}${notice}` } : c)) };
|
|
109
|
+
});
|
|
110
|
+
return server;
|
|
111
|
+
}
|
|
112
|
+
|
|
80
113
|
/**
|
|
81
114
|
* MEM-001 — **기억을 눈앞에 놓는다. 이 기능의 성패가 여기 달렸다.**
|
|
82
115
|
*
|
|
@@ -225,10 +258,14 @@ export function buildMcpServer({ name, version }) {
|
|
|
225
258
|
'rootr_request_signing_code first and ASK THE HUMAN for the 6 digits that arrive in their mail — ' +
|
|
226
259
|
'that code is the only evidence a person (not an agent) pressed sign; ' +
|
|
227
260
|
'FOLDER = grouping; LOG store = typed event table whose relation fields auto-build a lineage graph (metrics, incidents, pipeline events — use the rootr_*_log_* tools).\n\n' +
|
|
228
|
-
'Sending a document to someone by email (문서 보내기 · 문서함): rootr_send_document
|
|
229
|
-
'node (not a
|
|
230
|
-
'
|
|
231
|
-
'
|
|
261
|
+
'Sending a document to someone by email (문서 보내기 · 문서함): rootr_send_document gives 1-20 email addresses ' +
|
|
262
|
+
'access to the ORIGINAL node (not a copy) at access read|comment|write — they see live edits, and with write ' +
|
|
263
|
+
'their edits land in the original; an account on that email opens it from its inbox, others read it via the ' +
|
|
264
|
+
'email link after typing their address; rootr_list_doc_mail (box inbox|sent) / rootr_read_doc_mail / ' +
|
|
265
|
+
'rootr_set_doc_mail_access / rootr_revoke_doc_mail cover the mailbox. Only send to addresses the user gave you. ' +
|
|
266
|
+
'When any tool result ends with "📬 New documents received", tell the user right away (title and sender) and offer to ' +
|
|
267
|
+
'open them with rootr_read_doc_mail. rootr_add_doc_mail_webhook makes Rootr POST doc_mail.received / revoked / read ' +
|
|
268
|
+
'to the user\'s own URL (for their automations).\n\n' +
|
|
232
269
|
'Scaffolding discipline — treat "set up a workspace for X" exactly like "build me this app": you ask a ' +
|
|
233
270
|
'few clarifying questions, write it, compile it, run the tests, leave a README, and report. The user ' +
|
|
234
271
|
'should only have to type that one sentence; everything else finishes inside the conversation. Never ask ' +
|
|
@@ -268,6 +305,7 @@ export function buildMcpServer({ name, version }) {
|
|
|
268
305
|
// 한도 경고가 그 뒤에 붙는다 — 경고는 언제나 맨 끝에 있어야 눈에 띈다.
|
|
269
306
|
withMemoryRecall(server);
|
|
270
307
|
withQuotaWarnings(server);
|
|
308
|
+
withDocMailNotices(server);
|
|
271
309
|
|
|
272
310
|
registerDocumentTools(server);
|
|
273
311
|
registerTemplateTools(server);
|