@workclaw/openclaw-workchat 1.0.5 → 1.1.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/index.js +720 -233
- package/package.json +1 -1
- package/src/api/api.ts +74 -7
- package/src/api/session-guard.ts +84 -0
- package/src/api/types.ts +18 -3
- package/src/channel.ts +71 -7
- package/src/messaging/inbound.ts +56 -2
- package/src/messaging/process-message.ts +120 -3
- package/src/messaging/send-media.ts +201 -0
- package/src/messaging/send.ts +5 -0
- package/src/storage/seen-msgids.ts +79 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import type { WorkChatApiOptions } from "../api/api.js";
|
|
6
|
+
import { sendWorkChatMessage, sendWorkChatReplyMessage, uploadWorkChatFile } from "../api/api.js";
|
|
7
|
+
import { assertSessionActive, pauseOnPermanentError } from "../api/session-guard.js";
|
|
8
|
+
import { WorkChatMsgType } from "../api/types.js";
|
|
9
|
+
import type { ResolvedWorkChatAccount } from "../auth/accounts.js";
|
|
10
|
+
import { logger } from "../util/logger.js";
|
|
11
|
+
import { generateId } from "../util/random.js";
|
|
12
|
+
|
|
13
|
+
import { buildApiOptions, pushWorkChatMessage } from "./send.js";
|
|
14
|
+
|
|
15
|
+
/** Backend FileType whitelist (tuzai FileServiceImpl rejects unknown suffixes). */
|
|
16
|
+
const AUDIO_EXTENSIONS = new Set(["wav", "m4a", "mp3"]);
|
|
17
|
+
|
|
18
|
+
/** True when mediaUrl refers to a local filesystem path (not a remote URL). */
|
|
19
|
+
export function isLocalMediaSource(mediaUrl: string): boolean {
|
|
20
|
+
const trimmed = mediaUrl.trim();
|
|
21
|
+
if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) return false;
|
|
22
|
+
return trimmed.startsWith("/")
|
|
23
|
+
|| trimmed.startsWith("./")
|
|
24
|
+
|| trimmed.startsWith("../")
|
|
25
|
+
|| trimmed.startsWith("~")
|
|
26
|
+
|| trimmed.startsWith("file://")
|
|
27
|
+
|| path.isAbsolute(trimmed);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function resolveLocalMediaPath(mediaUrl: string): string {
|
|
31
|
+
if (mediaUrl.startsWith("file://")) {
|
|
32
|
+
const parsed = new URL(mediaUrl);
|
|
33
|
+
return process.platform === "win32" ? parsed.pathname.replace(/^\/([A-Za-z]:)/, "$1") : parsed.pathname;
|
|
34
|
+
}
|
|
35
|
+
if (mediaUrl.startsWith("~")) {
|
|
36
|
+
return path.join(os.homedir(), mediaUrl.slice(1));
|
|
37
|
+
}
|
|
38
|
+
return path.resolve(mediaUrl);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Map a file path to the message type the WorkChat client renders as a card.
|
|
43
|
+
* The backend ChatType enum has Video/Voice/Link codes, but the open send API
|
|
44
|
+
* documents only text/image/audio — audio extensions ride "audio", everything
|
|
45
|
+
* else (images, and files outside the documented set) rides "image".
|
|
46
|
+
*/
|
|
47
|
+
function resolveMediaMsgType(filePath: string): "image" | "audio" {
|
|
48
|
+
const ext = path.extname(filePath).replace(/^\./, "").toLowerCase();
|
|
49
|
+
return AUDIO_EXTENSIONS.has(ext) ? "audio" : "image";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface MediaSendParams {
|
|
53
|
+
account: ResolvedWorkChatAccount;
|
|
54
|
+
to: string;
|
|
55
|
+
text?: string;
|
|
56
|
+
replyToMessageId?: string;
|
|
57
|
+
conversationId?: string;
|
|
58
|
+
agentId?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function requireAgentId(account: ResolvedWorkChatAccount, agentId?: string): string {
|
|
62
|
+
const resolved = agentId ?? account.agentId;
|
|
63
|
+
if (!resolved) {
|
|
64
|
+
throw new Error("workchat: agentId is required — complete the binding QR flow first");
|
|
65
|
+
}
|
|
66
|
+
return resolved;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function sendMediaRequest(params: {
|
|
70
|
+
account: ResolvedWorkChatAccount;
|
|
71
|
+
to: string;
|
|
72
|
+
msgType: string;
|
|
73
|
+
content: string;
|
|
74
|
+
replyToMessageId?: string;
|
|
75
|
+
conversationId?: string;
|
|
76
|
+
agentId?: string;
|
|
77
|
+
}): Promise<{ messageId: string }> {
|
|
78
|
+
const agentId = requireAgentId(params.account, params.agentId);
|
|
79
|
+
assertSessionActive(params.account.accountId);
|
|
80
|
+
const opts = buildApiOptions(params.account);
|
|
81
|
+
const body: {
|
|
82
|
+
agentId: string;
|
|
83
|
+
receiveId: string;
|
|
84
|
+
msgType: string;
|
|
85
|
+
content: string;
|
|
86
|
+
conversationId?: string;
|
|
87
|
+
is_last?: boolean;
|
|
88
|
+
} = {
|
|
89
|
+
agentId,
|
|
90
|
+
receiveId: params.account.userId ?? params.to,
|
|
91
|
+
msgType: params.msgType,
|
|
92
|
+
content: params.content,
|
|
93
|
+
...(params.conversationId ? { conversationId: params.conversationId } : {}),
|
|
94
|
+
};
|
|
95
|
+
// is_last closes the streamed reply placeholder; media replies are single
|
|
96
|
+
// messages, so the media card itself is always the final frame.
|
|
97
|
+
if (params.replyToMessageId) body.is_last = true;
|
|
98
|
+
try {
|
|
99
|
+
if (params.replyToMessageId) {
|
|
100
|
+
const result = await sendWorkChatReplyMessage({ opts, messageId: params.replyToMessageId, body });
|
|
101
|
+
logger.info(`reply media sent to=${params.to} messageId=${result.msgId}`);
|
|
102
|
+
return { messageId: result.msgId };
|
|
103
|
+
}
|
|
104
|
+
const result = await sendWorkChatMessage({ opts, body });
|
|
105
|
+
logger.info(`push media sent to=${params.to} messageId=${result.msgId}`);
|
|
106
|
+
return { messageId: result.msgId };
|
|
107
|
+
} catch (err) {
|
|
108
|
+
pauseOnPermanentError(params.account.accountId, err instanceof Error ? err.message : String(err));
|
|
109
|
+
throw err;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Upload a local file and send it as a WorkChat image/audio message:
|
|
115
|
+
* audio (wav/m4a/mp3) → msgType "audio", content {"url"}
|
|
116
|
+
* everything else → msgType "image", content {"url"}
|
|
117
|
+
* The media content JSON cannot carry a caption, so a text caption (when
|
|
118
|
+
* present) is sent as its own message right before the media card.
|
|
119
|
+
*/
|
|
120
|
+
export async function sendWorkChatMediaFile(params: MediaSendParams & {
|
|
121
|
+
filePath: string;
|
|
122
|
+
}): Promise<{ messageId: string }> {
|
|
123
|
+
const { account, to, filePath } = params;
|
|
124
|
+
const opts: WorkChatApiOptions = buildApiOptions(account);
|
|
125
|
+
|
|
126
|
+
logger.info(`sendMedia: uploading filePath=${filePath} to=${to}`);
|
|
127
|
+
const uploaded = await uploadWorkChatFile({ opts, filePath, userId: account.userId ?? to });
|
|
128
|
+
logger.info(`sendMedia: upload done urlLen=${uploaded.url.length} suffix=${uploaded.suffix ?? "(none)"}`);
|
|
129
|
+
|
|
130
|
+
const caption = params.text?.trim() ?? "";
|
|
131
|
+
if (caption) {
|
|
132
|
+
await pushWorkChatMessage({
|
|
133
|
+
account,
|
|
134
|
+
to,
|
|
135
|
+
text: caption,
|
|
136
|
+
conversationId: params.conversationId,
|
|
137
|
+
agentId: params.agentId,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const msgType = resolveMediaMsgType(filePath) === "audio" ? WorkChatMsgType.Audio : WorkChatMsgType.Image;
|
|
142
|
+
return sendMediaRequest({
|
|
143
|
+
account,
|
|
144
|
+
to,
|
|
145
|
+
msgType,
|
|
146
|
+
content: JSON.stringify({ url: uploaded.url }),
|
|
147
|
+
replyToMessageId: params.replyToMessageId,
|
|
148
|
+
conversationId: params.conversationId,
|
|
149
|
+
agentId: params.agentId,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
// Remote URL relay
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
const REMOTE_MEDIA_DIR = path.join(os.tmpdir(), "openclaw", "workchat", "media");
|
|
158
|
+
const REMOTE_MEDIA_DOWNLOAD_TIMEOUT_MS = 60_000;
|
|
159
|
+
|
|
160
|
+
function tempMediaPath(ext: string): string {
|
|
161
|
+
return path.join(REMOTE_MEDIA_DIR, `${generateId("remote").replace(/:/g, "-")}${ext}`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function extensionFromUrlOrContentType(contentType: string | null, url: string): string {
|
|
165
|
+
const fromUrl = path.extname(new URL(url).pathname).toLowerCase();
|
|
166
|
+
if (fromUrl) return fromUrl;
|
|
167
|
+
if (contentType?.startsWith("audio/mpeg")) return ".mp3";
|
|
168
|
+
if (contentType?.startsWith("audio/wav")) return ".wav";
|
|
169
|
+
if (contentType?.startsWith("audio/mp4")) return ".m4a";
|
|
170
|
+
if (contentType?.startsWith("image/")) return ".png";
|
|
171
|
+
return ".bin";
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Download a remote media URL to a local temp file so the send pipeline can
|
|
176
|
+
* re-upload it to WorkChat file storage — clients render only WorkChat-hosted
|
|
177
|
+
* image/audio cards, never bare links. Bounded by a 60s abort so a hung
|
|
178
|
+
* remote never stalls the reply pipeline.
|
|
179
|
+
*/
|
|
180
|
+
export async function downloadRemoteMediaToTemp(url: string): Promise<string> {
|
|
181
|
+
const controller = new AbortController();
|
|
182
|
+
const timeoutId = setTimeout(() => controller.abort(), REMOTE_MEDIA_DOWNLOAD_TIMEOUT_MS);
|
|
183
|
+
let response: Response;
|
|
184
|
+
try {
|
|
185
|
+
response = (await globalThis.fetch(url, { signal: controller.signal })) as Response;
|
|
186
|
+
} finally {
|
|
187
|
+
clearTimeout(timeoutId);
|
|
188
|
+
}
|
|
189
|
+
if (!response.ok) {
|
|
190
|
+
throw new Error(
|
|
191
|
+
`remote media download failed: ${response.status} ${response.statusText} url=${url.slice(0, 80)}`,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
const buf = Buffer.from(await response.arrayBuffer());
|
|
195
|
+
const ext = extensionFromUrlOrContentType(response.headers.get("content-type"), url);
|
|
196
|
+
await mkdir(REMOTE_MEDIA_DIR, { recursive: true });
|
|
197
|
+
const filePath = tempMediaPath(ext);
|
|
198
|
+
await writeFile(filePath, buf);
|
|
199
|
+
logger.debug(`downloadRemoteMediaToTemp: saved ${buf.length} bytes to ${filePath}`);
|
|
200
|
+
return filePath;
|
|
201
|
+
}
|
package/src/messaging/send.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
sendWorkChatReplyMessage,
|
|
7
7
|
} from "../api/api.js";
|
|
8
8
|
import type { WorkChatApiOptions } from "../api/api.js";
|
|
9
|
+
import { pauseOnPermanentError } from "../api/session-guard.js";
|
|
9
10
|
import type { ResolvedWorkChatAccount } from "../auth/accounts.js";
|
|
10
11
|
import { logger } from "../util/logger.js";
|
|
11
12
|
|
|
@@ -38,6 +39,8 @@ function buildApiOptions(account: ResolvedWorkChatAccount): WorkChatApiOptions {
|
|
|
38
39
|
};
|
|
39
40
|
}
|
|
40
41
|
|
|
42
|
+
export { buildApiOptions };
|
|
43
|
+
|
|
41
44
|
/**
|
|
42
45
|
* Send a reply to a specific inbound message.
|
|
43
46
|
* POST /open-apis/im/v1/messages/{messageId}/reply
|
|
@@ -74,6 +77,7 @@ export async function replyWorkChatMessage(params: {
|
|
|
74
77
|
logger.info(`reply sent to=${to} messageId=${result.msgId}`);
|
|
75
78
|
return { messageId: result.msgId };
|
|
76
79
|
} catch (err) {
|
|
80
|
+
pauseOnPermanentError(account.accountId, err instanceof Error ? err.message : String(err));
|
|
77
81
|
logger.error(`replyWorkChatMessage: failed to=${to} err=${String(err)}`);
|
|
78
82
|
throw err;
|
|
79
83
|
}
|
|
@@ -113,6 +117,7 @@ export async function pushWorkChatMessage(params: {
|
|
|
113
117
|
logger.info(`push sent to=${to} messageId=${result.msgId}`);
|
|
114
118
|
return { messageId: result.msgId };
|
|
115
119
|
} catch (err) {
|
|
120
|
+
pauseOnPermanentError(account.accountId, err instanceof Error ? err.message : String(err));
|
|
116
121
|
logger.error(`pushWorkChatMessage: failed to=${to} err=${String(err)}`);
|
|
117
122
|
throw err;
|
|
118
123
|
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { resolveStateDir } from "./state-dir.js";
|
|
5
|
+
import { logger } from "../util/logger.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Persistent inbound message-id buffer (weixin sync-buf analogue): remembers
|
|
9
|
+
* the most recently processed AGENT_MESSAGE msgIds per account so a monitor
|
|
10
|
+
* restart / platform redelivery never double-dispatches a user message.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const SEEN_LIMIT = 500;
|
|
14
|
+
|
|
15
|
+
function resolveSeenFilePath(accountId: string): string {
|
|
16
|
+
const safe = accountId.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
17
|
+
return path.join(resolveStateDir(), "openclaw-workchat", "accounts", `${safe}.seen-msgids.json`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type SeenCache = { ids: string[]; index: Set<string> };
|
|
21
|
+
const cache = new Map<string, SeenCache>();
|
|
22
|
+
|
|
23
|
+
function loadSeen(accountId: string): SeenCache {
|
|
24
|
+
const cached = cache.get(accountId);
|
|
25
|
+
if (cached) return cached;
|
|
26
|
+
let ids: string[] = [];
|
|
27
|
+
try {
|
|
28
|
+
const raw = fs.readFileSync(resolveSeenFilePath(accountId), "utf-8");
|
|
29
|
+
const parsed = JSON.parse(raw) as { msgIds?: unknown };
|
|
30
|
+
if (Array.isArray(parsed.msgIds)) {
|
|
31
|
+
ids = parsed.msgIds.filter((x): x is string => typeof x === "string" && x !== "");
|
|
32
|
+
}
|
|
33
|
+
} catch {
|
|
34
|
+
// missing or corrupt file — start empty
|
|
35
|
+
}
|
|
36
|
+
const entry: SeenCache = { ids, index: new Set(ids) };
|
|
37
|
+
cache.set(accountId, entry);
|
|
38
|
+
return entry;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function persistSeen(accountId: string, seen: SeenCache): void {
|
|
42
|
+
try {
|
|
43
|
+
const filePath = resolveSeenFilePath(accountId);
|
|
44
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
45
|
+
fs.writeFileSync(filePath, JSON.stringify({ msgIds: seen.ids }), "utf-8");
|
|
46
|
+
} catch (err) {
|
|
47
|
+
logger.warn(`seen-msgids persist failed accountId=${accountId}: ${String(err)}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Returns true when msgId was already recorded (duplicate delivery). */
|
|
52
|
+
export function isDuplicateMessage(accountId: string, msgId: string): boolean {
|
|
53
|
+
if (!msgId) return false;
|
|
54
|
+
const seen = loadSeen(accountId);
|
|
55
|
+
return seen.index.has(msgId);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Record msgId as processed. Returns true the first time (not a duplicate);
|
|
60
|
+
* false when it was already known.
|
|
61
|
+
*/
|
|
62
|
+
export function recordMessageProcessed(accountId: string, msgId: string): boolean {
|
|
63
|
+
if (!msgId) return true;
|
|
64
|
+
const seen = loadSeen(accountId);
|
|
65
|
+
if (seen.index.has(msgId)) return false;
|
|
66
|
+
seen.index.add(msgId);
|
|
67
|
+
seen.ids.push(msgId);
|
|
68
|
+
if (seen.ids.length > SEEN_LIMIT) {
|
|
69
|
+
const drop = seen.ids.length - SEEN_LIMIT;
|
|
70
|
+
for (const old of seen.ids.splice(0, drop)) seen.index.delete(old);
|
|
71
|
+
}
|
|
72
|
+
persistSeen(accountId, seen);
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Clear state — only for tests. @internal */
|
|
77
|
+
export function _resetSeenMsgIdsForTest(): void {
|
|
78
|
+
cache.clear();
|
|
79
|
+
}
|