openclaw-linso 1.0.0 → 1.0.2

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.
@@ -1,6 +1,11 @@
1
1
  // src/monitor.ts
2
2
  // 核心 monitor:Plugin 主动连接云端 Relay(和飞书连 open.feishu.cn 一致)
3
3
  // 收到 iOS 消息后路由到 Agent,流式回复经 Relay 推回 iOS
4
+ import fs from "fs";
5
+ import os from "os";
6
+ import path from "path";
7
+ import https from "https";
8
+ import http from "http";
4
9
  import { getLinsoRuntime } from "./runtime.js";
5
10
  import { connectToRelay, disconnectFromRelay } from "./relay-client.js";
6
11
  import { sendToClient } from "./store.js";
@@ -16,8 +21,8 @@ export async function monitorLinsoProvider(opts = {}) {
16
21
  log("[Linso] 未启用或未配置,跳过 monitor");
17
22
  return;
18
23
  }
19
- if (!account.relayUrl) {
20
- log("[Linso] 未配置 relayUrl,跳过 monitor");
24
+ if (!account.appToken) {
25
+ log("[Linso] 未配置 appToken,跳过 monitor");
21
26
  return;
22
27
  }
23
28
  log(`[Linso] 连接 Relay Server: ${account.relayUrl}`);
@@ -39,6 +44,9 @@ export async function monitorLinsoProvider(opts = {}) {
39
44
  onMessage: (deviceId, msg) => {
40
45
  if (msg.type === "send" && typeof msg.text === "string" && msg.text.trim()) {
41
46
  const text = msg.text.trim();
47
+ const imageUrls = Array.isArray(msg.images)
48
+ ? msg.images.filter((u) => typeof u === "string")
49
+ : [];
42
50
  // 拦截 slash 命令,直接回复,不走 Agent
43
51
  if (text.startsWith("/")) {
44
52
  const runId = `linso-${Date.now()}`;
@@ -47,7 +55,7 @@ export async function monitorLinsoProvider(opts = {}) {
47
55
  sendToClient(deviceId, { type: "done", runId });
48
56
  return;
49
57
  }
50
- void handleIncomingMessage(deviceId, text, cfg, log).catch((err) => {
58
+ void handleIncomingMessage(deviceId, text, imageUrls, cfg, log).catch((err) => {
51
59
  log(`[Linso] 处理消息出错: ${String(err)}`);
52
60
  sendToClient(deviceId, { type: "error", message: String(err) });
53
61
  });
@@ -65,11 +73,53 @@ export async function monitorLinsoProvider(opts = {}) {
65
73
  });
66
74
  }
67
75
  }
76
+ /**
77
+ * 下载图片 URL 到本地临时文件,返回本地路径
78
+ * 和 Telegram/Discord 插件处理方式一致
79
+ */
80
+ async function downloadImageToTemp(url, index) {
81
+ return new Promise((resolve) => {
82
+ try {
83
+ const ext = url.includes(".png") ? ".png" : url.includes(".gif") ? ".gif" : ".jpg";
84
+ const tmpPath = path.join(os.tmpdir(), `linso-img-${Date.now()}-${index}${ext}`);
85
+ const file = fs.createWriteStream(tmpPath);
86
+ const client = url.startsWith("https") ? https : http;
87
+ const req = client.get(url, (res) => {
88
+ if (res.statusCode !== 200) {
89
+ file.close();
90
+ resolve(null);
91
+ return;
92
+ }
93
+ res.pipe(file);
94
+ file.on("finish", () => {
95
+ file.close();
96
+ resolve(tmpPath);
97
+ });
98
+ });
99
+ req.on("error", () => { file.close(); resolve(null); });
100
+ req.setTimeout(15000, () => { req.destroy(); file.close(); resolve(null); });
101
+ }
102
+ catch {
103
+ resolve(null);
104
+ }
105
+ });
106
+ }
68
107
  /**
69
108
  * 把 iOS 消息路由到 Agent,流式回复(核心逻辑不变)
70
109
  */
71
- async function handleIncomingMessage(deviceId, text, cfg, log) {
110
+ async function handleIncomingMessage(deviceId, text, imageUrls, cfg, log) {
72
111
  const core = getLinsoRuntime();
112
+ // 下载图片到本地临时文件(和 Telegram/Discord 一致)
113
+ const localPaths = [];
114
+ if (imageUrls.length > 0) {
115
+ log(`[Linso] 下载 ${imageUrls.length} 张图片...`);
116
+ const results = await Promise.all(imageUrls.map((url, i) => downloadImageToTemp(url, i)));
117
+ for (const p of results) {
118
+ if (p)
119
+ localPaths.push(p);
120
+ }
121
+ log(`[Linso] 下载完成: ${localPaths.length}/${imageUrls.length}`);
122
+ }
73
123
  const route = core.channel.routing.resolveAgentRoute({
74
124
  cfg,
75
125
  channel: "linso",
@@ -104,6 +154,10 @@ async function handleIncomingMessage(deviceId, text, cfg, log) {
104
154
  WasMentioned: true,
105
155
  OriginatingChannel: "linso",
106
156
  OriginatingTo: `device:${deviceId}`,
157
+ ...(localPaths.length > 0 && {
158
+ MediaPaths: localPaths,
159
+ MediaTypes: localPaths.map(() => "image/jpeg"),
160
+ }),
107
161
  });
108
162
  const runId = `linso-${Date.now()}`;
109
163
  const { dispatcher, replyOptions, markDispatchIdle } = core.channel.reply.createReplyDispatcherWithTyping({
@@ -20,7 +20,7 @@ export function sendToRelay(msg) {
20
20
  }
21
21
  function _connect(relayUrl, callbacks) {
22
22
  const { appToken, onMessage, onReady, onDisconnected, log } = callbacks;
23
- const url = relayUrl.replace(/\/$/, "") + "/plugin";
23
+ const url = relayUrl.replace(/\/$/, "") + "/ws/plugin";
24
24
  log(`[Linso] 连接 Relay: ${url}`);
25
25
  ws = new WebSocket(url);
26
26
  ws.on("open", () => {
@@ -1,7 +1,6 @@
1
1
  import type { OpenClawConfig } from "openclaw/plugin-sdk";
2
2
  export type LinsoChannelConfig = {
3
3
  enabled?: boolean;
4
- relayUrl?: string;
5
4
  appToken?: string;
6
5
  agentId?: string;
7
6
  dmPolicy?: "open" | "pairing";
package/dist/src/types.js CHANGED
@@ -1,3 +1,4 @@
1
+ const RELAY_URL = "ws://api.linso.ai";
1
2
  export function resolveLinsoConfig(cfg) {
2
3
  return cfg.channels?.linso ?? {};
3
4
  }
@@ -6,8 +7,8 @@ export function resolveLinsoAccount(cfg, accountId = "default") {
6
7
  return {
7
8
  accountId,
8
9
  enabled: c.enabled ?? false,
9
- configured: !!(c.relayUrl && c.appToken),
10
- relayUrl: c.relayUrl ?? "",
10
+ configured: !!(c.appToken),
11
+ relayUrl: RELAY_URL,
11
12
  appToken: c.appToken ?? "",
12
13
  agentId: c.agentId ?? "main",
13
14
  dmPolicy: c.dmPolicy ?? "open",
package/package.json CHANGED
@@ -1,9 +1,12 @@
1
1
  {
2
2
  "name": "openclaw-linso",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Linso iOS App channel plugin for OpenClaw — connects via cloud Relay Server",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
+ "openclaw": {
8
+ "extensions": ["./dist/index.js"]
9
+ },
7
10
  "files": [
8
11
  "dist/",
9
12
  "src/",
package/src/monitor.ts CHANGED
@@ -2,6 +2,11 @@
2
2
  // 核心 monitor:Plugin 主动连接云端 Relay(和飞书连 open.feishu.cn 一致)
3
3
  // 收到 iOS 消息后路由到 Agent,流式回复经 Relay 推回 iOS
4
4
 
5
+ import fs from "fs";
6
+ import os from "os";
7
+ import path from "path";
8
+ import https from "https";
9
+ import http from "http";
5
10
  import type { OpenClawConfig } from "openclaw/plugin-sdk";
6
11
  import { getLinsoRuntime } from "./runtime.js";
7
12
  import { connectToRelay, disconnectFromRelay } from "./relay-client.js";
@@ -27,8 +32,8 @@ export async function monitorLinsoProvider(opts: MonitorLinsoOpts = {}): Promise
27
32
  return;
28
33
  }
29
34
 
30
- if (!account.relayUrl) {
31
- log("[Linso] 未配置 relayUrl,跳过 monitor");
35
+ if (!account.appToken) {
36
+ log("[Linso] 未配置 appToken,跳过 monitor");
32
37
  return;
33
38
  }
34
39
 
@@ -55,6 +60,9 @@ export async function monitorLinsoProvider(opts: MonitorLinsoOpts = {}): Promise
55
60
  onMessage: (deviceId, msg) => {
56
61
  if (msg.type === "send" && typeof msg.text === "string" && msg.text.trim()) {
57
62
  const text = msg.text.trim();
63
+ const imageUrls: string[] = Array.isArray(msg.images)
64
+ ? (msg.images as unknown[]).filter((u): u is string => typeof u === "string")
65
+ : [];
58
66
 
59
67
  // 拦截 slash 命令,直接回复,不走 Agent
60
68
  if (text.startsWith("/")) {
@@ -65,7 +73,7 @@ export async function monitorLinsoProvider(opts: MonitorLinsoOpts = {}): Promise
65
73
  return;
66
74
  }
67
75
 
68
- void handleIncomingMessage(deviceId, text, cfg, log).catch((err) => {
76
+ void handleIncomingMessage(deviceId, text, imageUrls, cfg, log).catch((err) => {
69
77
  log(`[Linso] 处理消息出错: ${String(err)}`);
70
78
  sendToClient(deviceId, { type: "error", message: String(err) });
71
79
  });
@@ -85,17 +93,61 @@ export async function monitorLinsoProvider(opts: MonitorLinsoOpts = {}): Promise
85
93
  }
86
94
  }
87
95
 
96
+ /**
97
+ * 下载图片 URL 到本地临时文件,返回本地路径
98
+ * 和 Telegram/Discord 插件处理方式一致
99
+ */
100
+ async function downloadImageToTemp(url: string, index: number): Promise<string | null> {
101
+ return new Promise((resolve) => {
102
+ try {
103
+ const ext = url.includes(".png") ? ".png" : url.includes(".gif") ? ".gif" : ".jpg";
104
+ const tmpPath = path.join(os.tmpdir(), `linso-img-${Date.now()}-${index}${ext}`);
105
+ const file = fs.createWriteStream(tmpPath);
106
+ const client = url.startsWith("https") ? https : http;
107
+
108
+ const req = client.get(url, (res) => {
109
+ if (res.statusCode !== 200) {
110
+ file.close();
111
+ resolve(null);
112
+ return;
113
+ }
114
+ res.pipe(file);
115
+ file.on("finish", () => {
116
+ file.close();
117
+ resolve(tmpPath);
118
+ });
119
+ });
120
+ req.on("error", () => { file.close(); resolve(null); });
121
+ req.setTimeout(15000, () => { req.destroy(); file.close(); resolve(null); });
122
+ } catch {
123
+ resolve(null);
124
+ }
125
+ });
126
+ }
127
+
88
128
  /**
89
129
  * 把 iOS 消息路由到 Agent,流式回复(核心逻辑不变)
90
130
  */
91
131
  async function handleIncomingMessage(
92
132
  deviceId: string,
93
133
  text: string,
134
+ imageUrls: string[],
94
135
  cfg: OpenClawConfig,
95
136
  log: (...args: unknown[]) => void,
96
137
  ): Promise<void> {
97
138
  const core = getLinsoRuntime();
98
139
 
140
+ // 下载图片到本地临时文件(和 Telegram/Discord 一致)
141
+ const localPaths: string[] = [];
142
+ if (imageUrls.length > 0) {
143
+ log(`[Linso] 下载 ${imageUrls.length} 张图片...`);
144
+ const results = await Promise.all(imageUrls.map((url, i) => downloadImageToTemp(url, i)));
145
+ for (const p of results) {
146
+ if (p) localPaths.push(p);
147
+ }
148
+ log(`[Linso] 下载完成: ${localPaths.length}/${imageUrls.length}`);
149
+ }
150
+
99
151
  const route = core.channel.routing.resolveAgentRoute({
100
152
  cfg,
101
153
  channel: "linso",
@@ -133,6 +185,10 @@ async function handleIncomingMessage(
133
185
  WasMentioned: true,
134
186
  OriginatingChannel: "linso" as const,
135
187
  OriginatingTo: `device:${deviceId}`,
188
+ ...(localPaths.length > 0 && {
189
+ MediaPaths: localPaths,
190
+ MediaTypes: localPaths.map(() => "image/jpeg"),
191
+ }),
136
192
  });
137
193
 
138
194
  const runId = `linso-${Date.now()}`;
@@ -33,7 +33,7 @@ export function sendToRelay(msg: Record<string, unknown>) {
33
33
 
34
34
  function _connect(relayUrl: string, callbacks: RelayCallbacks) {
35
35
  const { appToken, onMessage, onReady, onDisconnected, log } = callbacks;
36
- const url = relayUrl.replace(/\/$/, "") + "/plugin";
36
+ const url = relayUrl.replace(/\/$/, "") + "/ws/plugin";
37
37
  log(`[Linso] 连接 Relay: ${url}`);
38
38
 
39
39
  ws = new WebSocket(url);
package/src/types.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import type { OpenClawConfig } from "openclaw/plugin-sdk";
2
2
 
3
+ const RELAY_URL = "ws://api.linso.ai";
4
+
3
5
  export type LinsoChannelConfig = {
4
6
  enabled?: boolean;
5
- relayUrl?: string;
6
7
  appToken?: string; // App 注册拿到的 token,Plugin 用它连接 Relay
7
8
  agentId?: string;
8
9
  dmPolicy?: "open" | "pairing";
@@ -28,8 +29,8 @@ export function resolveLinsoAccount(cfg: OpenClawConfig, accountId = "default"):
28
29
  return {
29
30
  accountId,
30
31
  enabled: c.enabled ?? false,
31
- configured: !!(c.relayUrl && c.appToken),
32
- relayUrl: c.relayUrl ?? "",
32
+ configured: !!(c.appToken),
33
+ relayUrl: RELAY_URL,
33
34
  appToken: c.appToken ?? "",
34
35
  agentId: c.agentId ?? "main",
35
36
  dmPolicy: c.dmPolicy ?? "open",