@rlynicrisis/link 0.1.4 → 0.1.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 CHANGED
@@ -25,58 +25,6 @@
25
25
  | `ssoUrl` | `https://www.bingolink.biz:443/sso` |
26
26
  | `notificationApiUrl` | `https://notificationapi.bingolink.biz:443/notificationapi` |
27
27
 
28
- **必填字段只有 `accessToken`**(群组模式还需额外填写 `groupId` 和 `botToken`)。
29
-
30
- ## 安装与配置
31
-
32
- ### 1. 构建插件
33
-
34
- 在插件根目录下运行:
35
-
36
- ```bash
37
- npm install
38
- npm run build
39
- ```
40
-
41
- 这将生成 `dist` 目录,包含编译后的插件代码。
42
-
43
- ### 2. 配置 OpenClaw
44
-
45
- 在您的 OpenClaw 主配置文件(通常是 `config.yaml` 或 `config.json`)中,添加以下内容:
46
-
47
- #### 最简配置(FileHelper 模式)
48
-
49
- ```yaml
50
- channels:
51
- link:
52
- # 必填:鉴权 Token
53
- accessToken: "your_access_token_here"
54
-
55
- # 以下均可省略,使用默认值:
56
- # host: "embtcp.bingolink.biz:20081"
57
- # ssoUrl: "https://www.bingolink.biz:443/sso"
58
-
59
- # Token 自动刷新(可选,推荐配置)
60
- # refreshToken: "your_refresh_token_here"
61
- ```
62
-
63
- #### 完整配置参考
64
-
65
- ```yaml
66
- channels:
67
- link:
68
- host: "embtcp.bingolink.biz:20081" # 消息服务地址,可省略
69
- accessToken: "your_access_token_here" # 必填
70
- refreshToken: "your_refresh_token_here" # 可选,用于 Token 自动刷新
71
- ssoUrl: "https://www.bingolink.biz:443/sso" # 可省略,刷新 Token 时使用
72
- heartbeatIntervalMs: 30000 # 心跳间隔,默认 30 秒
73
- ```
74
-
75
- > **注意**:
76
- > - `userId` 将自动从 `accessToken` (JWT) 中解析。
77
- > - `deviceUID` 将根据机器特征自动生成。
78
- > - `host` 支持 `host:port` 格式,默认端口 `20081`。
79
-
80
28
  ## 接入 OpenClaw
81
29
 
82
30
  ### 1. 安装插件
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rlynicrisis/link",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "type": "module",
5
5
  "description": "OpenClaw Link channel plugin",
6
6
  "files": [
@@ -30,7 +30,7 @@
30
30
  "@types/ws": "^8.5.12",
31
31
  "openclaw": "latest",
32
32
  "protobufjs-cli": "^2.0.0",
33
- "typescript": "^5.5.4",
33
+ "typescript": "^5.9.3",
34
34
  "vitest": "^2.0.5"
35
35
  },
36
36
  "openclaw": {
package/src/bot.ts CHANGED
@@ -70,11 +70,11 @@ async function handleLinkMessage(params: {
70
70
  const { cfg, msg, runtime, accountId, linkCfg } = params;
71
71
  const core = getLinkRuntime();
72
72
 
73
- console.log(`[LinkBot] Received message: type=${msg.type}, fromId=${msg.fromId}, toId=${msg.toId}`);
73
+ console.log(`[LinkBot:${accountId}] Received message: type=${msg.type}, fromId=${msg.fromId}, toId=${msg.toId}`);
74
74
 
75
75
  if (msg.type !== MsgType.TEXT) {
76
76
  // Only support text for now
77
- console.log(`[LinkBot] Ignoring non-text message: type=${msg.type}`);
77
+ console.log(`[LinkBot:${accountId}] Ignoring non-text message: type=${msg.type}`);
78
78
  return;
79
79
  }
80
80
 
@@ -83,7 +83,7 @@ async function handleLinkMessage(params: {
83
83
  const allowedUserId = linkCfg.verifyInfo?.userId;
84
84
  const receiverId = msg.toId || "unknown";
85
85
 
86
- console.log(`[LinkBot] allowedUserId=${allowedUserId}, senderId=${senderId}, receiverId=${receiverId}, toType=${msg.toType}`);
86
+ console.log(`[LinkBot:${accountId}] allowedUserId=${allowedUserId}, senderId=${senderId}, receiverId=${receiverId}, toType=${msg.toType}`);
87
87
 
88
88
  if (linkCfg.groupId) {
89
89
  // Group mode: only accept messages sent to the configured group
@@ -103,7 +103,7 @@ async function handleLinkMessage(params: {
103
103
  bodyStr = JSON.stringify(msg.content);
104
104
  }
105
105
 
106
- console.log(`[LinkBot] Message Body: ${bodyStr}`);
106
+ console.log(`[LinkBot:${accountId}] Message Body: ${bodyStr}`);
107
107
 
108
108
 
109
109
  // Determine chat ID (for reply)
@@ -113,7 +113,7 @@ async function handleLinkMessage(params: {
113
113
  const chatId = isGroup ? msg.toId : senderId;
114
114
 
115
115
  // Map Link message to OpenClaw InboundContext
116
- const sessionKey = `link:${chatId}`;
116
+ const sessionKey = `link:${accountId}`;
117
117
 
118
118
  const ctx = core.channel.reply.finalizeInboundContext({
119
119
  Body: bodyStr,
@@ -124,7 +124,7 @@ async function handleLinkMessage(params: {
124
124
  SessionKey: sessionKey,
125
125
  AccountId: accountId,
126
126
  ChatType: isGroup ? "group" : "direct",
127
- GroupSubject: isGroup ? chatId : undefined,
127
+ GroupSubject: isGroup ? sessionKey : undefined,
128
128
  SenderName: msg.fromName || senderId,
129
129
  SenderId: senderId,
130
130
  Provider: "link" as any,
package/src/channel.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import type { ChannelPlugin } from "openclaw/plugin-sdk";
2
2
  import { startLinkBot, stopLinkBot } from "./bot.js";
3
3
  import { linkOnboardingAdapter } from "./onboarding.js";
4
- import { LinkChannelConfig } from "./types.js";
4
+ import { LinkChannelConfig, resolveAccountConfig, applyLinkDefaults } from "./types.js";
5
+ import { sendMessageLink } from "./send.js";
5
6
 
6
7
  export const linkPlugin: ChannelPlugin<any> = {
7
8
  id: "link",
@@ -91,5 +92,50 @@ export const linkPlugin: ChannelPlugin<any> = {
91
92
  });
92
93
  });
93
94
  }
95
+ },
96
+ outbound: {
97
+ deliveryMode: "direct" as const,
98
+ textChunkLimit: 4096,
99
+ sendMedia: async (ctx: any) => {
100
+ // Link channel does not support media sending
101
+ return { channel: "link", messageId: "", error: "Media sending not supported on Link channel" };
102
+ },
103
+ sendText: async (ctx: any) => {
104
+ //打印ctx,json字符串
105
+ console.log("ctx:", JSON.stringify(ctx));
106
+ const { cfg, to, text, accountId } = ctx;
107
+ const channelCfg = cfg?.channels?.link as LinkChannelConfig | undefined;
108
+
109
+ if (!channelCfg) {
110
+ return { channel: "link", messageId: "", error: "Link channel not configured" };
111
+ }
112
+
113
+ const rawCfg = resolveAccountConfig(channelCfg, accountId || "default");
114
+ if (!rawCfg) {
115
+ return { channel: "link", messageId: "", error: `Link config not found for account ${accountId}` };
116
+ }
117
+
118
+ const linkCfg = applyLinkDefaults(rawCfg);
119
+
120
+ if (!to) {
121
+ return { channel: "link", messageId: "", error: "No target specified" };
122
+ }
123
+
124
+ if (!text) {
125
+ return { channel: "link", messageId: "", error: "No text content" };
126
+ }
127
+
128
+ try {
129
+ await sendMessageLink({
130
+ to,
131
+ text,
132
+ accountId: accountId || "default",
133
+ cfg: linkCfg
134
+ });
135
+ return { channel: "link", messageId: `link-${Date.now()}` };
136
+ } catch (error) {
137
+ return { channel: "link", messageId: "", error: String(error) };
138
+ }
139
+ }
94
140
  }
95
141
  };
@@ -26,6 +26,7 @@ export function createLinkClient(accountId: string, config: LinkConfig): LinkCli
26
26
  }
27
27
 
28
28
  const client = new LinkClient({
29
+ accountId,
29
30
  host,
30
31
  port,
31
32
  accessToken: config.accessToken,
@@ -6,6 +6,7 @@ import * as crypto from 'crypto';
6
6
  import { CmdCodes } from './constants.js';
7
7
  import { decodePacket, encodePacket, encodeMessage, decodeMessage, ProtocolError } from './protocol.js';
8
8
  import { EmbMessage, ClientVerifyInfo } from './types.js';
9
+ import { loadLastMsgTime, saveLastMsgTime } from './state-store.js';
9
10
 
10
11
  export class LinkClient extends EventEmitter {
11
12
  private socket: net.Socket | null = null;
@@ -14,9 +15,13 @@ export class LinkClient extends EventEmitter {
14
15
  private reconnectTimeout: NodeJS.Timeout | null = null;
15
16
  private isConnected = false;
16
17
  private immediateReconnect = false;
17
-
18
+ private stopped = false;
19
+ private lastOfflineMsgSendTime: number;
20
+ private get tag() { return `[LinkBot:${this.config.accountId}]`; }
21
+
18
22
  constructor(
19
23
  public readonly config: {
24
+ accountId: string;
20
25
  host: string;
21
26
  port: number;
22
27
  accessToken: string;
@@ -27,17 +32,19 @@ export class LinkClient extends EventEmitter {
27
32
  }
28
33
  ) {
29
34
  super();
35
+ this.lastOfflineMsgSendTime = loadLastMsgTime(config.accountId);
30
36
  }
31
37
 
32
38
  public connect() {
39
+ this.stopped = false;
33
40
  if (this.socket) {
34
41
  this.socket.destroy();
35
42
  }
36
43
 
37
44
  this.socket = new net.Socket();
38
-
45
+
39
46
  this.socket.on('connect', () => {
40
- console.log('LinkClient connected');
47
+ console.log(`${this.tag} LinkClient connected`);
41
48
  this.isConnected = true;
42
49
  this.sendVerify();
43
50
  this.startHeartbeat();
@@ -50,7 +57,7 @@ export class LinkClient extends EventEmitter {
50
57
  });
51
58
 
52
59
  this.socket.on('close', () => {
53
- console.log('LinkClient disconnected');
60
+ console.log(`${this.tag} LinkClient disconnected`);
54
61
  this.isConnected = false;
55
62
  this.stopHeartbeat();
56
63
  this.emit('disconnected');
@@ -58,7 +65,7 @@ export class LinkClient extends EventEmitter {
58
65
  });
59
66
 
60
67
  this.socket.on('error', (err) => {
61
- console.error('LinkClient error:', err);
68
+ console.error(`${this.tag} LinkClient error:`, err);
62
69
  this.emit('error', err);
63
70
  });
64
71
 
@@ -66,9 +73,11 @@ export class LinkClient extends EventEmitter {
66
73
  }
67
74
 
68
75
  public disconnect() {
76
+ this.stopped = true;
69
77
  this.stopHeartbeat();
70
78
  if (this.reconnectTimeout) {
71
79
  clearTimeout(this.reconnectTimeout);
80
+ this.reconnectTimeout = null;
72
81
  }
73
82
  if (this.socket) {
74
83
  this.socket.destroy();
@@ -96,37 +105,36 @@ export class LinkClient extends EventEmitter {
96
105
  const payload = JSON.parse(Buffer.from(tokenParts[1], 'base64').toString());
97
106
  userId = payload.sub || payload.user_id || 'unknown';
98
107
  } catch (e) {
99
- console.error('Failed to parse accessToken for userId', e);
108
+ console.error(`${this.tag} Failed to parse accessToken for userId`, e);
100
109
  }
101
110
  }
102
111
 
103
112
  // Generate stable deviceUID based on machine info if not provided
104
113
  let deviceUID = this.config.verifyInfo?.deviceUID;
105
114
  if (!deviceUID) {
106
- // ... (keep existing logic)
107
- deviceUID = `openclaw-link-${userId}`;
115
+ const machineInfo = `${os.hostname()}-${os.platform()}-${os.arch()}`;
116
+ const machineHash = crypto.createHash('sha256').update(machineInfo).digest('hex').slice(0, 8);
117
+ deviceUID = `openclaw-link-${userId}-${this.config.accountId}-${machineHash}`;
108
118
  }
109
119
 
110
120
  const info = {
111
121
  userId: userId,
112
- deviceUID: deviceUID!,
122
+ deviceUID: deviceUID!,
113
123
  deviceName: 'OpenClawBot',
114
- deviceToken: 'openclaw-device-token',
124
+ deviceToken: 'openclaw-device-token',
115
125
  accessToken: this.config.accessToken,
116
126
  version: '1.0.0',
117
127
  force: true,
118
128
  protocolVersion: 3.0,
119
129
  isFirstLogin: false,
120
130
  secret: '',
121
- ...this.config.verifyInfo
131
+ ...this.config.verifyInfo
122
132
  };
123
133
 
124
134
  if (!this.config.verifyInfo) {
125
135
  // Should not happen if initialized correctly in client-manager
126
136
  // But if it is undefined, we can't update the config object passed in constructor if it's readonly
127
137
  // However, this.config.verifyInfo is a reference.
128
- // Let's cast to writable or ensure it is set.
129
- // Actually, 'config' is readonly, but 'verifyInfo' property inside it is an object reference.
130
138
  // If verifyInfo was undefined, we can't assign to it.
131
139
  // We need to make sure verifyInfo is initialized in constructor or allow it to be mutable.
132
140
  // The best way is to ensure client-manager passes an object reference.
@@ -135,7 +143,7 @@ export class LinkClient extends EventEmitter {
135
143
  // But wait, typescript readonly on config means we can't assign to config.verifyInfo if it's not there.
136
144
  // We can assign to properties OF verifyInfo if verifyInfo exists.
137
145
  }
138
-
146
+
139
147
  if (this.config.verifyInfo) {
140
148
  this.config.verifyInfo.userId = info.userId;
141
149
  this.config.verifyInfo.deviceUID = info.deviceUID;
@@ -158,7 +166,7 @@ export class LinkClient extends EventEmitter {
158
166
  };
159
167
 
160
168
  const body = JSON.stringify(verifyBody);
161
-
169
+
162
170
  const packet = encodePacket(CmdCodes.CLIENT_VERIFY_UP, body);
163
171
  if (this.socket) this.socket.write(packet);
164
172
  }
@@ -169,6 +177,7 @@ export class LinkClient extends EventEmitter {
169
177
  if (this.socket && this.isConnected) {
170
178
  const packet = encodePacket(CmdCodes.HEART_BEAT_UP);
171
179
  this.socket.write(packet);
180
+ console.log(`${this.tag} Heartbeat UP sent`);
172
181
  }
173
182
  }, this.config.heartbeatIntervalMs || 30000);
174
183
  }
@@ -181,12 +190,13 @@ export class LinkClient extends EventEmitter {
181
190
  }
182
191
 
183
192
  private scheduleReconnect() {
193
+ if (this.stopped) return;
184
194
  if (this.reconnectTimeout) return;
185
195
  const delay = this.immediateReconnect ? 0 : 5000;
186
196
  this.immediateReconnect = false;
187
197
  this.reconnectTimeout = setTimeout(() => {
188
198
  this.reconnectTimeout = null;
189
- console.log('Reconnecting...');
199
+ console.log(`${this.tag} Reconnecting...`);
190
200
  this.connect();
191
201
  }, delay);
192
202
  }
@@ -197,18 +207,18 @@ export class LinkClient extends EventEmitter {
197
207
  // Try to decode one packet
198
208
  // We need to peek header first
199
209
  if (this.buffer.length < 4) break; // Wait for more data
200
-
210
+
201
211
  // decodePacket throws if incomplete body, but we need to handle that gracefully
202
212
  // The decodePacket implementation I wrote throws "Incomplete body" which is not ideal for stream processing
203
- // Let's modify decodePacket or handle it here.
213
+ // Let's modify decodePacket or handle it here.
204
214
  // Better: implement a peek function or modify decodePacket to return null if incomplete.
205
215
  // For now, I'll rely on the length check I added in decodePacket:
206
216
  // if (buffer.length < 8 + bodyLen) throw ...
207
-
217
+
208
218
  // Wait, if it throws "Incomplete body", I should catch it and return.
209
219
  // But "ProtocolError" might mean malformed too.
210
220
  // I should probably improve decodePacket to differentiate "Need more data" vs "Invalid data".
211
-
221
+
212
222
  // Let's do a manual check here to be safe and efficient
213
223
  const bodyCount = this.buffer[3];
214
224
  let requiredLen = 4;
@@ -217,13 +227,13 @@ export class LinkClient extends EventEmitter {
217
227
  const bodyLen = this.buffer.readUInt32BE(4);
218
228
  requiredLen = 8 + bodyLen;
219
229
  }
220
-
230
+
221
231
  if (this.buffer.length < requiredLen) break; // Wait for more data
222
232
 
223
233
  // Now we have a full packet
224
234
  const packetData = this.buffer.subarray(0, requiredLen);
225
235
  const { cmdCode, body } = decodePacket(packetData); // This shouldn't throw "Incomplete" now
226
-
236
+
227
237
  // Advance buffer
228
238
  this.buffer = this.buffer.subarray(requiredLen);
229
239
 
@@ -233,7 +243,7 @@ export class LinkClient extends EventEmitter {
233
243
  if (err instanceof ProtocolError) {
234
244
  // If it's truly a protocol error (e.g. invalid header), we might need to close connection or skip byte?
235
245
  // For simplicity, let's log and maybe close.
236
- console.error('Protocol error:', err);
246
+ console.error(`${this.tag} Protocol error:`, err);
237
247
  // Skip one byte and try again to resync?
238
248
  this.buffer = this.buffer.subarray(1);
239
249
  // this.socket?.destroy();
@@ -247,71 +257,97 @@ export class LinkClient extends EventEmitter {
247
257
  private async handleCommand(cmdCode: number, body?: string | Buffer) {
248
258
  switch (cmdCode) {
249
259
  case CmdCodes.HEART_BEAT_DOWN:
250
- // Heartbeat ack, ignore or reset timer
260
+ console.log(`${this.tag} Heartbeat DOWN received (server ack)`);
251
261
  break;
252
262
  case CmdCodes.SEND_MSG:
253
263
  if (body) {
254
264
  try {
255
265
  const msg = decodeMessage(body);
266
+ if (this.lastOfflineMsgSendTime > 0 && msg.sendTime <= this.lastOfflineMsgSendTime) {
267
+ console.log(`${this.tag} Skipping duplicate offline message (sendTime=${msg.sendTime})`);
268
+ this.sendReceipt(msg.id);
269
+ break;
270
+ }
271
+ if (msg.sendTime > this.lastOfflineMsgSendTime) {
272
+ this.lastOfflineMsgSendTime = msg.sendTime;
273
+ saveLastMsgTime(this.config.accountId, this.lastOfflineMsgSendTime);
274
+ }
256
275
  this.sendReceipt(msg.id);
257
276
  this.emit('message', msg);
258
277
  } catch (e) {
259
- console.error('Failed to decode message:', e);
260
- console.error('Raw body:', body);
278
+ console.error(`${this.tag} Failed to decode message:`, e);
279
+ console.error(`${this.tag} Raw body:`, body);
261
280
  }
262
281
  }
263
282
  break;
264
283
  case CmdCodes.CLIENT_VERIFY_DOWN:
265
- console.log('Client verify success');
284
+ console.log(`${this.tag} Client verify success`);
266
285
  break;
267
286
  case CmdCodes.ERROR_UP:
268
- console.error('Link server returned error:', body);
287
+ console.error(`${this.tag} Link server returned error:`, body);
269
288
  break;
270
289
  case CmdCodes.REFRESH_TOKEN_DOWN:
271
- console.log('Received REFRESH_TOKEN_DOWN (0x87), token might be expired. Attempting refresh...');
290
+ console.log(`${this.tag} Received REFRESH_TOKEN_DOWN (0x87), token might be expired. Attempting refresh...`);
272
291
  await this.refreshAccessToken();
273
292
  break;
274
293
  case CmdCodes.OFFLINE_UNEND_DOWN:
294
+ console.log(`${this.tag} Offline messages not complete, pulling more...`);
295
+ this.requestOfflineMessages();
296
+ break;
275
297
  case CmdCodes.OFFLINE_END_DOWN:
298
+ console.log(`${this.tag} Offline messages fully received`);
299
+ break;
276
300
  case CmdCodes.SINGLE_DEVICE_DOWN:
277
301
  case CmdCodes.SLEEP_DOWN:
302
+ console.log(`${this.tag} Received cmd: 0x${cmdCode.toString(16)}`);
303
+ break;
278
304
  case CmdCodes.SEND_MSG_RECEIPT:
279
- console.log('Received message receipt from server. Body len:', body ? body.length : 0);
305
+ console.log(`${this.tag} Received message receipt from server. Body len:`, body ? body.length : 0);
280
306
  break;
281
307
  case CmdCodes.REC_READ_DOWN:
282
308
  // Ignore for now or log
283
309
  // console.log(`Received cmd: 0x${cmdCode.toString(16)}, body: ${body}`);
284
310
  break;
285
311
  case CmdCodes.FORCE_RECONNECT_DOWN:
286
- console.log('Server signaled session reset (0x6b), will reconnect immediately on disconnect');
312
+ console.log(`${this.tag} Server signaled session reset (0x6b), will reconnect immediately on disconnect`);
287
313
  this.immediateReconnect = true;
288
314
  break;
289
315
  default:
290
- console.log(`Received unknown cmd: 0x${cmdCode.toString(16)}, body: ${body}`);
316
+ console.log(`${this.tag} Received unknown cmd: 0x${cmdCode.toString(16)}, body: ${body}`);
291
317
  }
292
318
  }
293
319
 
320
+ private requestOfflineMessages() {
321
+ if (!this.socket || !this.isConnected) return;
322
+ const body = JSON.stringify({
323
+ pageSize: -1,
324
+ startSendTime: this.lastOfflineMsgSendTime + 1,
325
+ });
326
+ const packet = encodePacket(CmdCodes.OFFLINE_GET_UP, body);
327
+ this.socket.write(packet);
328
+ }
329
+
294
330
  private sendReceipt(msgId: unknown) {
295
331
  if (!this.socket || !this.isConnected) return;
296
- console.log('Sending receipt for message:', msgId);
332
+ console.log(`${this.tag} Sending receipt for message:`, msgId);
297
333
  const packet = encodePacket(CmdCodes.SEND_MSG_RECEIPT, String(msgId));
298
334
  this.socket.write(packet);
299
335
  }
300
336
 
301
337
  private async refreshAccessToken() {
302
338
  if (!this.config.refreshToken || !this.config.ssoUrl) {
303
- console.error('Cannot refresh token: Missing refreshToken or ssoUrl in config');
339
+ console.error(`${this.tag} Cannot refresh token: Missing refreshToken or ssoUrl in config`);
304
340
  return;
305
341
  }
306
342
 
307
343
  try {
308
- console.log('Refreshing access token via SSO...');
344
+ console.log(`${this.tag} Refreshing access token via SSO...`);
309
345
  let ssoUrl = this.config.ssoUrl;
310
346
  if (!ssoUrl.endsWith('/')) {
311
347
  ssoUrl += '/';
312
348
  }
313
349
  const url = new URL('oauth2/token', ssoUrl);
314
-
350
+
315
351
  // Assume client credentials (clientId/clientSecret) are encoded in basic auth or not needed?
316
352
  // User input implies "Authorization: Basic Y2xpZW50SWQ6Y2xpZW50U2VjcmV0" (clientId:clientSecret)
317
353
  // Since we don't have clientId/Secret in config, we might need to add them or assume they are fixed/optional.
@@ -329,7 +365,7 @@ export class LinkClient extends EventEmitter {
329
365
  // And maybe use a default or empty basic auth if not provided?
330
366
  // Let's check if we can add clientId/Secret to config. The prompt didn't explicitly ask for them, but implied by the example.
331
367
  // I'll assume for now that I should just send the POST request.
332
-
368
+
333
369
  // Let's add a placeholder for Authorization header if not configurable.
334
370
  const authHeader = 'Basic Y2xpZW50SWQ6Y2xpZW50U2VjcmV0'; // From example
335
371
 
@@ -353,12 +389,12 @@ export class LinkClient extends EventEmitter {
353
389
 
354
390
  const data = await response.json();
355
391
  if (data.access_token) {
356
- console.log('Token refreshed successfully');
392
+ console.log(`${this.tag} Token refreshed successfully`);
357
393
  this.config.accessToken = data.access_token;
358
394
  if (data.refresh_token) {
359
395
  this.config.refreshToken = data.refresh_token;
360
396
  }
361
-
397
+
362
398
  // Re-verify with new token
363
399
  // We might need to disconnect and reconnect, or just send verify packet again?
364
400
  // Usually Verify packet is sent after connection.
@@ -367,10 +403,10 @@ export class LinkClient extends EventEmitter {
367
403
  // Let's try sending Verify again.
368
404
  this.sendVerify();
369
405
  } else {
370
- console.error('Invalid refresh response:', data);
406
+ console.error(`${this.tag} Invalid refresh response:`, data);
371
407
  }
372
408
  } catch (e) {
373
- console.error('Failed to refresh token:', e);
409
+ console.error(`${this.tag} Failed to refresh token:`, e);
374
410
  }
375
411
  }
376
412
  }
@@ -273,13 +273,13 @@ function convertProtoMessage(protoMsg: any): EmbMessage {
273
273
 
274
274
  return {
275
275
  id: protoMsg.msgId || 'unknown',
276
- type: protoMsg.type || 1,
276
+ type: protoMsg.type ?? 1,
277
277
  content: content,
278
- fromType: protoMsg.from?.fromType || 1,
278
+ fromType: protoMsg.from?.fromType ?? 1,
279
279
  fromId: protoMsg.from?.fromId || 'unknown',
280
280
  fromName: protoMsg.from?.fromName || undefined,
281
281
  fromCompany: protoMsg.from?.fromCompany || undefined,
282
- toType: protoMsg.to?.toType || 1,
282
+ toType: protoMsg.to?.toType ?? 1,
283
283
  toId: protoMsg.to?.toId || 'unknown',
284
284
  toName: protoMsg.to?.toName || undefined,
285
285
  toCompany: protoMsg.to?.toCompany || undefined,
@@ -0,0 +1,31 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import * as os from 'os';
4
+
5
+ const STATE_DIR = path.join(os.homedir(), '.openclaw', 'link');
6
+ const STATE_FILE = path.join(STATE_DIR, 'state.json');
7
+
8
+ type StateMap = Record<string, { lastOfflineMsgSendTime: number }>;
9
+
10
+ function readAll(): StateMap {
11
+ try {
12
+ return JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8'));
13
+ } catch {
14
+ return {};
15
+ }
16
+ }
17
+
18
+ export function loadLastMsgTime(accountId: string): number {
19
+ return readAll()[accountId]?.lastOfflineMsgSendTime ?? 0;
20
+ }
21
+
22
+ export function saveLastMsgTime(accountId: string, time: number): void {
23
+ try {
24
+ fs.mkdirSync(STATE_DIR, { recursive: true });
25
+ const all = readAll();
26
+ all[accountId] = { lastOfflineMsgSendTime: time };
27
+ fs.writeFileSync(STATE_FILE, JSON.stringify(all, null, 2));
28
+ } catch (e) {
29
+ console.error(`[LinkBot:${accountId}] Failed to save state:`, e);
30
+ }
31
+ }