openclaw-linso 1.0.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.md +66 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +35 -0
- package/dist/src/channel.d.ts +3 -0
- package/dist/src/channel.js +118 -0
- package/dist/src/monitor.d.ts +7 -0
- package/dist/src/monitor.js +157 -0
- package/dist/src/relay-client.d.ts +10 -0
- package/dist/src/relay-client.js +59 -0
- package/dist/src/runtime.d.ts +3 -0
- package/dist/src/runtime.js +11 -0
- package/dist/src/server.d.ts +1 -0
- package/dist/src/server.js +1 -0
- package/dist/src/store.d.ts +2 -0
- package/dist/src/store.js +8 -0
- package/dist/src/types.d.ts +20 -0
- package/dist/src/types.js +16 -0
- package/openclaw.plugin.json +18 -0
- package/package.json +36 -0
- package/src/channel.ts +148 -0
- package/src/monitor.ts +195 -0
- package/src/relay-client.ts +72 -0
- package/src/runtime.ts +15 -0
- package/src/server.ts +4 -0
- package/src/store.ts +10 -0
- package/src/types.ts +38 -0
package/README.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# openclaw-linso
|
|
2
|
+
|
|
3
|
+
Linso iOS App 的 OpenClaw Channel Plugin,让你的 iPhone 直接和你的 AI 龙虾对话。
|
|
4
|
+
|
|
5
|
+
## 架构
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
iPhone (Linso App)
|
|
9
|
+
│ WebSocket + appToken
|
|
10
|
+
▼
|
|
11
|
+
Relay Server(云端中转)
|
|
12
|
+
│ WebSocket + appToken
|
|
13
|
+
▼
|
|
14
|
+
OpenClaw Gateway(你的龙虾)
|
|
15
|
+
│
|
|
16
|
+
▼
|
|
17
|
+
AI Agent
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## 安装
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
openclaw plugins install openclaw-linso
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## 配置
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
openclaw config set channels.linso.enabled true
|
|
30
|
+
openclaw config set channels.linso.relayUrl wss://relay.linsoapp.com
|
|
31
|
+
openclaw config set channels.linso.appToken <从 App 设置页复制的 Token>
|
|
32
|
+
openclaw gateway restart
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
或直接编辑 `~/.openclaw/openclaw.json`:
|
|
36
|
+
|
|
37
|
+
```json
|
|
38
|
+
{
|
|
39
|
+
"channels": {
|
|
40
|
+
"linso": {
|
|
41
|
+
"enabled": true,
|
|
42
|
+
"relayUrl": "wss://relay.linsoapp.com",
|
|
43
|
+
"appToken": "从 App 设置页复制的 Token"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## 使用步骤
|
|
50
|
+
|
|
51
|
+
1. 下载 Linso iOS App
|
|
52
|
+
2. 打开 App → 设置 → 填写 Relay 地址 → 自动获取 Token
|
|
53
|
+
3. 复制 Token,运行上面的配置命令
|
|
54
|
+
4. 重启龙虾,配对完成 ✅
|
|
55
|
+
|
|
56
|
+
## App 消息协议
|
|
57
|
+
|
|
58
|
+
| 事件 | 说明 |
|
|
59
|
+
|------|------|
|
|
60
|
+
| `connected` | 鉴权成功 |
|
|
61
|
+
| `run_start` | AI 开始生成 |
|
|
62
|
+
| `delta` | 流式增量文字 |
|
|
63
|
+
| `final` | 完整回复 |
|
|
64
|
+
| `done` | 本轮结束 |
|
|
65
|
+
| `plugin_status` | 龙虾连接状态变化 |
|
|
66
|
+
| `error` | 错误信息 |
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
|
+
export { monitorLinsoProvider } from "./src/monitor.js";
|
|
3
|
+
export { linsoPlugin } from "./src/channel.js";
|
|
4
|
+
declare const plugin: {
|
|
5
|
+
id: string;
|
|
6
|
+
name: string;
|
|
7
|
+
description: string;
|
|
8
|
+
register(api: OpenClawPluginApi): void;
|
|
9
|
+
};
|
|
10
|
+
export default plugin;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// index.ts
|
|
2
|
+
// 插件入口:注册 channel + 启动 monitor service
|
|
3
|
+
// 和飞书 index.ts 结构完全一致
|
|
4
|
+
import { linsoPlugin } from "./src/channel.js";
|
|
5
|
+
import { monitorLinsoProvider } from "./src/monitor.js";
|
|
6
|
+
import { setLinsoRuntime } from "./src/runtime.js";
|
|
7
|
+
export { monitorLinsoProvider } from "./src/monitor.js";
|
|
8
|
+
export { linsoPlugin } from "./src/channel.js";
|
|
9
|
+
const plugin = {
|
|
10
|
+
id: "linso",
|
|
11
|
+
name: "Linso iOS Channel",
|
|
12
|
+
description: "Linso iOS App WebSocket 直连渠道,与飞书接入方式完全一致",
|
|
13
|
+
register(api) {
|
|
14
|
+
// 1. 保存 runtime 引用(供 monitor.ts 和 channel.ts 使用)
|
|
15
|
+
setLinsoRuntime(api.runtime);
|
|
16
|
+
// 2. 注册 channel plugin(channel 定义、config schema、outbound...)
|
|
17
|
+
api.registerChannel({ plugin: linsoPlugin });
|
|
18
|
+
// 3. 注册 service(Gateway 启动时自动调用 start(),停止时调用 stop())
|
|
19
|
+
// 这是 monitor 的入口,和飞书的 monitorFeishuProvider 调用方式一样
|
|
20
|
+
api.registerService({
|
|
21
|
+
id: "linso-monitor",
|
|
22
|
+
start: async (ctx) => {
|
|
23
|
+
await monitorLinsoProvider({
|
|
24
|
+
config: ctx.config,
|
|
25
|
+
log: (msg) => ctx.logger.info(String(msg)),
|
|
26
|
+
});
|
|
27
|
+
},
|
|
28
|
+
stop: async () => {
|
|
29
|
+
const { disconnectFromRelay } = await import("./src/relay-client.js");
|
|
30
|
+
disconnectFromRelay();
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
export default plugin;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// src/channel.ts
|
|
2
|
+
// ChannelPlugin 定义
|
|
3
|
+
import { DEFAULT_ACCOUNT_ID, PAIRING_APPROVED_MESSAGE, buildBaseChannelStatusSummary, createDefaultChannelRuntimeState, } from "openclaw/plugin-sdk";
|
|
4
|
+
import { sendToClient } from "./store.js";
|
|
5
|
+
import { resolveLinsoAccount } from "./types.js";
|
|
6
|
+
// ChannelOutboundAdapter: Agent 主动发消息给 iOS
|
|
7
|
+
const linsoOutbound = {
|
|
8
|
+
deliveryMode: "direct",
|
|
9
|
+
textChunkLimit: 4000,
|
|
10
|
+
sendText: async ({ to, text }) => {
|
|
11
|
+
const deviceId = to.replace(/^device:/, "");
|
|
12
|
+
sendToClient(deviceId, { type: "message", text });
|
|
13
|
+
return { channel: "linso", messageId: `${deviceId}-${Date.now()}` };
|
|
14
|
+
},
|
|
15
|
+
sendMedia: async ({ to, mediaUrl }) => {
|
|
16
|
+
const deviceId = to.replace(/^device:/, "");
|
|
17
|
+
sendToClient(deviceId, { type: "media", url: mediaUrl });
|
|
18
|
+
return { channel: "linso", messageId: `${deviceId}-${Date.now()}` };
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
export const linsoPlugin = {
|
|
22
|
+
id: "linso",
|
|
23
|
+
meta: {
|
|
24
|
+
id: "linso",
|
|
25
|
+
label: "Linso",
|
|
26
|
+
selectionLabel: "Linso iOS App",
|
|
27
|
+
docsPath: "/channels/linso",
|
|
28
|
+
docsLabel: "linso",
|
|
29
|
+
blurb: "Linso iOS App WebSocket 直连渠道",
|
|
30
|
+
order: 90,
|
|
31
|
+
},
|
|
32
|
+
capabilities: {
|
|
33
|
+
chatTypes: ["direct"],
|
|
34
|
+
reactions: false,
|
|
35
|
+
threads: false,
|
|
36
|
+
media: false,
|
|
37
|
+
polls: false,
|
|
38
|
+
},
|
|
39
|
+
reload: {
|
|
40
|
+
configPrefixes: ["channels.linso"],
|
|
41
|
+
},
|
|
42
|
+
config: {
|
|
43
|
+
listAccountIds: () => [DEFAULT_ACCOUNT_ID],
|
|
44
|
+
resolveAccount: (cfg, accountId) => resolveLinsoAccount(cfg, accountId ?? DEFAULT_ACCOUNT_ID),
|
|
45
|
+
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
|
|
46
|
+
isConfigured: (account) => account.configured,
|
|
47
|
+
describeAccount: (account) => ({
|
|
48
|
+
accountId: account.accountId,
|
|
49
|
+
enabled: account.enabled,
|
|
50
|
+
configured: account.configured,
|
|
51
|
+
relayUrl: account.relayUrl,
|
|
52
|
+
}),
|
|
53
|
+
resolveAllowFrom: () => [],
|
|
54
|
+
setAccountEnabled: ({ cfg, enabled }) => ({
|
|
55
|
+
...cfg,
|
|
56
|
+
channels: {
|
|
57
|
+
...cfg.channels,
|
|
58
|
+
linso: {
|
|
59
|
+
...cfg.channels?.linso,
|
|
60
|
+
enabled,
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
}),
|
|
64
|
+
deleteAccount: ({ cfg }) => {
|
|
65
|
+
const channels = { ...cfg.channels };
|
|
66
|
+
delete channels.linso;
|
|
67
|
+
return { ...cfg, channels };
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
configSchema: {
|
|
71
|
+
schema: {
|
|
72
|
+
type: "object",
|
|
73
|
+
additionalProperties: false,
|
|
74
|
+
properties: {
|
|
75
|
+
enabled: { type: "boolean" },
|
|
76
|
+
relayUrl: { type: "string" },
|
|
77
|
+
pluginSecret: { type: "string" },
|
|
78
|
+
appToken: { type: "string" },
|
|
79
|
+
agentId: { type: "string" },
|
|
80
|
+
dmPolicy: { type: "string", enum: ["open", "pairing"] },
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
uiHints: {
|
|
84
|
+
relayUrl: { label: "Relay Server 地址", placeholder: "wss://relay.yourdomain.com" },
|
|
85
|
+
pluginSecret: { label: "Plugin 密钥(Plugin↔Relay 鉴权)", sensitive: true },
|
|
86
|
+
appToken: { label: "App Token(iOS↔Relay 鉴权)", sensitive: true },
|
|
87
|
+
agentId: { label: "Agent ID(默认 main)", placeholder: "main" },
|
|
88
|
+
dmPolicy: { label: "连接策略" },
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
pairing: {
|
|
92
|
+
idLabel: "linsoDeviceId",
|
|
93
|
+
normalizeAllowEntry: (entry) => entry.replace(/^linso:/i, ""),
|
|
94
|
+
notifyApproval: async ({ id }) => {
|
|
95
|
+
sendToClient(id, { type: "pairing_approved", message: PAIRING_APPROVED_MESSAGE });
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
outbound: linsoOutbound,
|
|
99
|
+
status: {
|
|
100
|
+
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
|
|
101
|
+
buildChannelSummary: ({ account, snapshot }) => {
|
|
102
|
+
return buildBaseChannelStatusSummary(snapshot);
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
setup: {
|
|
106
|
+
resolveAccountId: () => DEFAULT_ACCOUNT_ID,
|
|
107
|
+
applyAccountConfig: ({ cfg }) => ({
|
|
108
|
+
...cfg,
|
|
109
|
+
channels: {
|
|
110
|
+
...cfg.channels,
|
|
111
|
+
linso: {
|
|
112
|
+
...cfg.channels?.linso,
|
|
113
|
+
enabled: true,
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
}),
|
|
117
|
+
},
|
|
118
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
2
|
+
export type MonitorLinsoOpts = {
|
|
3
|
+
config?: OpenClawConfig;
|
|
4
|
+
log?: (...args: unknown[]) => void;
|
|
5
|
+
abortSignal?: AbortSignal;
|
|
6
|
+
};
|
|
7
|
+
export declare function monitorLinsoProvider(opts?: MonitorLinsoOpts): Promise<void>;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// src/monitor.ts
|
|
2
|
+
// 核心 monitor:Plugin 主动连接云端 Relay(和飞书连 open.feishu.cn 一致)
|
|
3
|
+
// 收到 iOS 消息后路由到 Agent,流式回复经 Relay 推回 iOS
|
|
4
|
+
import { getLinsoRuntime } from "./runtime.js";
|
|
5
|
+
import { connectToRelay, disconnectFromRelay } from "./relay-client.js";
|
|
6
|
+
import { sendToClient } from "./store.js";
|
|
7
|
+
import { resolveLinsoAccount } from "./types.js";
|
|
8
|
+
export async function monitorLinsoProvider(opts = {}) {
|
|
9
|
+
const cfg = opts.config;
|
|
10
|
+
if (!cfg)
|
|
11
|
+
throw new Error("Config is required for Linso monitor");
|
|
12
|
+
const core = getLinsoRuntime();
|
|
13
|
+
const log = opts.log ?? ((...args) => console.log(...args));
|
|
14
|
+
const account = resolveLinsoAccount(cfg);
|
|
15
|
+
if (!account.enabled || !account.configured) {
|
|
16
|
+
log("[Linso] 未启用或未配置,跳过 monitor");
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
if (!account.relayUrl) {
|
|
20
|
+
log("[Linso] 未配置 relayUrl,跳过 monitor");
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
log(`[Linso] 连接 Relay Server: ${account.relayUrl}`);
|
|
24
|
+
// Plugin 主动连接 Relay(outbound,和飞书连 open.feishu.cn 完全一致)
|
|
25
|
+
connectToRelay(account.relayUrl, {
|
|
26
|
+
appToken: account.appToken,
|
|
27
|
+
log,
|
|
28
|
+
onReady: () => {
|
|
29
|
+
log("[Linso] Plugin 已连接 Relay,等待 iOS 消息");
|
|
30
|
+
core.channel.activity.record({
|
|
31
|
+
channel: "linso",
|
|
32
|
+
accountId: "default",
|
|
33
|
+
direction: "inbound",
|
|
34
|
+
});
|
|
35
|
+
},
|
|
36
|
+
onDisconnected: () => {
|
|
37
|
+
log("[Linso] 与 Relay 断开");
|
|
38
|
+
},
|
|
39
|
+
onMessage: (deviceId, msg) => {
|
|
40
|
+
if (msg.type === "send" && typeof msg.text === "string" && msg.text.trim()) {
|
|
41
|
+
const text = msg.text.trim();
|
|
42
|
+
// 拦截 slash 命令,直接回复,不走 Agent
|
|
43
|
+
if (text.startsWith("/")) {
|
|
44
|
+
const runId = `linso-${Date.now()}`;
|
|
45
|
+
sendToClient(deviceId, { type: "run_start", runId });
|
|
46
|
+
sendToClient(deviceId, { type: "final", runId, text: handleSlashCommand(text) });
|
|
47
|
+
sendToClient(deviceId, { type: "done", runId });
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
void handleIncomingMessage(deviceId, text, cfg, log).catch((err) => {
|
|
51
|
+
log(`[Linso] 处理消息出错: ${String(err)}`);
|
|
52
|
+
sendToClient(deviceId, { type: "error", message: String(err) });
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
// 等待 abort 信号(Gateway 重启/重载时触发)
|
|
58
|
+
if (opts.abortSignal) {
|
|
59
|
+
await new Promise((resolve) => {
|
|
60
|
+
opts.abortSignal.addEventListener("abort", () => {
|
|
61
|
+
log("[Linso] 收到 abort 信号,断开 Relay");
|
|
62
|
+
disconnectFromRelay();
|
|
63
|
+
resolve();
|
|
64
|
+
}, { once: true });
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* 把 iOS 消息路由到 Agent,流式回复(核心逻辑不变)
|
|
70
|
+
*/
|
|
71
|
+
async function handleIncomingMessage(deviceId, text, cfg, log) {
|
|
72
|
+
const core = getLinsoRuntime();
|
|
73
|
+
const route = core.channel.routing.resolveAgentRoute({
|
|
74
|
+
cfg,
|
|
75
|
+
channel: "linso",
|
|
76
|
+
accountId: "default",
|
|
77
|
+
peer: { kind: "direct", id: deviceId },
|
|
78
|
+
});
|
|
79
|
+
log(`[Linso] 路由: ${deviceId} → session=${route.sessionKey} agent=${route.agentId}`);
|
|
80
|
+
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
|
81
|
+
const body = core.channel.reply.formatAgentEnvelope({
|
|
82
|
+
channel: "Linso",
|
|
83
|
+
from: deviceId,
|
|
84
|
+
timestamp: new Date(),
|
|
85
|
+
envelope: envelopeOptions,
|
|
86
|
+
body: text,
|
|
87
|
+
});
|
|
88
|
+
const inboundCtx = core.channel.reply.finalizeInboundContext({
|
|
89
|
+
Body: body,
|
|
90
|
+
BodyForAgent: text,
|
|
91
|
+
RawBody: text,
|
|
92
|
+
CommandBody: text,
|
|
93
|
+
From: `linso:${deviceId}`,
|
|
94
|
+
To: `device:${deviceId}`,
|
|
95
|
+
SessionKey: route.sessionKey,
|
|
96
|
+
AccountId: "default",
|
|
97
|
+
ChatType: "direct",
|
|
98
|
+
SenderId: deviceId,
|
|
99
|
+
SenderName: deviceId,
|
|
100
|
+
Provider: "linso",
|
|
101
|
+
Surface: "linso",
|
|
102
|
+
MessageSid: `${deviceId}-${Date.now()}`,
|
|
103
|
+
Timestamp: Date.now(),
|
|
104
|
+
WasMentioned: true,
|
|
105
|
+
OriginatingChannel: "linso",
|
|
106
|
+
OriginatingTo: `device:${deviceId}`,
|
|
107
|
+
});
|
|
108
|
+
const runId = `linso-${Date.now()}`;
|
|
109
|
+
const { dispatcher, replyOptions, markDispatchIdle } = core.channel.reply.createReplyDispatcherWithTyping({
|
|
110
|
+
deliver: async (payload, info) => {
|
|
111
|
+
if (!payload.text || payload.isReasoning)
|
|
112
|
+
return;
|
|
113
|
+
sendToClient(deviceId, {
|
|
114
|
+
type: info.kind === "final" ? "final" : "delta",
|
|
115
|
+
runId,
|
|
116
|
+
text: payload.text,
|
|
117
|
+
});
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
sendToClient(deviceId, { type: "run_start", runId });
|
|
121
|
+
await core.channel.reply.withReplyDispatcher({
|
|
122
|
+
dispatcher,
|
|
123
|
+
onSettled: () => markDispatchIdle(),
|
|
124
|
+
run: () => core.channel.reply.dispatchReplyFromConfig({
|
|
125
|
+
ctx: inboundCtx,
|
|
126
|
+
cfg,
|
|
127
|
+
dispatcher,
|
|
128
|
+
replyOptions,
|
|
129
|
+
}),
|
|
130
|
+
});
|
|
131
|
+
sendToClient(deviceId, { type: "done", runId });
|
|
132
|
+
log(`[Linso] 回复完成: deviceId=${deviceId}`);
|
|
133
|
+
}
|
|
134
|
+
function handleSlashCommand(text) {
|
|
135
|
+
const cmd = text.toLowerCase().split(/\s+/)[0];
|
|
136
|
+
switch (cmd) {
|
|
137
|
+
case "/help":
|
|
138
|
+
return [
|
|
139
|
+
"📋 可用命令:",
|
|
140
|
+
"/help — 显示帮助",
|
|
141
|
+
"/status — 查看连接状态",
|
|
142
|
+
"/clear — 提示清除会话(实际需在 App 操作)",
|
|
143
|
+
"",
|
|
144
|
+
"直接发消息即可和 AI 对话 🦞",
|
|
145
|
+
].join("\n");
|
|
146
|
+
case "/status":
|
|
147
|
+
return [
|
|
148
|
+
"✅ 连接状态:",
|
|
149
|
+
"• Relay Server:已连接",
|
|
150
|
+
"• Plugin:已就绪",
|
|
151
|
+
"• AI Agent:可用",
|
|
152
|
+
`• 时间:${new Date().toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" })}`,
|
|
153
|
+
].join("\n");
|
|
154
|
+
default:
|
|
155
|
+
return `未知命令:${text}\n输入 /help 查看可用命令`;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type RelayCallbacks = {
|
|
2
|
+
appToken: string;
|
|
3
|
+
onMessage: (deviceId: string, msg: Record<string, unknown>) => void;
|
|
4
|
+
onReady: () => void;
|
|
5
|
+
onDisconnected: () => void;
|
|
6
|
+
log: (...args: unknown[]) => void;
|
|
7
|
+
};
|
|
8
|
+
export declare function connectToRelay(relayUrl: string, callbacks: RelayCallbacks): void;
|
|
9
|
+
export declare function disconnectFromRelay(): void;
|
|
10
|
+
export declare function sendToRelay(msg: Record<string, unknown>): void;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// src/relay-client.ts
|
|
2
|
+
import { WebSocket } from "ws";
|
|
3
|
+
let ws = null;
|
|
4
|
+
let shouldReconnect = false;
|
|
5
|
+
let reconnectDelay = 3000;
|
|
6
|
+
export function connectToRelay(relayUrl, callbacks) {
|
|
7
|
+
shouldReconnect = true;
|
|
8
|
+
reconnectDelay = 3000;
|
|
9
|
+
_connect(relayUrl, callbacks);
|
|
10
|
+
}
|
|
11
|
+
export function disconnectFromRelay() {
|
|
12
|
+
shouldReconnect = false;
|
|
13
|
+
ws?.close();
|
|
14
|
+
ws = null;
|
|
15
|
+
}
|
|
16
|
+
export function sendToRelay(msg) {
|
|
17
|
+
if (ws?.readyState === WebSocket.OPEN) {
|
|
18
|
+
ws.send(JSON.stringify(msg));
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function _connect(relayUrl, callbacks) {
|
|
22
|
+
const { appToken, onMessage, onReady, onDisconnected, log } = callbacks;
|
|
23
|
+
const url = relayUrl.replace(/\/$/, "") + "/plugin";
|
|
24
|
+
log(`[Linso] 连接 Relay: ${url}`);
|
|
25
|
+
ws = new WebSocket(url);
|
|
26
|
+
ws.on("open", () => {
|
|
27
|
+
log("[Linso] Relay 连接建立,发送鉴权...");
|
|
28
|
+
ws.send(JSON.stringify({ type: "plugin_auth", appToken }));
|
|
29
|
+
});
|
|
30
|
+
ws.on("message", (data) => {
|
|
31
|
+
let msg;
|
|
32
|
+
try {
|
|
33
|
+
msg = JSON.parse(data.toString());
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (msg.type === "plugin_ready") {
|
|
39
|
+
log("[Linso] Relay 鉴权成功,Plugin 就绪");
|
|
40
|
+
reconnectDelay = 3000;
|
|
41
|
+
onReady();
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const deviceId = msg.deviceId;
|
|
45
|
+
if (deviceId)
|
|
46
|
+
onMessage(deviceId, msg);
|
|
47
|
+
});
|
|
48
|
+
ws.on("close", () => {
|
|
49
|
+
log(`[Linso] Relay 断开,${reconnectDelay / 1000}s 后重连...`);
|
|
50
|
+
ws = null;
|
|
51
|
+
onDisconnected();
|
|
52
|
+
if (shouldReconnect) {
|
|
53
|
+
setTimeout(() => { if (shouldReconnect)
|
|
54
|
+
_connect(relayUrl, callbacks); }, reconnectDelay);
|
|
55
|
+
reconnectDelay = Math.min(reconnectDelay * 2, 60_000);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
ws.on("error", err => log(`[Linso] Relay 错误: ${err.message}`));
|
|
59
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// src/runtime.ts
|
|
2
|
+
// 存储 PluginRuntime 引用(和飞书 runtime.ts 完全一致的模式)
|
|
3
|
+
let runtime = null;
|
|
4
|
+
export function setLinsoRuntime(next) {
|
|
5
|
+
runtime = next;
|
|
6
|
+
}
|
|
7
|
+
export function getLinsoRuntime() {
|
|
8
|
+
if (!runtime)
|
|
9
|
+
throw new Error("Linso runtime not initialized");
|
|
10
|
+
return runtime;
|
|
11
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
2
|
+
export type LinsoChannelConfig = {
|
|
3
|
+
enabled?: boolean;
|
|
4
|
+
relayUrl?: string;
|
|
5
|
+
appToken?: string;
|
|
6
|
+
agentId?: string;
|
|
7
|
+
dmPolicy?: "open" | "pairing";
|
|
8
|
+
};
|
|
9
|
+
export type ResolvedLinsoAccount = {
|
|
10
|
+
accountId: string;
|
|
11
|
+
enabled: boolean;
|
|
12
|
+
configured: boolean;
|
|
13
|
+
relayUrl: string;
|
|
14
|
+
appToken: string;
|
|
15
|
+
agentId: string;
|
|
16
|
+
dmPolicy: "open" | "pairing";
|
|
17
|
+
config: LinsoChannelConfig;
|
|
18
|
+
};
|
|
19
|
+
export declare function resolveLinsoConfig(cfg: OpenClawConfig): LinsoChannelConfig;
|
|
20
|
+
export declare function resolveLinsoAccount(cfg: OpenClawConfig, accountId?: string): ResolvedLinsoAccount;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export function resolveLinsoConfig(cfg) {
|
|
2
|
+
return cfg.channels?.linso ?? {};
|
|
3
|
+
}
|
|
4
|
+
export function resolveLinsoAccount(cfg, accountId = "default") {
|
|
5
|
+
const c = resolveLinsoConfig(cfg);
|
|
6
|
+
return {
|
|
7
|
+
accountId,
|
|
8
|
+
enabled: c.enabled ?? false,
|
|
9
|
+
configured: !!(c.relayUrl && c.appToken),
|
|
10
|
+
relayUrl: c.relayUrl ?? "",
|
|
11
|
+
appToken: c.appToken ?? "",
|
|
12
|
+
agentId: c.agentId ?? "main",
|
|
13
|
+
dmPolicy: c.dmPolicy ?? "open",
|
|
14
|
+
config: c,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "linso",
|
|
3
|
+
"name": "Linso iOS Channel",
|
|
4
|
+
"description": "Linso iOS App channel for OpenClaw — connects your iPhone to your AI via cloud Relay",
|
|
5
|
+
"version": "1.0.0",
|
|
6
|
+
"channels": ["linso"],
|
|
7
|
+
"configSchema": {
|
|
8
|
+
"type": "object",
|
|
9
|
+
"additionalProperties": false,
|
|
10
|
+
"properties": {
|
|
11
|
+
"enabled": { "type": "boolean" },
|
|
12
|
+
"relayUrl": { "type": "string", "description": "Relay Server WebSocket URL, e.g. wss://relay.yourdomain.com" },
|
|
13
|
+
"appToken": { "type": "string", "description": "Token from Linso iOS App Settings — used to pair App with this Gateway" },
|
|
14
|
+
"agentId": { "type": "string" },
|
|
15
|
+
"dmPolicy": { "type": "string", "enum": ["open", "pairing"] }
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "openclaw-linso",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Linso iOS App channel plugin for OpenClaw — connects via cloud Relay Server",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"files": [
|
|
8
|
+
"dist/",
|
|
9
|
+
"src/",
|
|
10
|
+
"openclaw.plugin.json",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsc",
|
|
15
|
+
"dev": "tsc --watch",
|
|
16
|
+
"prepublishOnly": "npm run build"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"openclaw",
|
|
20
|
+
"openclaw-plugin",
|
|
21
|
+
"linso",
|
|
22
|
+
"ios",
|
|
23
|
+
"channel"
|
|
24
|
+
],
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"ws": "^8.18.0"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/ws": "^8.5.12",
|
|
31
|
+
"typescript": "^5.4.0"
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"openclaw": "*"
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/channel.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// src/channel.ts
|
|
2
|
+
// ChannelPlugin 定义
|
|
3
|
+
|
|
4
|
+
import type {
|
|
5
|
+
ChannelPlugin,
|
|
6
|
+
ChannelOutboundAdapter,
|
|
7
|
+
OpenClawConfig,
|
|
8
|
+
} from "openclaw/plugin-sdk";
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_ACCOUNT_ID,
|
|
11
|
+
PAIRING_APPROVED_MESSAGE,
|
|
12
|
+
buildBaseChannelStatusSummary,
|
|
13
|
+
createDefaultChannelRuntimeState,
|
|
14
|
+
} from "openclaw/plugin-sdk";
|
|
15
|
+
import { sendToClient } from "./store.js";
|
|
16
|
+
import { resolveLinsoAccount, type ResolvedLinsoAccount } from "./types.js";
|
|
17
|
+
|
|
18
|
+
// ChannelOutboundAdapter: Agent 主动发消息给 iOS
|
|
19
|
+
const linsoOutbound: ChannelOutboundAdapter = {
|
|
20
|
+
deliveryMode: "direct",
|
|
21
|
+
textChunkLimit: 4000,
|
|
22
|
+
sendText: async ({ to, text }) => {
|
|
23
|
+
const deviceId = to.replace(/^device:/, "");
|
|
24
|
+
sendToClient(deviceId, { type: "message", text });
|
|
25
|
+
return { channel: "linso" as any, messageId: `${deviceId}-${Date.now()}` };
|
|
26
|
+
},
|
|
27
|
+
sendMedia: async ({ to, mediaUrl }) => {
|
|
28
|
+
const deviceId = to.replace(/^device:/, "");
|
|
29
|
+
sendToClient(deviceId, { type: "media", url: mediaUrl });
|
|
30
|
+
return { channel: "linso" as any, messageId: `${deviceId}-${Date.now()}` };
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const linsoPlugin: ChannelPlugin<ResolvedLinsoAccount> = {
|
|
35
|
+
id: "linso",
|
|
36
|
+
|
|
37
|
+
meta: {
|
|
38
|
+
id: "linso",
|
|
39
|
+
label: "Linso",
|
|
40
|
+
selectionLabel: "Linso iOS App",
|
|
41
|
+
docsPath: "/channels/linso",
|
|
42
|
+
docsLabel: "linso",
|
|
43
|
+
blurb: "Linso iOS App WebSocket 直连渠道",
|
|
44
|
+
order: 90,
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
capabilities: {
|
|
48
|
+
chatTypes: ["direct"],
|
|
49
|
+
reactions: false,
|
|
50
|
+
threads: false,
|
|
51
|
+
media: false,
|
|
52
|
+
polls: false,
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
reload: {
|
|
56
|
+
configPrefixes: ["channels.linso"],
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
config: {
|
|
60
|
+
listAccountIds: () => [DEFAULT_ACCOUNT_ID],
|
|
61
|
+
|
|
62
|
+
resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) =>
|
|
63
|
+
resolveLinsoAccount(cfg, accountId ?? DEFAULT_ACCOUNT_ID),
|
|
64
|
+
|
|
65
|
+
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
|
|
66
|
+
|
|
67
|
+
isConfigured: (account: ResolvedLinsoAccount) => account.configured,
|
|
68
|
+
|
|
69
|
+
describeAccount: (account: ResolvedLinsoAccount) => ({
|
|
70
|
+
accountId: account.accountId,
|
|
71
|
+
enabled: account.enabled,
|
|
72
|
+
configured: account.configured,
|
|
73
|
+
relayUrl: account.relayUrl,
|
|
74
|
+
}),
|
|
75
|
+
|
|
76
|
+
resolveAllowFrom: () => [],
|
|
77
|
+
|
|
78
|
+
setAccountEnabled: ({ cfg, enabled }: { cfg: OpenClawConfig; accountId: string; enabled: boolean }) => ({
|
|
79
|
+
...cfg,
|
|
80
|
+
channels: {
|
|
81
|
+
...(cfg.channels as Record<string, unknown>),
|
|
82
|
+
linso: {
|
|
83
|
+
...((cfg.channels as Record<string, unknown>)?.linso as Record<string, unknown>),
|
|
84
|
+
enabled,
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
}),
|
|
88
|
+
|
|
89
|
+
deleteAccount: ({ cfg }: { cfg: OpenClawConfig; accountId: string }) => {
|
|
90
|
+
const channels = { ...(cfg.channels as Record<string, unknown>) };
|
|
91
|
+
delete channels.linso;
|
|
92
|
+
return { ...cfg, channels } as OpenClawConfig;
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
configSchema: {
|
|
97
|
+
schema: {
|
|
98
|
+
type: "object",
|
|
99
|
+
additionalProperties: false,
|
|
100
|
+
properties: {
|
|
101
|
+
enabled: { type: "boolean" },
|
|
102
|
+
relayUrl: { type: "string" },
|
|
103
|
+
pluginSecret: { type: "string" },
|
|
104
|
+
appToken: { type: "string" },
|
|
105
|
+
agentId: { type: "string" },
|
|
106
|
+
dmPolicy: { type: "string", enum: ["open", "pairing"] },
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
uiHints: {
|
|
110
|
+
relayUrl: { label: "Relay Server 地址", placeholder: "wss://relay.yourdomain.com" },
|
|
111
|
+
pluginSecret: { label: "Plugin 密钥(Plugin↔Relay 鉴权)", sensitive: true },
|
|
112
|
+
appToken: { label: "App Token(iOS↔Relay 鉴权)", sensitive: true },
|
|
113
|
+
agentId: { label: "Agent ID(默认 main)", placeholder: "main" },
|
|
114
|
+
dmPolicy: { label: "连接策略" },
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
pairing: {
|
|
119
|
+
idLabel: "linsoDeviceId",
|
|
120
|
+
normalizeAllowEntry: (entry) => entry.replace(/^linso:/i, ""),
|
|
121
|
+
notifyApproval: async ({ id }) => {
|
|
122
|
+
sendToClient(id, { type: "pairing_approved", message: PAIRING_APPROVED_MESSAGE });
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
outbound: linsoOutbound,
|
|
127
|
+
|
|
128
|
+
status: {
|
|
129
|
+
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
|
|
130
|
+
buildChannelSummary: ({ account, snapshot }) => {
|
|
131
|
+
return buildBaseChannelStatusSummary(snapshot);
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
setup: {
|
|
136
|
+
resolveAccountId: () => DEFAULT_ACCOUNT_ID,
|
|
137
|
+
applyAccountConfig: ({ cfg }) => ({
|
|
138
|
+
...cfg,
|
|
139
|
+
channels: {
|
|
140
|
+
...(cfg.channels as Record<string, unknown>),
|
|
141
|
+
linso: {
|
|
142
|
+
...((cfg.channels as Record<string, unknown>)?.linso as Record<string, unknown>),
|
|
143
|
+
enabled: true,
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
}),
|
|
147
|
+
},
|
|
148
|
+
};
|
package/src/monitor.ts
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// src/monitor.ts
|
|
2
|
+
// 核心 monitor:Plugin 主动连接云端 Relay(和飞书连 open.feishu.cn 一致)
|
|
3
|
+
// 收到 iOS 消息后路由到 Agent,流式回复经 Relay 推回 iOS
|
|
4
|
+
|
|
5
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
6
|
+
import { getLinsoRuntime } from "./runtime.js";
|
|
7
|
+
import { connectToRelay, disconnectFromRelay } from "./relay-client.js";
|
|
8
|
+
import { sendToClient } from "./store.js";
|
|
9
|
+
import { resolveLinsoAccount } from "./types.js";
|
|
10
|
+
|
|
11
|
+
export type MonitorLinsoOpts = {
|
|
12
|
+
config?: OpenClawConfig;
|
|
13
|
+
log?: (...args: unknown[]) => void;
|
|
14
|
+
abortSignal?: AbortSignal;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export async function monitorLinsoProvider(opts: MonitorLinsoOpts = {}): Promise<void> {
|
|
18
|
+
const cfg = opts.config;
|
|
19
|
+
if (!cfg) throw new Error("Config is required for Linso monitor");
|
|
20
|
+
|
|
21
|
+
const core = getLinsoRuntime();
|
|
22
|
+
const log = opts.log ?? ((...args: unknown[]) => console.log(...args));
|
|
23
|
+
const account = resolveLinsoAccount(cfg);
|
|
24
|
+
|
|
25
|
+
if (!account.enabled || !account.configured) {
|
|
26
|
+
log("[Linso] 未启用或未配置,跳过 monitor");
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (!account.relayUrl) {
|
|
31
|
+
log("[Linso] 未配置 relayUrl,跳过 monitor");
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
log(`[Linso] 连接 Relay Server: ${account.relayUrl}`);
|
|
36
|
+
|
|
37
|
+
// Plugin 主动连接 Relay(outbound,和飞书连 open.feishu.cn 完全一致)
|
|
38
|
+
connectToRelay(account.relayUrl, {
|
|
39
|
+
appToken: account.appToken,
|
|
40
|
+
log,
|
|
41
|
+
|
|
42
|
+
onReady: () => {
|
|
43
|
+
log("[Linso] Plugin 已连接 Relay,等待 iOS 消息");
|
|
44
|
+
core.channel.activity.record({
|
|
45
|
+
channel: "linso",
|
|
46
|
+
accountId: "default",
|
|
47
|
+
direction: "inbound",
|
|
48
|
+
});
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
onDisconnected: () => {
|
|
52
|
+
log("[Linso] 与 Relay 断开");
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
onMessage: (deviceId, msg) => {
|
|
56
|
+
if (msg.type === "send" && typeof msg.text === "string" && msg.text.trim()) {
|
|
57
|
+
const text = msg.text.trim();
|
|
58
|
+
|
|
59
|
+
// 拦截 slash 命令,直接回复,不走 Agent
|
|
60
|
+
if (text.startsWith("/")) {
|
|
61
|
+
const runId = `linso-${Date.now()}`;
|
|
62
|
+
sendToClient(deviceId, { type: "run_start", runId });
|
|
63
|
+
sendToClient(deviceId, { type: "final", runId, text: handleSlashCommand(text) });
|
|
64
|
+
sendToClient(deviceId, { type: "done", runId });
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
void handleIncomingMessage(deviceId, text, cfg, log).catch((err) => {
|
|
69
|
+
log(`[Linso] 处理消息出错: ${String(err)}`);
|
|
70
|
+
sendToClient(deviceId, { type: "error", message: String(err) });
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// 等待 abort 信号(Gateway 重启/重载时触发)
|
|
77
|
+
if (opts.abortSignal) {
|
|
78
|
+
await new Promise<void>((resolve) => {
|
|
79
|
+
opts.abortSignal!.addEventListener("abort", () => {
|
|
80
|
+
log("[Linso] 收到 abort 信号,断开 Relay");
|
|
81
|
+
disconnectFromRelay();
|
|
82
|
+
resolve();
|
|
83
|
+
}, { once: true });
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 把 iOS 消息路由到 Agent,流式回复(核心逻辑不变)
|
|
90
|
+
*/
|
|
91
|
+
async function handleIncomingMessage(
|
|
92
|
+
deviceId: string,
|
|
93
|
+
text: string,
|
|
94
|
+
cfg: OpenClawConfig,
|
|
95
|
+
log: (...args: unknown[]) => void,
|
|
96
|
+
): Promise<void> {
|
|
97
|
+
const core = getLinsoRuntime();
|
|
98
|
+
|
|
99
|
+
const route = core.channel.routing.resolveAgentRoute({
|
|
100
|
+
cfg,
|
|
101
|
+
channel: "linso",
|
|
102
|
+
accountId: "default",
|
|
103
|
+
peer: { kind: "direct", id: deviceId },
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
log(`[Linso] 路由: ${deviceId} → session=${route.sessionKey} agent=${route.agentId}`);
|
|
107
|
+
|
|
108
|
+
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
|
109
|
+
const body = core.channel.reply.formatAgentEnvelope({
|
|
110
|
+
channel: "Linso",
|
|
111
|
+
from: deviceId,
|
|
112
|
+
timestamp: new Date(),
|
|
113
|
+
envelope: envelopeOptions,
|
|
114
|
+
body: text,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const inboundCtx = core.channel.reply.finalizeInboundContext({
|
|
118
|
+
Body: body,
|
|
119
|
+
BodyForAgent: text,
|
|
120
|
+
RawBody: text,
|
|
121
|
+
CommandBody: text,
|
|
122
|
+
From: `linso:${deviceId}`,
|
|
123
|
+
To: `device:${deviceId}`,
|
|
124
|
+
SessionKey: route.sessionKey,
|
|
125
|
+
AccountId: "default",
|
|
126
|
+
ChatType: "direct" as const,
|
|
127
|
+
SenderId: deviceId,
|
|
128
|
+
SenderName: deviceId,
|
|
129
|
+
Provider: "linso" as const,
|
|
130
|
+
Surface: "linso" as const,
|
|
131
|
+
MessageSid: `${deviceId}-${Date.now()}`,
|
|
132
|
+
Timestamp: Date.now(),
|
|
133
|
+
WasMentioned: true,
|
|
134
|
+
OriginatingChannel: "linso" as const,
|
|
135
|
+
OriginatingTo: `device:${deviceId}`,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const runId = `linso-${Date.now()}`;
|
|
139
|
+
|
|
140
|
+
const { dispatcher, replyOptions, markDispatchIdle } =
|
|
141
|
+
core.channel.reply.createReplyDispatcherWithTyping({
|
|
142
|
+
deliver: async (payload, info) => {
|
|
143
|
+
if (!payload.text || payload.isReasoning) return;
|
|
144
|
+
sendToClient(deviceId, {
|
|
145
|
+
type: info.kind === "final" ? "final" : "delta",
|
|
146
|
+
runId,
|
|
147
|
+
text: payload.text,
|
|
148
|
+
});
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
sendToClient(deviceId, { type: "run_start", runId });
|
|
153
|
+
|
|
154
|
+
await core.channel.reply.withReplyDispatcher({
|
|
155
|
+
dispatcher,
|
|
156
|
+
onSettled: () => markDispatchIdle(),
|
|
157
|
+
run: () =>
|
|
158
|
+
core.channel.reply.dispatchReplyFromConfig({
|
|
159
|
+
ctx: inboundCtx,
|
|
160
|
+
cfg,
|
|
161
|
+
dispatcher,
|
|
162
|
+
replyOptions,
|
|
163
|
+
}),
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
sendToClient(deviceId, { type: "done", runId });
|
|
167
|
+
log(`[Linso] 回复完成: deviceId=${deviceId}`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function handleSlashCommand(text: string): string {
|
|
171
|
+
const cmd = text.toLowerCase().split(/\s+/)[0];
|
|
172
|
+
switch (cmd) {
|
|
173
|
+
case "/help":
|
|
174
|
+
return [
|
|
175
|
+
"📋 可用命令:",
|
|
176
|
+
"/help — 显示帮助",
|
|
177
|
+
"/status — 查看连接状态",
|
|
178
|
+
"/clear — 提示清除会话(实际需在 App 操作)",
|
|
179
|
+
"",
|
|
180
|
+
"直接发消息即可和 AI 对话 🦞",
|
|
181
|
+
].join("\n");
|
|
182
|
+
|
|
183
|
+
case "/status":
|
|
184
|
+
return [
|
|
185
|
+
"✅ 连接状态:",
|
|
186
|
+
"• Relay Server:已连接",
|
|
187
|
+
"• Plugin:已就绪",
|
|
188
|
+
"• AI Agent:可用",
|
|
189
|
+
`• 时间:${new Date().toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" })}`,
|
|
190
|
+
].join("\n");
|
|
191
|
+
|
|
192
|
+
default:
|
|
193
|
+
return `未知命令:${text}\n输入 /help 查看可用命令`;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// src/relay-client.ts
|
|
2
|
+
import { WebSocket } from "ws";
|
|
3
|
+
|
|
4
|
+
export type RelayCallbacks = {
|
|
5
|
+
appToken: string;
|
|
6
|
+
onMessage: (deviceId: string, msg: Record<string, unknown>) => void;
|
|
7
|
+
onReady: () => void;
|
|
8
|
+
onDisconnected: () => void;
|
|
9
|
+
log: (...args: unknown[]) => void;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
let ws: WebSocket | null = null;
|
|
13
|
+
let shouldReconnect = false;
|
|
14
|
+
let reconnectDelay = 3000;
|
|
15
|
+
|
|
16
|
+
export function connectToRelay(relayUrl: string, callbacks: RelayCallbacks) {
|
|
17
|
+
shouldReconnect = true;
|
|
18
|
+
reconnectDelay = 3000;
|
|
19
|
+
_connect(relayUrl, callbacks);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function disconnectFromRelay() {
|
|
23
|
+
shouldReconnect = false;
|
|
24
|
+
ws?.close();
|
|
25
|
+
ws = null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function sendToRelay(msg: Record<string, unknown>) {
|
|
29
|
+
if (ws?.readyState === WebSocket.OPEN) {
|
|
30
|
+
ws.send(JSON.stringify(msg));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function _connect(relayUrl: string, callbacks: RelayCallbacks) {
|
|
35
|
+
const { appToken, onMessage, onReady, onDisconnected, log } = callbacks;
|
|
36
|
+
const url = relayUrl.replace(/\/$/, "") + "/plugin";
|
|
37
|
+
log(`[Linso] 连接 Relay: ${url}`);
|
|
38
|
+
|
|
39
|
+
ws = new WebSocket(url);
|
|
40
|
+
|
|
41
|
+
ws.on("open", () => {
|
|
42
|
+
log("[Linso] Relay 连接建立,发送鉴权...");
|
|
43
|
+
ws!.send(JSON.stringify({ type: "plugin_auth", appToken }));
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
ws.on("message", (data) => {
|
|
47
|
+
let msg: Record<string, unknown>;
|
|
48
|
+
try { msg = JSON.parse(data.toString()); } catch { return; }
|
|
49
|
+
|
|
50
|
+
if (msg.type === "plugin_ready") {
|
|
51
|
+
log("[Linso] Relay 鉴权成功,Plugin 就绪");
|
|
52
|
+
reconnectDelay = 3000;
|
|
53
|
+
onReady();
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const deviceId = msg.deviceId as string | undefined;
|
|
58
|
+
if (deviceId) onMessage(deviceId, msg);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
ws.on("close", () => {
|
|
62
|
+
log(`[Linso] Relay 断开,${reconnectDelay / 1000}s 后重连...`);
|
|
63
|
+
ws = null;
|
|
64
|
+
onDisconnected();
|
|
65
|
+
if (shouldReconnect) {
|
|
66
|
+
setTimeout(() => { if (shouldReconnect) _connect(relayUrl, callbacks); }, reconnectDelay);
|
|
67
|
+
reconnectDelay = Math.min(reconnectDelay * 2, 60_000);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
ws.on("error", err => log(`[Linso] Relay 错误: ${err.message}`));
|
|
72
|
+
}
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// src/runtime.ts
|
|
2
|
+
// 存储 PluginRuntime 引用(和飞书 runtime.ts 完全一致的模式)
|
|
3
|
+
|
|
4
|
+
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
|
5
|
+
|
|
6
|
+
let runtime: PluginRuntime | null = null;
|
|
7
|
+
|
|
8
|
+
export function setLinsoRuntime(next: PluginRuntime) {
|
|
9
|
+
runtime = next;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function getLinsoRuntime(): PluginRuntime {
|
|
13
|
+
if (!runtime) throw new Error("Linso runtime not initialized");
|
|
14
|
+
return runtime;
|
|
15
|
+
}
|
package/src/server.ts
ADDED
package/src/store.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// src/store.ts
|
|
2
|
+
// 通过 Relay 向 iOS 推送消息
|
|
3
|
+
// Plugin 不直接持有 iOS 的 WebSocket,而是通过 Relay 转发
|
|
4
|
+
|
|
5
|
+
import { sendToRelay } from "./relay-client.js";
|
|
6
|
+
|
|
7
|
+
/** 向指定 iOS 设备发送消息(经由 Relay) */
|
|
8
|
+
export function sendToClient(deviceId: string, msg: Record<string, unknown>) {
|
|
9
|
+
sendToRelay({ ...msg, deviceId });
|
|
10
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
2
|
+
|
|
3
|
+
export type LinsoChannelConfig = {
|
|
4
|
+
enabled?: boolean;
|
|
5
|
+
relayUrl?: string;
|
|
6
|
+
appToken?: string; // App 注册拿到的 token,Plugin 用它连接 Relay
|
|
7
|
+
agentId?: string;
|
|
8
|
+
dmPolicy?: "open" | "pairing";
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type ResolvedLinsoAccount = {
|
|
12
|
+
accountId: string;
|
|
13
|
+
enabled: boolean;
|
|
14
|
+
configured: boolean;
|
|
15
|
+
relayUrl: string;
|
|
16
|
+
appToken: string;
|
|
17
|
+
agentId: string;
|
|
18
|
+
dmPolicy: "open" | "pairing";
|
|
19
|
+
config: LinsoChannelConfig;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function resolveLinsoConfig(cfg: OpenClawConfig): LinsoChannelConfig {
|
|
23
|
+
return (cfg.channels as Record<string, unknown>)?.linso as LinsoChannelConfig ?? {};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function resolveLinsoAccount(cfg: OpenClawConfig, accountId = "default"): ResolvedLinsoAccount {
|
|
27
|
+
const c = resolveLinsoConfig(cfg);
|
|
28
|
+
return {
|
|
29
|
+
accountId,
|
|
30
|
+
enabled: c.enabled ?? false,
|
|
31
|
+
configured: !!(c.relayUrl && c.appToken),
|
|
32
|
+
relayUrl: c.relayUrl ?? "",
|
|
33
|
+
appToken: c.appToken ?? "",
|
|
34
|
+
agentId: c.agentId ?? "main",
|
|
35
|
+
dmPolicy: c.dmPolicy ?? "open",
|
|
36
|
+
config: c,
|
|
37
|
+
};
|
|
38
|
+
}
|