@rlynicrisis/link 0.1.5 → 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/package.json +2 -2
- package/src/bot.ts +6 -6
- package/src/channel.ts +47 -1
- package/src/client-manager.ts +1 -0
- package/src/link/client.ts +77 -41
- package/src/link/protocol.ts +3 -3
- package/src/link/state-store.ts +31 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rlynicrisis/link",
|
|
3
|
-
"version": "0.1.
|
|
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.
|
|
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:${
|
|
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 ?
|
|
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
|
};
|
package/src/client-manager.ts
CHANGED
package/src/link/client.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
107
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
260
|
-
console.error(
|
|
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(
|
|
284
|
+
console.log(`${this.tag} Client verify success`);
|
|
266
285
|
break;
|
|
267
286
|
case CmdCodes.ERROR_UP:
|
|
268
|
-
console.error(
|
|
287
|
+
console.error(`${this.tag} Link server returned error:`, body);
|
|
269
288
|
break;
|
|
270
289
|
case CmdCodes.REFRESH_TOKEN_DOWN:
|
|
271
|
-
console.log(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
406
|
+
console.error(`${this.tag} Invalid refresh response:`, data);
|
|
371
407
|
}
|
|
372
408
|
} catch (e) {
|
|
373
|
-
console.error(
|
|
409
|
+
console.error(`${this.tag} Failed to refresh token:`, e);
|
|
374
410
|
}
|
|
375
411
|
}
|
|
376
412
|
}
|
package/src/link/protocol.ts
CHANGED
|
@@ -273,13 +273,13 @@ function convertProtoMessage(protoMsg: any): EmbMessage {
|
|
|
273
273
|
|
|
274
274
|
return {
|
|
275
275
|
id: protoMsg.msgId || 'unknown',
|
|
276
|
-
type: protoMsg.type
|
|
276
|
+
type: protoMsg.type ?? 1,
|
|
277
277
|
content: content,
|
|
278
|
-
fromType: protoMsg.from?.fromType
|
|
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
|
|
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
|
+
}
|