larkit 0.0.0-stage → 0.1.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.
Files changed (73) hide show
  1. package/AGENT_SKILL.md +12 -0
  2. package/README.md +765 -2
  3. package/bin/larkit-mcp.js +8 -0
  4. package/bin/larkit.js +532 -0
  5. package/larkit.config.example.json +12 -0
  6. package/package.json +65 -4
  7. package/scripts/.tmp-npm-webauth.mjs +49 -0
  8. package/scripts/build-index.mjs +66 -0
  9. package/scripts/check-syntax.mjs +28 -0
  10. package/scripts/login-worker.mjs +50 -0
  11. package/scripts/publish-pack.mjs +277 -0
  12. package/scripts/verify-bootstrap.mjs +238 -0
  13. package/scripts/verify-console.mjs +60 -0
  14. package/scripts/verify-index.mjs +59 -0
  15. package/scripts/verify-mcp.mjs +215 -0
  16. package/skills/larkit/SKILL.md +120 -0
  17. package/skills/larkit/references/console.md +86 -0
  18. package/skills/larkit/references/playbooks.md +177 -0
  19. package/skills/larkit/references/tools.md +115 -0
  20. package/skills/larkit/references/truth-model.md +117 -0
  21. package/src/auth/login.js +171 -0
  22. package/src/auth/service.js +213 -0
  23. package/src/auth/store.js +213 -0
  24. package/src/bootstrap/channel.js +201 -0
  25. package/src/bootstrap/cli-install.js +184 -0
  26. package/src/bootstrap/download.js +182 -0
  27. package/src/bootstrap/init.js +672 -0
  28. package/src/bootstrap/pack-cli.js +97 -0
  29. package/src/bootstrap/pack.js +541 -0
  30. package/src/bootstrap/report.js +62 -0
  31. package/src/bootstrap/skills.js +223 -0
  32. package/src/bootstrap/state.js +66 -0
  33. package/src/bootstrap/tar.js +417 -0
  34. package/src/console/browser.js +338 -0
  35. package/src/console/client.js +827 -0
  36. package/src/console/dispatch.js +101 -0
  37. package/src/docs/client.js +284 -0
  38. package/src/indexer/anchors.js +366 -0
  39. package/src/indexer/db.js +449 -0
  40. package/src/indexer/extract-assets.js +285 -0
  41. package/src/indexer/extract-deb.js +56 -0
  42. package/src/indexer/format-pool.js +64 -0
  43. package/src/indexer/format-worker.js +132 -0
  44. package/src/indexer/format.js +239 -0
  45. package/src/indexer/protocol.js +123 -0
  46. package/src/mcp/server.js +433 -0
  47. package/src/probe/client.js +225 -0
  48. package/src/probe/openapi.js +194 -0
  49. package/src/protocol/catalog.js +146 -0
  50. package/src/protocol/data/README.md +15 -0
  51. package/src/protocol/data/bundle.json +1 -0
  52. package/src/protocol/data/commands.json +12896 -0
  53. package/src/protocol/data/constants.json +8 -0
  54. package/src/protocol/data/reference/PROTOCOL.md +66 -0
  55. package/src/protocol/data/reference/endpoints.json +46 -0
  56. package/src/protocol/data/schema.json.gz +0 -0
  57. package/src/protocol/descriptor.js +358 -0
  58. package/src/protocol/schema.js +235 -0
  59. package/src/protocol/transport.js +387 -0
  60. package/src/shared/browser.js +70 -0
  61. package/src/shared/config.js +90 -0
  62. package/src/shared/paths.js +151 -0
  63. package/src/shared/quiet.js +12 -0
  64. package/src/shared/util.js +124 -0
  65. package/src/truth/fts.js +24 -0
  66. package/src/truth/search.js +554 -0
  67. package/src/truth/verified.js +107 -0
  68. package/verified_truths/README.md +28 -0
  69. package/verified_truths/card-json-2.0-official.md +55 -0
  70. package/verified_truths/client-ws-gateway-boundaries.md +33 -0
  71. package/verified_truths/feishu-developer-console-apis.md +104 -0
  72. package/workers/truth-api/src/index.js +180 -0
  73. package/workers/truth-api/wrangler.toml +31 -0
@@ -0,0 +1,55 @@
1
+ ---
2
+ topic: 卡片 JSON 2.0 全局结构与 chart_spec
3
+ verified_at: 2026-11-15T00:00:00.000Z
4
+ source: doc
5
+ confidence: high
6
+ tags: [card, json2.0, chart_spec, card_link]
7
+ ---
8
+
9
+ ## 结论
10
+
11
+ 飞书卡片 JSON 2.0 通过顶层 `"schema": "2.0"` 声明;全局字段包括 `config`、`card_link`、`header`、`body`。
12
+ 图表组件位于 `body.elements[]`,`tag` 为 `"chart"`,其图表定义字段名为 `chart_spec`(VChart spec)。
13
+ 平台会对 `chart_spec` 自动追加媒体查询;在 `chart_spec` 中设置 `"media": []` 可禁用默认追加。
14
+
15
+ ## card_link 结构
16
+
17
+ ```json
18
+ {
19
+ "card_link": {
20
+ "url": "https://www.example.com",
21
+ "android_url": "https://developer.android.com/",
22
+ "ios_url": "https://developer.apple.com/",
23
+ "pc_url": "https://www.windows.com"
24
+ }
25
+ }
26
+ ```
27
+
28
+ ## chart 组件骨架
29
+
30
+ ```json
31
+ {
32
+ "schema": "2.0",
33
+ "body": {
34
+ "elements": [
35
+ {
36
+ "tag": "chart",
37
+ "element_id": "custom_id",
38
+ "aspect_ratio": "16:9",
39
+ "color_theme": "brand",
40
+ "chart_spec": {
41
+ "type": "bar",
42
+ "data": [{ "x": "A", "y": 1 }],
43
+ "media": []
44
+ }
45
+ }
46
+ ]
47
+ }
48
+ }
49
+ ```
50
+
51
+ ## 证据
52
+
53
+ - 卡片 JSON 2.0 结构:https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/card-json-v2-structure
54
+ - 图表组件(含 `chart_spec` 说明):https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/card-json-v2-components/content-components/chart
55
+ - 通过 `document_portal/v1/document/get_detail?fullPath=...` 读取官方正文,确认 `chart_spec` 与 `card_link` 字段存在。
@@ -0,0 +1,33 @@
1
+ ---
2
+ topic: 客户端 WS / gateway 探针实测边界
3
+ verified_at: 2026-11-15T07:05:00.000Z
4
+ source: probe
5
+ confidence: high
6
+ tags: [client, websocket, gateway, probe]
7
+ ---
8
+
9
+ ## 结论
10
+
11
+ - `connect()` 用网页版 Cookie(必须含 `passport_web_did`)可以建立
12
+ `wss://msg-frontier.feishu.cn/ws/v2` 长连接;实测收到 `cmd=7001`
13
+ (`device.PushDeviceOnlineStatus`)与 `cmd=7017`
14
+ (`device.PushDeviceNetworkAccessEvent`),protobuf 能解出业务字段。
15
+ - `PUSH_MESSAGES(6)` 是推送命令,不应主动发送;发送会收到
16
+ `packet.status=200, sid="command: 6 not found"`。探针应使用
17
+ `wait_push=true` 只监听。
18
+ - 客户端 pb 网关 `POST https://internal-api-lark-api.feishu.cn/im/gateway/`
19
+ 可以发送请求命令并解回 `improto.Packet`。读命令 `GET_CHAT_MESSAGES(1020)`
20
+ 实测返回 HTTP 400,`sid="PullFeedGroups invalid request"`,说明读消息不在
21
+ 这个网关路由;读消息走开放 API 或网页版另一条 HTTP 通道。
22
+ - 官方 OpenAPI 探针可用应用凭据换 `tenant_access_token`,再请求
23
+ `/open-apis/im/v1/chats`;有权限时返回 `code:0`,无权限时返回真实错误码
24
+ (如 `99991672`),适合验证隐藏字段与权限边界。
25
+
26
+ ## 证据
27
+
28
+ - `lark_probe(mode=client, command=PUSH_MESSAGES, transport=ws, wait_push=true)`
29
+ -> `handshake=true`,events 含 7001/7017 解码结果。
30
+ - `lark_probe(mode=client, command=GET_CHAT_MESSAGES, transport=gateway)`
31
+ -> HTTP 400,decoded packet sid `PullFeedGroups invalid request`。
32
+ - `lark_probe(mode=openapi, path=/im/v1/chats)` -> token 换取成功,返回真实
33
+ `code/msg/log_id`。
@@ -0,0 +1,104 @@
1
+ ---
2
+ topic: 飞书开发者后台内部 REST API(Console)
3
+ verified_at: 2026-11-15T07:00:00.000Z
4
+ source: probe
5
+ confidence: high
6
+ tags: [console, developer, app, scopes, websocket, publish]
7
+ ---
8
+
9
+ ## 结论
10
+
11
+ 飞书开发者后台(`https://open.feishu.cn/app`)的内部 API 前缀是
12
+ `https://open.feishu.cn/developers`,默认 `POST application/json`,返回
13
+ `{ code, msg, data }`。带 Cookie 登录后,还必须带 `x-csrf-token`,其值取自
14
+ 后台 HTML 的 `window.csrfToken`(88 字符),并且 Cookie 中要有页面响应下发的
15
+ `lark_oapi_csrf_token`(44 字符)。只带 `swp_csrf_token` 会返回
16
+ `{"code":9499,"msg":"lark_oapi_csrf_token not exist in cookie"}`。
17
+
18
+ ## 应用
19
+
20
+ ```http
21
+ POST /developers/v1/app/list
22
+ body: {"Count":20,"Cursor":0}
23
+ # data.apps[] -> appID/name/ability/version/owner...
24
+
25
+ POST /developers/v1/app/create
26
+ body: {"fromProduct":0,"appSceneType":0,"name":"x","desc":"","avatar":"",
27
+ "primaryLang":"zh_cn","i18n":{"zh_cn":{"name":"x","description":""}}}
28
+ # data.ClientID / data.Avatar
29
+
30
+ POST /developers/v1/app/:clientId
31
+ body: {}
32
+
33
+ POST /developers/v1/secret/:clientId
34
+ body: {}
35
+ # data.secret
36
+
37
+ POST /developers/v1/base_info/:clientId
38
+ body: {"clientId":"cli_x","name":"new","desc":"...","i18n":{"zh_cn":{"name":"new"}}}
39
+ ```
40
+
41
+ ## 权限
42
+
43
+ ```http
44
+ POST /developers/v1/scope/all/:clientId
45
+ body: {"clientId":"cli_x"}
46
+ # data.scopes[] {id,name,bizId,status,levels,...}, data.scopeBizs[]
47
+
48
+ POST /developers/v1/scope/update/:clientId
49
+ body: {"clientId":"cli_x","operation":"add","scopeIds":[],
50
+ "appScopeIDs":["20001"],"userScopeIDs":[]}
51
+ ```
52
+
53
+ `im:message` 的 scope id 是 `20001`(实测)。批量开通时按 scope 名称从
54
+ `scope/all` 解析 id,再放入 `appScopeIDs`(应用身份)或 `userScopeIDs`(用户身份)。
55
+
56
+ ## 机器人 / 事件 / 卡片回调(长连接)
57
+
58
+ ```http
59
+ POST /developers/v1/robot/:clientId # data.enable, data.cardCallbackMode
60
+ POST /developers/v1/robot/switch/:clientId # {"clientId":"cli_x","enable":true}
61
+
62
+ POST /developers/v1/event/:clientId # data.eventMode; 4 = WebSocket
63
+ POST /developers/v1/event/switch/:clientId # {"clientId":"cli_x","eventMode":4}
64
+
65
+ POST /developers/v1/callback/:clientId # data.callbackMode; 4 = WebSocket
66
+ POST /developers/v1/callback/switch/:clientId# {"clientId":"cli_x","callbackMode":4}
67
+ ```
68
+
69
+ - `eventMode=4` 与 `callbackMode=4` 即事件订阅/卡片回调的 WebSocket 长连接模式。
70
+ - `robot.cardCallbackMode` 是卡片请求地址模式(1=Webhook),不是长连接开关;
71
+ 不要把它当成 `callbackMode`。实测向 `/v2/robot/push_mode/switch/:clientId`
72
+ 传 `callbackMode:4` 会返回 `ParamInvalid`。
73
+
74
+ ## 版本与发布
75
+
76
+ ```http
77
+ POST /developers/v1/app_version/list/:clientId
78
+ body: {"clientId":"cli_x"}
79
+ # data.versions[] {versionId,appVersion,versionStatus,publishTime,...}
80
+
81
+ POST /developers/v1/app_version/create/:clientId
82
+ body: {"clientId":"cli_x","appVersion":"1.0.0","updateRemark":"...",
83
+ "visibleSuggest":{},"changeLog":"..."}
84
+ # data.versionId
85
+
86
+ POST /developers/v1/publish/commit/:clientId/:versionId
87
+ body: {"clientId":"cli_x","versionId":"...","is_full_release":false}
88
+
89
+ POST /developers/v1/publish/release/:clientId/:versionId
90
+ body: {"clientId":"cli_x","versionId":"...","is_full_release":false}
91
+ ```
92
+
93
+ ## 证据
94
+
95
+ - 2026-11-15 使用 `geren` profile 实测:`POST /developers/v1/app/list` 返回 `code:0`,15 个应用。
96
+ - 创建测试应用 `larkit-e2e-20261115` -> `cli_aa73eb66fcb89cf4`,返回 `code:0`。
97
+ - `POST /developers/v1/secret/:clientId` 返回 32 字符 secret。
98
+ - `switch_robot` / `switch_event` / `switch_callback` 均返回 `code:0`;
99
+ 复查 `eventMode=4`、`callbackMode=4`。
100
+ - `scope/update` 添加 `im:message` 返回 `code:0`。
101
+ - `app_version/create` + `publish/commit` 返回 `code:0`,版本 `1.0.0` 进入提交状态。
102
+ - 浏览器兜底实测捕获到 `/developers/v1/tenant_app/auth_info/get`、
103
+ `/developers/v1/message/unread_list` 等真实请求,并写入
104
+ `.larkit-data/console-api-recipes.json`。
@@ -0,0 +1,180 @@
1
+ /**
2
+ * larkit truth channel API (Cloudflare Worker).
3
+ *
4
+ * A single stable URL that always answers "what is the current truth pack, and
5
+ * where do I download it from":
6
+ *
7
+ * GET /v1/truth/latest → manifest of the current pack (schemaVersion 1)
8
+ * GET /v1/truth/<tag> → manifest of a pinned pack (immutable)
9
+ * GET /v1/truth → index: every published tag + sha256
10
+ * GET /health → liveness
11
+ *
12
+ * The Worker reads the objects that `scripts/publish-pack.mjs` writes into the
13
+ * R2 bucket (manifest.json / <tag>.json / index.json) and turns them into an
14
+ * API response: no duplicated state, no rebuild step, publishing stays one
15
+ * upload. `ETag` is the archive sha256, so clients can do conditional GETs.
16
+ */
17
+
18
+ const CACHE_LATEST = "public, max-age=60, stale-while-revalidate=600";
19
+ const CACHE_IMMUTABLE = "public, max-age=31536000, immutable";
20
+
21
+ function jsonHeaders(extra = {}) {
22
+ return {
23
+ "content-type": "application/json; charset=utf-8",
24
+ "access-control-allow-origin": "*",
25
+ "access-control-allow-headers": "if-none-match, content-type",
26
+ "access-control-allow-methods": "GET, HEAD, OPTIONS",
27
+ ...extra,
28
+ };
29
+ }
30
+
31
+ function json(payload, { status = 200, headers = {} } = {}) {
32
+ return new Response(`${JSON.stringify(payload, null, 2)}\n`, {
33
+ status,
34
+ headers: jsonHeaders(headers),
35
+ });
36
+ }
37
+
38
+ function fail(status, message) {
39
+ return json({ error: message, status }, { status });
40
+ }
41
+
42
+ function archiveSha(manifest) {
43
+ return (
44
+ manifest?.tiers?.full?.sha256 ||
45
+ manifest?.tiers?.core?.sha256 ||
46
+ null
47
+ );
48
+ }
49
+
50
+ /** Pin absolute download URLs to the public bucket host, if configured. */
51
+ function rewriteUrls(manifest, base) {
52
+ if (!base) return manifest;
53
+ const root = String(base).replace(/\/+$/, "");
54
+ for (const tier of Object.values(manifest.tiers || {})) {
55
+ if (!tier?.file) continue;
56
+ tier.url = `${root}/${tier.file}`;
57
+ }
58
+ return manifest;
59
+ }
60
+
61
+ async function readJson(env, key) {
62
+ const object = await env.TRUTH.get(key);
63
+ if (!object) return null;
64
+ const text = await object.text();
65
+ try {
66
+ return JSON.parse(text);
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
71
+
72
+ function decodeTag(tag) {
73
+ return /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(tag) ? tag : null;
74
+ }
75
+
76
+ async function serveManifest(request, env, key, { immutable }) {
77
+ const manifest = await readJson(env, key);
78
+ if (!manifest) return fail(404, `no such pack manifest: ${key}`);
79
+
80
+ const payload = rewriteUrls({ ...manifest }, env.PUBLIC_BASE);
81
+ const sha = archiveSha(payload);
82
+ const tag = payload.packVersion || payload.larkitVersion || null;
83
+ const etag = sha ? `"${sha.slice(0, 32)}"` : null;
84
+ if (etag && request.headers.get("if-none-match") === etag) {
85
+ return new Response(null, { status: 304, headers: jsonHeaders({ etag, "cache-control": immutable ? CACHE_IMMUTABLE : CACHE_LATEST }) });
86
+ }
87
+
88
+ const headers = {
89
+ "cache-control": immutable ? CACHE_IMMUTABLE : CACHE_LATEST,
90
+ "x-larkit-pack": tag || "unknown",
91
+ };
92
+ if (etag) headers.etag = etag;
93
+ return json(
94
+ {
95
+ ...payload,
96
+ channel: "stable",
97
+ api: { version: 1, servedAt: new Date().toISOString(), tag },
98
+ },
99
+ { headers },
100
+ );
101
+ }
102
+
103
+ async function serveIndex(env) {
104
+ const index = await readJson(env, "index.json");
105
+ if (!index) return fail(503, "channel index is not published yet");
106
+ const base = new URL(env.PUBLIC_API || "https://larkit-api.tapeless.eu.org");
107
+ const packs = (index.packs || []).map((entry) => ({
108
+ tag: entry.tag,
109
+ builtAt: entry.builtAt || null,
110
+ larkitVersion: entry.larkitVersion || null,
111
+ clientVersion: entry.clientVersion || null,
112
+ stats: entry.stats || null,
113
+ manifest: entry.tag ? `${base.origin}/v1/truth/${entry.tag}` : null,
114
+ tiers: entry.tiers || {},
115
+ }));
116
+ return json(
117
+ {
118
+ api: { version: 1, servedAt: new Date().toISOString() },
119
+ latest: index.latest || packs[packs.length - 1]?.tag || null,
120
+ packs,
121
+ },
122
+ { headers: { "cache-control": CACHE_LATEST } },
123
+ );
124
+ }
125
+
126
+ export default {
127
+ async fetch(request, env) {
128
+ const url = new URL(request.url);
129
+ if (request.method === "OPTIONS") {
130
+ return new Response(null, { status: 204, headers: jsonHeaders() });
131
+ }
132
+ if (request.method !== "GET" && request.method !== "HEAD") {
133
+ return fail(405, "method not allowed");
134
+ }
135
+
136
+ const path = url.pathname.replace(/\/+$/, "");
137
+
138
+ // Tombstone for the retired core/code split archives. The objects are
139
+ // deleted from R2, but the edge may still hold `immutable` copies for up to
140
+ // a year; a Worker route bypasses that cache and answers immediately.
141
+ if (/^\/larkit-truth-(core|code)-/.test(path)) {
142
+ return json(
143
+ {
144
+ error: "gone",
145
+ status: 410,
146
+ message:
147
+ "The core/code split packs were retired. larkit now installs one unified archive: " +
148
+ "GET /v1/truth/latest for the current pack manifest.",
149
+ },
150
+ { status: 410, headers: { "cache-control": "public, max-age=300" } },
151
+ );
152
+ }
153
+
154
+ if (path === "/health") {
155
+ return json({ ok: true, service: "larkit-truth", time: new Date().toISOString() });
156
+ }
157
+ if (path === "" || path === "/") {
158
+ return json({
159
+ service: "larkit-truth",
160
+ docs: "https://github.com/…", // placeholder, see README
161
+ endpoints: {
162
+ latest: "/v1/truth/latest",
163
+ pinned: "/v1/truth/<tag>",
164
+ index: "/v1/truth",
165
+ health: "/health",
166
+ },
167
+ });
168
+ }
169
+ if (!path.startsWith("/v1/truth")) return fail(404, "not found");
170
+
171
+ const rest = path.slice("/v1/truth".length).replace(/^\//, "");
172
+ if (!rest || rest === "index") return serveIndex(env);
173
+ if (rest === "latest") {
174
+ return serveManifest(request, env, "manifest.json", { immutable: false });
175
+ }
176
+ const tag = decodeTag(rest);
177
+ if (!tag) return fail(400, "invalid tag");
178
+ return serveManifest(request, env, `${tag}.json`, { immutable: true });
179
+ },
180
+ };
@@ -0,0 +1,31 @@
1
+ name = "larkit-truth-api"
2
+ main = "src/index.js"
3
+ compatibility_date = "2025-01-01"
4
+ workers_dev = true
5
+
6
+ # 真相源对象(manifest.json / <tag>.json / index.json + tar.gz 归档)
7
+ [[r2_buckets]]
8
+ binding = "TRUTH"
9
+ bucket_name = "larkit-truth"
10
+
11
+ [vars]
12
+ # 归档的公开域名(R2 自定义域名);Worker 会把 manifest 里的下载链接重写到它上面
13
+ PUBLIC_BASE = "https://larkit.tapeless.eu.org"
14
+ # 本 API 自己的公开地址,用于在 index 里生成 <tag> 的 API 链接
15
+ PUBLIC_API = "https://larkit.tapeless.eu.org"
16
+
17
+ # 固定入口:https://larkit.tapeless.eu.org/v1/truth/latest
18
+ # 首选入口:和下载域名同一个 host,只管 /v1/*,其余路径继续由 R2 提供
19
+ [[routes]]
20
+ pattern = "larkit.tapeless.eu.org/v1/*"
21
+ zone_name = "tapeless.eu.org"
22
+
23
+ # 已下线的 core/code 分体归档:对象已从 R2 删除,但边缘仍可能命中 immutable 缓存
24
+ # (最长 1 年)。这两条 route 让 Worker 直接回 410,绕开缓存。
25
+ [[routes]]
26
+ pattern = "larkit.tapeless.eu.org/larkit-truth-core-*"
27
+ zone_name = "tapeless.eu.org"
28
+
29
+ [[routes]]
30
+ pattern = "larkit.tapeless.eu.org/larkit-truth-code-*"
31
+ zone_name = "tapeless.eu.org"