@zeyiy/openclaw-channel 0.3.4 → 0.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -1
- package/README.zh-CN.md +29 -1
- package/dist/portal.js +210 -21
- package/dist/types.d.ts +32 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -14,6 +14,9 @@ Chinese documentation: [README.zh-CN.md](https://github.com/ZeyiY/openclaw-chann
|
|
|
14
14
|
- Quote/reply message parsing for inbound context
|
|
15
15
|
- Multi-account login via `channels.openim.accounts.<id>`
|
|
16
16
|
- Group trigger policy with optional mention-only mode
|
|
17
|
+
- Auto read-receipt for direct messages
|
|
18
|
+
- Per-user session isolation (direct chat) / shared session (group chat)
|
|
19
|
+
- Agent Portal Bridge — persistent WebSocket connection to agent-portal cloud service
|
|
17
20
|
- Interactive setup command: `openclaw openim setup`
|
|
18
21
|
|
|
19
22
|
## Installation
|
|
@@ -58,7 +61,9 @@ openclaw openim setup
|
|
|
58
61
|
"enabled": true,
|
|
59
62
|
"token": "your_token",
|
|
60
63
|
"wsAddr": "ws://127.0.0.1:10001",
|
|
61
|
-
"apiAddr": "http://127.0.0.1:10002"
|
|
64
|
+
"apiAddr": "http://127.0.0.1:10002",
|
|
65
|
+
"botId": "my-bot-001",
|
|
66
|
+
"portalWsAddr": "wss://portal.example.com/ws"
|
|
62
67
|
}
|
|
63
68
|
}
|
|
64
69
|
}
|
|
@@ -75,6 +80,8 @@ If set, only these users can trigger processing:
|
|
|
75
80
|
- direct messages to the account
|
|
76
81
|
- group messages where they `@` the account
|
|
77
82
|
|
|
83
|
+
`botId` and `portalWsAddr` are optional. When both are set, the plugin establishes a WebSocket connection to the agent-portal cloud service, enabling remote management of agents, files, and models.
|
|
84
|
+
|
|
78
85
|
Single-account fallback (without `accounts`) is supported.
|
|
79
86
|
|
|
80
87
|
Environment fallback is supported for the `default` account:
|
|
@@ -113,6 +120,27 @@ Optional env overrides:
|
|
|
113
120
|
- `name` (optional): override filename for URL input
|
|
114
121
|
- `accountId` (optional): select sending account
|
|
115
122
|
|
|
123
|
+
## Agent Portal Bridge
|
|
124
|
+
|
|
125
|
+
When `botId` and `portalWsAddr` are configured, the plugin connects to the agent-portal cloud service via WebSocket. The portal can remotely invoke the following methods:
|
|
126
|
+
|
|
127
|
+
| Method | Description |
|
|
128
|
+
|---|---|
|
|
129
|
+
| `bot.agent.get` | Resolve the agentId bound to the current bot |
|
|
130
|
+
| `models.list` | List available models from config |
|
|
131
|
+
| `agents.list` | List all configured agents |
|
|
132
|
+
| `agents.create` | Create a new agent with workspace |
|
|
133
|
+
| `agents.files.list` | List workspace files for an agent |
|
|
134
|
+
| `agents.files.get` | Read a single workspace file |
|
|
135
|
+
| `agents.files.set` | Write a file to agent workspace |
|
|
136
|
+
| `tools.catalog` | List available tools |
|
|
137
|
+
| `skills.status` | List installed skills/plugins status |
|
|
138
|
+
| `skills.search` | Search ClawHub for skills (placeholder) |
|
|
139
|
+
| `skills.detail` | Get detail for a specific skill |
|
|
140
|
+
| `cron.list` | List configured cron jobs |
|
|
141
|
+
|
|
142
|
+
The connection features automatic reconnect with exponential backoff and heartbeat keepalive.
|
|
143
|
+
|
|
116
144
|
## Development
|
|
117
145
|
|
|
118
146
|
```bash
|
package/README.zh-CN.md
CHANGED
|
@@ -14,6 +14,9 @@ English documentation: [README.md](https://github.com/ZeyiY/openclaw-channel/blo
|
|
|
14
14
|
- 支持引用消息解析(用于入站上下文)
|
|
15
15
|
- 支持多账号并发(`channels.openim.accounts.<id>`)
|
|
16
16
|
- 支持群聊仅 @ 触发
|
|
17
|
+
- 私聊消息自动标记已读(已读回执)
|
|
18
|
+
- 每用户独立会话(私聊)/ 同群共享会话(群聊)
|
|
19
|
+
- Agent Portal Bridge — 与 agent-portal 云服务保持 WebSocket 长连接,支持远程管理
|
|
17
20
|
- 提供交互式配置命令:`openclaw openim setup`
|
|
18
21
|
|
|
19
22
|
## 安装
|
|
@@ -58,7 +61,9 @@ openclaw openim setup
|
|
|
58
61
|
"enabled": true,
|
|
59
62
|
"token": "your_token",
|
|
60
63
|
"wsAddr": "ws://127.0.0.1:10001",
|
|
61
|
-
"apiAddr": "http://127.0.0.1:10002"
|
|
64
|
+
"apiAddr": "http://127.0.0.1:10002",
|
|
65
|
+
"botId": "my-bot-001",
|
|
66
|
+
"portalWsAddr": "wss://portal.example.com/ws"
|
|
62
67
|
}
|
|
63
68
|
}
|
|
64
69
|
}
|
|
@@ -74,6 +79,8 @@ openclaw openim setup
|
|
|
74
79
|
- 给账号发单聊消息
|
|
75
80
|
- 在群里 @ 账号的消息
|
|
76
81
|
|
|
82
|
+
`botId` 和 `portalWsAddr` 为可选项。同时配置后,插件会与 agent-portal 云服务建立 WebSocket 连接,支持远程管理 agent、文件和模型。
|
|
83
|
+
|
|
77
84
|
支持单账号兜底写法(不使用 `accounts`)。
|
|
78
85
|
|
|
79
86
|
`default` 账号支持环境变量兜底:
|
|
@@ -112,6 +119,27 @@ openclaw openim setup
|
|
|
112
119
|
- `name`(可选):URL 输入时覆盖文件名
|
|
113
120
|
- `accountId`(可选):指定发送账号
|
|
114
121
|
|
|
122
|
+
## Agent Portal Bridge
|
|
123
|
+
|
|
124
|
+
配置 `botId` 和 `portalWsAddr` 后,插件会通过 WebSocket 连接到 agent-portal 云服务。Portal 可远程调用以下方法:
|
|
125
|
+
|
|
126
|
+
| 方法 | 说明 |
|
|
127
|
+
|---|---|
|
|
128
|
+
| `bot.agent.get` | 获取当前 bot 绑定的 agentId |
|
|
129
|
+
| `models.list` | 列出配置中的可用模型 |
|
|
130
|
+
| `agents.list` | 列出所有已配置的 agent |
|
|
131
|
+
| `agents.create` | 创建新 agent 及工作空间 |
|
|
132
|
+
| `agents.files.list` | 列出 agent 工作空间文件 |
|
|
133
|
+
| `agents.files.get` | 读取单个工作空间文件 |
|
|
134
|
+
| `agents.files.set` | 写入文件到 agent 工作空间 |
|
|
135
|
+
| `tools.catalog` | 列出可用工具 |
|
|
136
|
+
| `skills.status` | 列出已安装技能/插件状态 |
|
|
137
|
+
| `skills.search` | 搜索 ClawHub 技能(占位) |
|
|
138
|
+
| `skills.detail` | 获取特定技能详情 |
|
|
139
|
+
| `cron.list` | 列出已配置的定时任务 |
|
|
140
|
+
|
|
141
|
+
连接支持指数退避自动重连和心跳保活。
|
|
142
|
+
|
|
115
143
|
## 开发
|
|
116
144
|
|
|
117
145
|
```bash
|
package/dist/portal.js
CHANGED
|
@@ -207,26 +207,24 @@ async function handleAgentsFilesList(api, params) {
|
|
|
207
207
|
for (const name of AGENT_FILE_NAMES) {
|
|
208
208
|
const filePath = join(workspaceDir, name);
|
|
209
209
|
const meta = await statFileSafely(filePath);
|
|
210
|
-
if (meta)
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
catch {
|
|
216
|
-
// skip unreadable files
|
|
217
|
-
}
|
|
218
|
-
files.push({
|
|
219
|
-
name,
|
|
220
|
-
path: filePath,
|
|
221
|
-
missing: false,
|
|
222
|
-
size: meta.size,
|
|
223
|
-
updatedAtMs: meta.updatedAtMs,
|
|
224
|
-
content,
|
|
225
|
-
});
|
|
210
|
+
if (!meta)
|
|
211
|
+
continue;
|
|
212
|
+
let content;
|
|
213
|
+
try {
|
|
214
|
+
content = await readFile(filePath, "utf-8");
|
|
226
215
|
}
|
|
227
|
-
|
|
228
|
-
|
|
216
|
+
catch {
|
|
217
|
+
// skip unreadable files
|
|
218
|
+
continue;
|
|
229
219
|
}
|
|
220
|
+
files.push({
|
|
221
|
+
name,
|
|
222
|
+
path: filePath,
|
|
223
|
+
missing: false,
|
|
224
|
+
size: meta.size,
|
|
225
|
+
updatedAtMs: meta.updatedAtMs,
|
|
226
|
+
content,
|
|
227
|
+
});
|
|
230
228
|
}
|
|
231
229
|
portalLog(api, "info", `agents.files.list: agentId=${agentId} workspace=${workspaceDir} found=${files.filter(f => !f.missing).length}`);
|
|
232
230
|
return { agentId, workspace: workspaceDir, files };
|
|
@@ -350,15 +348,191 @@ async function handleAgentsCreate(api, params) {
|
|
|
350
348
|
portalLog(api, "info", `agents.create: agentId=${agentId} name=${rawName} workspace=${workspaceDir}`);
|
|
351
349
|
return { ok: true, agentId, name: rawName, workspace: workspaceDir };
|
|
352
350
|
}
|
|
351
|
+
/**
|
|
352
|
+
* bot.agent.get — resolve the agentId bound to the current bot connection.
|
|
353
|
+
*
|
|
354
|
+
* Lookup order:
|
|
355
|
+
* 1. Config bindings: match channel=openim + accountId
|
|
356
|
+
* 2. Fallback to default agent
|
|
357
|
+
*/
|
|
358
|
+
function handleBotAgentGet(api, accountId) {
|
|
359
|
+
const cfg = getConfig(api);
|
|
360
|
+
const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : [];
|
|
361
|
+
// 1. Check bindings
|
|
362
|
+
const bindings = Array.isArray(cfg.bindings) ? cfg.bindings : [];
|
|
363
|
+
for (const b of bindings) {
|
|
364
|
+
if (b?.match?.channel === "openim" &&
|
|
365
|
+
b?.match?.accountId === accountId &&
|
|
366
|
+
b?.agentId) {
|
|
367
|
+
const agentId = normalizeAgentId(b.agentId);
|
|
368
|
+
const entry = agents.find((a) => a?.id && normalizeAgentId(a.id) === agentId);
|
|
369
|
+
return { agentId, ...(entry?.name ? { name: entry.name } : {}) };
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
// 2. Fallback to default agent
|
|
373
|
+
const defaultId = resolveDefaultAgentId(cfg);
|
|
374
|
+
const defaultEntry = agents.find((a) => a?.id && normalizeAgentId(a.id) === defaultId);
|
|
375
|
+
return { agentId: defaultId, ...(defaultEntry?.name ? { name: defaultEntry.name } : {}) };
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* tools.catalog — return the runtime tool catalog from config.
|
|
379
|
+
* Reads tools from agents' TOOLS.md references and registered plugin tools.
|
|
380
|
+
*/
|
|
381
|
+
function handleToolsCatalog(api) {
|
|
382
|
+
const cfg = getConfig(api);
|
|
383
|
+
const tools = [];
|
|
384
|
+
// Core tools from config.tools section
|
|
385
|
+
const configTools = cfg.tools;
|
|
386
|
+
if (configTools && typeof configTools === "object") {
|
|
387
|
+
for (const [name, toolCfg] of Object.entries(configTools)) {
|
|
388
|
+
if (!name || name.startsWith("_"))
|
|
389
|
+
continue;
|
|
390
|
+
tools.push({
|
|
391
|
+
name,
|
|
392
|
+
description: toolCfg?.description ?? "",
|
|
393
|
+
source: "core",
|
|
394
|
+
optional: Boolean(toolCfg?.optional),
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
// Plugin-registered tools (from channel registrations)
|
|
399
|
+
const plugins = cfg.plugins;
|
|
400
|
+
if (plugins && typeof plugins === "object") {
|
|
401
|
+
for (const [pluginId, pluginCfg] of Object.entries(plugins)) {
|
|
402
|
+
if (!pluginId)
|
|
403
|
+
continue;
|
|
404
|
+
const pluginTools = pluginCfg?.tools;
|
|
405
|
+
if (Array.isArray(pluginTools)) {
|
|
406
|
+
for (const t of pluginTools) {
|
|
407
|
+
const tName = typeof t === "string" ? t : t?.name;
|
|
408
|
+
if (!tName)
|
|
409
|
+
continue;
|
|
410
|
+
tools.push({
|
|
411
|
+
name: String(tName),
|
|
412
|
+
description: typeof t === "object" ? t?.description ?? "" : "",
|
|
413
|
+
source: "plugin",
|
|
414
|
+
pluginId,
|
|
415
|
+
optional: typeof t === "object" ? Boolean(t?.optional) : false,
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
// Also include OpenIM channel tools registered by this plugin
|
|
422
|
+
const openimTools = ["openim_send_text", "openim_send_image", "openim_send_file", "openim_send_video"];
|
|
423
|
+
for (const name of openimTools) {
|
|
424
|
+
tools.push({ name, description: `OpenIM: ${name.replace("openim_", "")}`, source: "plugin", pluginId: "openim" });
|
|
425
|
+
}
|
|
426
|
+
return { tools };
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* skills.status — return the visible skill list for an agent.
|
|
430
|
+
* Reads installed plugins/skills from config and reports their status.
|
|
431
|
+
*/
|
|
432
|
+
function handleSkillsStatus(api, params) {
|
|
433
|
+
const cfg = getConfig(api);
|
|
434
|
+
const skills = [];
|
|
435
|
+
// Plugins as skills
|
|
436
|
+
const plugins = cfg.plugins;
|
|
437
|
+
if (plugins && typeof plugins === "object") {
|
|
438
|
+
for (const [pluginId, pluginCfg] of Object.entries(plugins)) {
|
|
439
|
+
if (!pluginId)
|
|
440
|
+
continue;
|
|
441
|
+
const enabled = pluginCfg?.enabled !== false;
|
|
442
|
+
skills.push({
|
|
443
|
+
name: pluginId,
|
|
444
|
+
description: pluginCfg?.description ?? "",
|
|
445
|
+
installed: true,
|
|
446
|
+
enabled,
|
|
447
|
+
configCheck: enabled ? "ok" : "missing",
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
// Channels as skills
|
|
452
|
+
const channels = cfg.channels;
|
|
453
|
+
if (channels && typeof channels === "object") {
|
|
454
|
+
for (const [chanId, chanCfg] of Object.entries(channels)) {
|
|
455
|
+
if (!chanId)
|
|
456
|
+
continue;
|
|
457
|
+
skills.push({
|
|
458
|
+
name: `channel:${chanId}`,
|
|
459
|
+
description: `Channel: ${chanId}`,
|
|
460
|
+
installed: true,
|
|
461
|
+
enabled: chanCfg?.enabled !== false,
|
|
462
|
+
configCheck: "ok",
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return { skills };
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* skills.search — search ClawHub for discoverable skills.
|
|
470
|
+
* Currently returns an empty list (ClawHub integration not yet implemented locally).
|
|
471
|
+
*/
|
|
472
|
+
function handleSkillsSearch(_api, params) {
|
|
473
|
+
// ClawHub discovery requires network access to the registry — placeholder for now
|
|
474
|
+
return { skills: [] };
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* skills.detail — get detail for a specific skill from ClawHub.
|
|
478
|
+
* Placeholder: returns basic info from local config if the skill is installed.
|
|
479
|
+
*/
|
|
480
|
+
function handleSkillsDetail(api, params) {
|
|
481
|
+
const name = String(params.name ?? "").trim();
|
|
482
|
+
if (!name)
|
|
483
|
+
throw { code: 400, message: "name is required" };
|
|
484
|
+
const cfg = getConfig(api);
|
|
485
|
+
const plugins = cfg.plugins;
|
|
486
|
+
if (plugins && typeof plugins === "object" && name in plugins) {
|
|
487
|
+
const p = plugins[name];
|
|
488
|
+
return {
|
|
489
|
+
skill: {
|
|
490
|
+
name,
|
|
491
|
+
description: p?.description ?? "",
|
|
492
|
+
author: p?.author ?? undefined,
|
|
493
|
+
version: p?.version ?? undefined,
|
|
494
|
+
source: "local",
|
|
495
|
+
},
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
return { skill: null };
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* cron.list — return configured cron jobs.
|
|
502
|
+
*/
|
|
503
|
+
function handleCronList(api) {
|
|
504
|
+
const cfg = getConfig(api);
|
|
505
|
+
const jobs = [];
|
|
506
|
+
const cronConfig = cfg.cron ?? cfg.automation?.cron;
|
|
507
|
+
if (Array.isArray(cronConfig)) {
|
|
508
|
+
for (const entry of cronConfig) {
|
|
509
|
+
if (!entry || typeof entry !== "object")
|
|
510
|
+
continue;
|
|
511
|
+
jobs.push({
|
|
512
|
+
id: String(entry.id ?? entry.name ?? `cron-${jobs.length}`),
|
|
513
|
+
name: entry.name ?? entry.id ?? undefined,
|
|
514
|
+
schedule: String(entry.schedule ?? entry.cron ?? ""),
|
|
515
|
+
command: entry.command ?? entry.text ?? entry.message ?? undefined,
|
|
516
|
+
enabled: entry.enabled !== false,
|
|
517
|
+
lastRun: entry.lastRun ?? undefined,
|
|
518
|
+
nextRun: entry.nextRun ?? undefined,
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
return { jobs };
|
|
523
|
+
}
|
|
353
524
|
// ---------------------------------------------------------------------------
|
|
354
525
|
// Request dispatch
|
|
355
526
|
// ---------------------------------------------------------------------------
|
|
356
|
-
async function handlePortalRequest(api, request) {
|
|
527
|
+
async function handlePortalRequest(api, accountId, request) {
|
|
357
528
|
const { id, method, params } = request;
|
|
358
|
-
portalLog(api, "info", `request received: id=${id} method=${method} params=${JSON.stringify(params)}`);
|
|
529
|
+
portalLog(api, "info", `request received: id=${id} method=${method} params=${JSON.stringify(params)} accountId=${accountId}`);
|
|
359
530
|
try {
|
|
360
531
|
let result;
|
|
361
532
|
switch (method) {
|
|
533
|
+
case "bot.agent.get":
|
|
534
|
+
result = handleBotAgentGet(api, accountId);
|
|
535
|
+
break;
|
|
362
536
|
case "models.list":
|
|
363
537
|
result = handleModelsList(api, params ?? {});
|
|
364
538
|
break;
|
|
@@ -377,6 +551,21 @@ async function handlePortalRequest(api, request) {
|
|
|
377
551
|
case "agents.create":
|
|
378
552
|
result = await handleAgentsCreate(api, params ?? {});
|
|
379
553
|
break;
|
|
554
|
+
case "tools.catalog":
|
|
555
|
+
result = handleToolsCatalog(api);
|
|
556
|
+
break;
|
|
557
|
+
case "skills.status":
|
|
558
|
+
result = handleSkillsStatus(api, params ?? {});
|
|
559
|
+
break;
|
|
560
|
+
case "skills.search":
|
|
561
|
+
result = handleSkillsSearch(api, params ?? {});
|
|
562
|
+
break;
|
|
563
|
+
case "skills.detail":
|
|
564
|
+
result = handleSkillsDetail(api, params ?? {});
|
|
565
|
+
break;
|
|
566
|
+
case "cron.list":
|
|
567
|
+
result = handleCronList(api);
|
|
568
|
+
break;
|
|
380
569
|
case "ping":
|
|
381
570
|
result = { pong: true };
|
|
382
571
|
break;
|
|
@@ -451,7 +640,7 @@ function connectPortal(api, bridge) {
|
|
|
451
640
|
portalLog(api, "warn", `malformed request from portal: missing id or method`);
|
|
452
641
|
return;
|
|
453
642
|
}
|
|
454
|
-
const response = await handlePortalRequest(api, request);
|
|
643
|
+
const response = await handlePortalRequest(api, bridge.accountId, request);
|
|
455
644
|
sendResponse(ws, response);
|
|
456
645
|
portalLog(api, "debug", `response sent: id=${request.id} method=${request.method} ok=${!response.error}`);
|
|
457
646
|
});
|
package/dist/types.d.ts
CHANGED
|
@@ -39,7 +39,38 @@ export interface InboundBodyResult {
|
|
|
39
39
|
kind: "text" | "image" | "video" | "file" | "mixed" | "unknown";
|
|
40
40
|
media?: InboundMediaItem[];
|
|
41
41
|
}
|
|
42
|
-
export type PortalMethod = "models.list" | "agents.list" | "agents.files.list" | "agents.files.get" | "agents.files.set" | "agents.create" | "ping";
|
|
42
|
+
export type PortalMethod = "bot.agent.get" | "models.list" | "agents.list" | "agents.files.list" | "agents.files.get" | "agents.files.set" | "agents.create" | "tools.catalog" | "skills.status" | "skills.search" | "skills.detail" | "cron.list" | "ping";
|
|
43
|
+
export interface ToolCatalogEntry {
|
|
44
|
+
name: string;
|
|
45
|
+
description?: string;
|
|
46
|
+
source: "core" | "plugin";
|
|
47
|
+
pluginId?: string;
|
|
48
|
+
optional?: boolean;
|
|
49
|
+
}
|
|
50
|
+
export interface SkillStatusEntry {
|
|
51
|
+
name: string;
|
|
52
|
+
description?: string;
|
|
53
|
+
installed: boolean;
|
|
54
|
+
enabled: boolean;
|
|
55
|
+
configCheck?: "ok" | "missing" | "invalid";
|
|
56
|
+
missingRequirements?: string[];
|
|
57
|
+
}
|
|
58
|
+
export interface SkillSearchEntry {
|
|
59
|
+
name: string;
|
|
60
|
+
description?: string;
|
|
61
|
+
author?: string;
|
|
62
|
+
version?: string;
|
|
63
|
+
source: string;
|
|
64
|
+
}
|
|
65
|
+
export interface CronJobEntry {
|
|
66
|
+
id: string;
|
|
67
|
+
name?: string;
|
|
68
|
+
schedule: string;
|
|
69
|
+
command?: string;
|
|
70
|
+
enabled: boolean;
|
|
71
|
+
lastRun?: number;
|
|
72
|
+
nextRun?: number;
|
|
73
|
+
}
|
|
43
74
|
export interface PortalRequest {
|
|
44
75
|
id: string;
|
|
45
76
|
method: PortalMethod;
|
package/openclaw.plugin.json
CHANGED