@seamnet/client 0.12.4
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 +74 -0
- package/bin/cli.js +99 -0
- package/bin/seam.js +406 -0
- package/lib/api.js +18 -0
- package/lib/autostart.js +48 -0
- package/lib/contracts/actions.cjs +53 -0
- package/lib/errors.cjs +66 -0
- package/lib/guardian-core.cjs +200 -0
- package/lib/guardian.js +261 -0
- package/lib/hub.cjs +140 -0
- package/lib/init.js +265 -0
- package/lib/mcp-server.cjs +250 -0
- package/lib/paths.js +11 -0
- package/lib/plugins/im/README.md +41 -0
- package/lib/plugins/im/index.cjs +634 -0
- package/lib/plugins/scheduler/index.cjs +215 -0
- package/lib/plugins/wechat/README.md +54 -0
- package/lib/plugins/wechat/index.cjs +736 -0
- package/lib/services/README.md +93 -0
- package/lib/services/event-bus/README.md +76 -0
- package/lib/services/event-bus/index.cjs +90 -0
- package/lib/services/inbox/README.md +52 -0
- package/lib/services/inbox/index.cjs +168 -0
- package/lib/services/logger/README.md +81 -0
- package/lib/services/logger/index.cjs +109 -0
- package/lib/services/message-buffer/README.md +46 -0
- package/lib/services/message-buffer/index.cjs +100 -0
- package/lib/services/state/README.md +71 -0
- package/lib/services/state/index.cjs +138 -0
- package/lib/status.js +38 -0
- package/lib/stop.js +11 -0
- package/lib/upgrade.js +105 -0
- package/package.json +38 -0
- package/templates/CHANNEL_RULES.md +71 -0
- package/templates/IDENTITY.md +31 -0
- package/templates/README.md +58 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Seam Scheduler Plugin — 心跳 + 定时自注入。
|
|
3
|
+
*
|
|
4
|
+
* 契约(Plugin v1):
|
|
5
|
+
* - Actions:
|
|
6
|
+
* self_schedule : 创建/更新重复 schedule
|
|
7
|
+
* self_cancel : 取消某个 schedule
|
|
8
|
+
* self_list : 列出所有活跃 schedule
|
|
9
|
+
*
|
|
10
|
+
* 内置行为:
|
|
11
|
+
* - init 时从 state 恢复已有 schedules
|
|
12
|
+
* - 如果没有 id=heartbeat 的 schedule,自动创建默认心跳
|
|
13
|
+
* - 心跳内容从 .seam/heartbeat.md 读(每次 fire 重新读,改文件即生效)
|
|
14
|
+
*
|
|
15
|
+
* 错误码:
|
|
16
|
+
* - SCHEDULER_BAD_PAYLOAD : 参数缺失
|
|
17
|
+
* - SCHEDULER_TOO_MANY : schedule 数量超限
|
|
18
|
+
* - SCHEDULER_NOT_FOUND : cancel 时 id 不存在
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const fs = require('node:fs');
|
|
22
|
+
const path = require('node:path');
|
|
23
|
+
const { seamError } = require('../../errors.cjs');
|
|
24
|
+
const { validatePayload } = require('../../contracts/actions.cjs');
|
|
25
|
+
|
|
26
|
+
const MAX_SCHEDULES = 10;
|
|
27
|
+
const MIN_INTERVAL = 60000; // 1 min
|
|
28
|
+
const MAX_INTERVAL = 86400000; // 24 h
|
|
29
|
+
const DEFAULT_HEARTBEAT_MS = 600000; // 10 min
|
|
30
|
+
|
|
31
|
+
function createSchedulerPlugin() {
|
|
32
|
+
let hub = null;
|
|
33
|
+
let log = null;
|
|
34
|
+
let stateScope = null;
|
|
35
|
+
let seamDir = '';
|
|
36
|
+
const timers = new Map(); // id → intervalId
|
|
37
|
+
|
|
38
|
+
async function init(_hub) {
|
|
39
|
+
hub = _hub;
|
|
40
|
+
log = hub.logger('scheduler');
|
|
41
|
+
stateScope = hub.service('state').scope('scheduler');
|
|
42
|
+
seamDir = hub.seamDir || process.cwd();
|
|
43
|
+
|
|
44
|
+
const saved = stateScope.all();
|
|
45
|
+
const schedules = saved.schedules || {};
|
|
46
|
+
|
|
47
|
+
// 恢复已有 schedules
|
|
48
|
+
for (const [id, cfg] of Object.entries(schedules)) {
|
|
49
|
+
if (cfg.active) {
|
|
50
|
+
startTimer(id, cfg);
|
|
51
|
+
log.info('schedule_restored', { id, every_ms: cfg.every_ms });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 心跳模板文件准备好(但不自动启用——AI 自己决定要不要开)
|
|
56
|
+
ensureHeartbeatFile(path.join(seamDir, 'heartbeat.md'));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function readConfigHeartbeatInterval() {
|
|
60
|
+
try {
|
|
61
|
+
const cfgPath = path.join(seamDir, 'config.json');
|
|
62
|
+
if (fs.existsSync(cfgPath)) {
|
|
63
|
+
const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
|
|
64
|
+
if (cfg.heartbeat?.interval_ms) {
|
|
65
|
+
return clampInterval(cfg.heartbeat.interval_ms);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
} catch {}
|
|
69
|
+
return DEFAULT_HEARTBEAT_MS;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function ensureHeartbeatFile(filePath) {
|
|
73
|
+
if (fs.existsSync(filePath)) return;
|
|
74
|
+
const defaultContent = '[心跳] 心跳。你在做什么?想做什么?';
|
|
75
|
+
try {
|
|
76
|
+
fs.writeFileSync(filePath, defaultContent);
|
|
77
|
+
} catch {}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function clampInterval(ms) {
|
|
81
|
+
return Math.max(MIN_INTERVAL, Math.min(MAX_INTERVAL, parseInt(ms, 10) || DEFAULT_HEARTBEAT_MS));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function resolveText(cfg) {
|
|
85
|
+
if (cfg.text_file) {
|
|
86
|
+
try {
|
|
87
|
+
return fs.readFileSync(cfg.text_file, 'utf8').trim();
|
|
88
|
+
} catch {
|
|
89
|
+
return `[scheduler] 无法读取 ${cfg.text_file}`;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return cfg.text || '';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function startTimer(id, cfg) {
|
|
96
|
+
if (timers.has(id)) {
|
|
97
|
+
clearInterval(timers.get(id));
|
|
98
|
+
}
|
|
99
|
+
const intervalId = setInterval(() => {
|
|
100
|
+
const text = resolveText(cfg);
|
|
101
|
+
if (text) {
|
|
102
|
+
hub.inject(text);
|
|
103
|
+
log.info('schedule_fired', { id, text_len: text.length });
|
|
104
|
+
}
|
|
105
|
+
}, cfg.every_ms);
|
|
106
|
+
timers.set(id, intervalId);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function stopTimer(id) {
|
|
110
|
+
if (timers.has(id)) {
|
|
111
|
+
clearInterval(timers.get(id));
|
|
112
|
+
timers.delete(id);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function saveSchedule(id, cfg) {
|
|
117
|
+
const all = stateScope.get('schedules') || {};
|
|
118
|
+
all[id] = cfg;
|
|
119
|
+
stateScope.set('schedules', all);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function removeSchedule(id) {
|
|
123
|
+
const all = stateScope.get('schedules') || {};
|
|
124
|
+
delete all[id];
|
|
125
|
+
stateScope.set('schedules', all);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function getSchedules() {
|
|
129
|
+
return stateScope.get('schedules') || {};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function handleRequest(req) {
|
|
133
|
+
const check = validatePayload(req.action, req);
|
|
134
|
+
if (!check.ok) {
|
|
135
|
+
throw seamError({ code: 'SCHEDULER_BAD_PAYLOAD', message: check.error });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (req.action === 'self_schedule') {
|
|
139
|
+
const id = req.id;
|
|
140
|
+
const everyMs = clampInterval(req.every_ms);
|
|
141
|
+
const schedules = getSchedules();
|
|
142
|
+
|
|
143
|
+
if (!schedules[id] && Object.keys(schedules).length >= MAX_SCHEDULES) {
|
|
144
|
+
throw seamError({ code: 'SCHEDULER_TOO_MANY', message: `最多 ${MAX_SCHEDULES} 个 schedule` });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const cfg = {
|
|
148
|
+
every_ms: everyMs,
|
|
149
|
+
active: true,
|
|
150
|
+
created_at: schedules[id]?.created_at || new Date().toISOString(),
|
|
151
|
+
updated_at: new Date().toISOString(),
|
|
152
|
+
};
|
|
153
|
+
if (req.text_file) cfg.text_file = req.text_file;
|
|
154
|
+
else if (req.text) cfg.text = req.text;
|
|
155
|
+
else if (schedules[id]?.text_file) cfg.text_file = schedules[id].text_file;
|
|
156
|
+
else if (schedules[id]?.text) cfg.text = schedules[id].text;
|
|
157
|
+
|
|
158
|
+
saveSchedule(id, cfg);
|
|
159
|
+
startTimer(id, cfg);
|
|
160
|
+
log.info('schedule_set', { id, every_ms: everyMs });
|
|
161
|
+
return { ok: true, id, every_ms: everyMs };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (req.action === 'self_cancel') {
|
|
165
|
+
const id = req.id;
|
|
166
|
+
const schedules = getSchedules();
|
|
167
|
+
if (!schedules[id]) {
|
|
168
|
+
throw seamError({ code: 'SCHEDULER_NOT_FOUND', message: `schedule "${id}" not found` });
|
|
169
|
+
}
|
|
170
|
+
stopTimer(id);
|
|
171
|
+
removeSchedule(id);
|
|
172
|
+
log.info('schedule_cancelled', { id });
|
|
173
|
+
return { ok: true, cancelled: id };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (req.action === 'self_list') {
|
|
177
|
+
const schedules = getSchedules();
|
|
178
|
+
const list = Object.entries(schedules).map(([id, cfg]) => ({
|
|
179
|
+
id,
|
|
180
|
+
every_ms: cfg.every_ms,
|
|
181
|
+
every_human: humanInterval(cfg.every_ms),
|
|
182
|
+
active: cfg.active,
|
|
183
|
+
source: cfg.text_file ? `file:${cfg.text_file}` : `text:${(cfg.text || '').slice(0, 50)}`,
|
|
184
|
+
created_at: cfg.created_at,
|
|
185
|
+
}));
|
|
186
|
+
return { ok: true, schedules: list };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
throw seamError({ code: 'SCHEDULER_UNKNOWN_ACTION', message: `unknown: ${req.action}` });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function humanInterval(ms) {
|
|
193
|
+
if (ms >= 3600000) return `${(ms / 3600000).toFixed(1)}h`;
|
|
194
|
+
if (ms >= 60000) return `${(ms / 60000).toFixed(0)}m`;
|
|
195
|
+
return `${(ms / 1000).toFixed(0)}s`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function destroy() {
|
|
199
|
+
for (const [id, intervalId] of timers) {
|
|
200
|
+
clearInterval(intervalId);
|
|
201
|
+
}
|
|
202
|
+
timers.clear();
|
|
203
|
+
if (log) log.info('scheduler_destroyed');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
name: 'scheduler',
|
|
208
|
+
actions: ['self_schedule', 'self_cancel', 'self_list'],
|
|
209
|
+
init,
|
|
210
|
+
handleRequest,
|
|
211
|
+
destroy,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
module.exports = { createSchedulerPlugin };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# WeChat Plugin
|
|
2
|
+
|
|
3
|
+
微信 iLink Bot API 适配器。
|
|
4
|
+
|
|
5
|
+
## Actions
|
|
6
|
+
|
|
7
|
+
| action | 参数 | 说明 |
|
|
8
|
+
|---|---|---|
|
|
9
|
+
| `wechat_send` | `{text}` | 向绑定的微信用户发消息(需 context_token 已刷新) |
|
|
10
|
+
| `wechat_login` | `{fromUserId?}` | 启动扫码登录流程(二维码会通过 IM 发给 fromUserId) |
|
|
11
|
+
| `wechat_status` | `{}` | 返回 `{bound, status, user}` |
|
|
12
|
+
|
|
13
|
+
## 触发机制
|
|
14
|
+
|
|
15
|
+
IM 收到"绑定微信"文本 → `im.onMessage` 回调检测 → 自动启动 `startLogin(fromUserId)`。
|
|
16
|
+
|
|
17
|
+
## 消息注入前缀
|
|
18
|
+
|
|
19
|
+
- `📱 [微信]` — 微信消息注入终端
|
|
20
|
+
|
|
21
|
+
## 错误码
|
|
22
|
+
|
|
23
|
+
- `WECHAT_MISSING_IM_DEP` — init 时 hub.get('im') 为空
|
|
24
|
+
- `WECHAT_QR_FAILED` — 获取二维码失败
|
|
25
|
+
- `WECHAT_QR_EXPIRED` — 扫码超时(5 分钟)
|
|
26
|
+
- `WECHAT_SESSION_FROZEN` — bot 被风控(-14)
|
|
27
|
+
- `WECHAT_NOT_BOUND` — 未绑定或 context_token 为空
|
|
28
|
+
- `WECHAT_UNKNOWN_ACTION` — 未知 action
|
|
29
|
+
|
|
30
|
+
## 依赖
|
|
31
|
+
|
|
32
|
+
- `im` 插件:发二维码图片、订阅 IM 消息触发
|
|
33
|
+
- npm 包:`qrcode`(生成二维码图片)
|
|
34
|
+
|
|
35
|
+
## 状态持久化
|
|
36
|
+
|
|
37
|
+
`.seam/wechat-binding.json`:
|
|
38
|
+
```json
|
|
39
|
+
{
|
|
40
|
+
"bot_token": "...",
|
|
41
|
+
"ilink_user_id": "...",
|
|
42
|
+
"context_token": "...",
|
|
43
|
+
"updates_buf": "...",
|
|
44
|
+
"status": "active | pending_first_message | frozen"
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
文件权限 0600(避免同机其他用户读取)。
|
|
49
|
+
|
|
50
|
+
## 已知问题
|
|
51
|
+
|
|
52
|
+
- `context_token` 在每次收到用户消息时刷新——如果长时间没收到消息,发消息可能失败(需要用户主动发一条新的)
|
|
53
|
+
- `-14` 风控会让状态变 frozen 并暂停 1 小时自动恢复
|
|
54
|
+
- 跨天后 binding 可能失效——需要重新扫码
|