@ascegu/teamily 1.0.22 → 1.0.24
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 +1 -1
- package/src/channel.ts +124 -27
- package/src/monitor.ts +127 -62
- package/src/upload.ts +46 -0
- package/src/send.ts +0 -271
package/package.json
CHANGED
package/src/channel.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import path from "node:path";
|
|
1
2
|
import {
|
|
2
3
|
applyAccountNameToChannelSection,
|
|
3
4
|
buildChannelConfigSchema,
|
|
@@ -14,6 +15,9 @@ import {
|
|
|
14
15
|
type ChannelOutboundContext,
|
|
15
16
|
type ChannelStatusIssue,
|
|
16
17
|
type HistoryEntry,
|
|
18
|
+
loadOutboundMediaFromUrl,
|
|
19
|
+
resolveOutboundMediaUrls,
|
|
20
|
+
sendMediaWithLeadingCaption,
|
|
17
21
|
} from "openclaw/plugin-sdk";
|
|
18
22
|
import {
|
|
19
23
|
buildAccountScopedDmSecurityPolicy,
|
|
@@ -26,12 +30,18 @@ import {
|
|
|
26
30
|
} from "./accounts.js";
|
|
27
31
|
import { TeamilyConfigSchema } from "./config-schema.js";
|
|
28
32
|
import type { CoreConfig } from "./config-schema.js";
|
|
29
|
-
import {
|
|
33
|
+
import {
|
|
34
|
+
getTeamilyMonitor,
|
|
35
|
+
startTeamilyMonitoring,
|
|
36
|
+
stopTeamilyMonitoring,
|
|
37
|
+
type TeamilyMonitor,
|
|
38
|
+
} from "./monitor.js";
|
|
30
39
|
import { normalizeTeamilyTarget, normalizeTeamilyAllowEntry } from "./normalize.js";
|
|
31
40
|
import { probeTeamily } from "./probe.js";
|
|
32
41
|
import { getTeamilyRuntime } from "./runtime.js";
|
|
33
42
|
import type { ResolvedTeamilyAccount } from "./types.js";
|
|
34
43
|
import { isGroupSession } from "./types.js";
|
|
44
|
+
import { detectMediaCategory, guessContentType, isLocalMediaPath } from "./upload.js";
|
|
35
45
|
|
|
36
46
|
const meta = {
|
|
37
47
|
id: "teamily",
|
|
@@ -60,6 +70,11 @@ export const teamilyPlugin: ChannelPlugin<ResolvedTeamilyAccount> = {
|
|
|
60
70
|
threads: false,
|
|
61
71
|
polls: false,
|
|
62
72
|
},
|
|
73
|
+
agentPrompt: {
|
|
74
|
+
messageToolHints: () => [
|
|
75
|
+
"- To send a local file or image to the user, use MEDIA:./relative-path in your reply (e.g. MEDIA:./image.png). The path must be relative to the workspace. You can also use the message tool with media/path/filePath. Avoid absolute paths and ~ paths — they are blocked for security.",
|
|
76
|
+
],
|
|
77
|
+
},
|
|
63
78
|
reload: { configPrefixes: ["channels.teamily"] },
|
|
64
79
|
setup: {
|
|
65
80
|
resolveAccountId: ({ accountId, input }) => {
|
|
@@ -178,27 +193,27 @@ export const teamilyPlugin: ChannelPlugin<ResolvedTeamilyAccount> = {
|
|
|
178
193
|
const monitor = requireMonitor(accountId);
|
|
179
194
|
const target = normalizeTeamilyTarget(to);
|
|
180
195
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
if (
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
} else {
|
|
199
|
-
messageId = await monitor.sendImage(target, mediaUrl);
|
|
196
|
+
// Local file path: load buffer and send via SDK's *ByFile methods
|
|
197
|
+
// (SDK handles the upload internally, like Telegram's InputFile).
|
|
198
|
+
if (isLocalMediaPath(mediaUrl)) {
|
|
199
|
+
const media = await loadOutboundMediaFromUrl(mediaUrl, {
|
|
200
|
+
mediaLocalRoots: ctx.mediaLocalRoots,
|
|
201
|
+
});
|
|
202
|
+
const fileName = media.fileName || path.basename(mediaUrl) || "media";
|
|
203
|
+
const contentType = media.contentType || guessContentType(mediaUrl);
|
|
204
|
+
const messageId = await sendMediaBuffer(
|
|
205
|
+
monitor,
|
|
206
|
+
target,
|
|
207
|
+
media.buffer,
|
|
208
|
+
fileName,
|
|
209
|
+
contentType,
|
|
210
|
+
mediaUrl,
|
|
211
|
+
);
|
|
212
|
+
return { channel: "teamily" as const, messageId };
|
|
200
213
|
}
|
|
201
214
|
|
|
215
|
+
// Remote URL: use existing *ByURL methods.
|
|
216
|
+
const messageId = await sendMediaByUrl(monitor, target, mediaUrl);
|
|
202
217
|
return { channel: "teamily" as const, messageId };
|
|
203
218
|
},
|
|
204
219
|
},
|
|
@@ -368,16 +383,38 @@ export const teamilyPlugin: ChannelPlugin<ResolvedTeamilyAccount> = {
|
|
|
368
383
|
ctx: msgCtx,
|
|
369
384
|
cfg: currentCfg,
|
|
370
385
|
dispatcherOptions: {
|
|
371
|
-
deliver: async (payload
|
|
372
|
-
const
|
|
373
|
-
if (
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
386
|
+
deliver: async (payload) => {
|
|
387
|
+
const monitor = getTeamilyMonitor(accountId);
|
|
388
|
+
if (!monitor)
|
|
389
|
+
throw new Error(`Teamily monitor not running for account ${accountId}`);
|
|
390
|
+
const target = normalizeTeamilyTarget(replyTarget);
|
|
391
|
+
const replyText =
|
|
392
|
+
((payload as Record<string, unknown>)?.text as string | undefined) ??
|
|
393
|
+
((payload as Record<string, unknown>)?.body as string | undefined) ??
|
|
394
|
+
"";
|
|
395
|
+
|
|
396
|
+
// Send media attachments (first with caption, rest without).
|
|
397
|
+
const mediaUrls = resolveOutboundMediaUrls(payload as Record<string, unknown>);
|
|
398
|
+
const sentMedia = await sendMediaWithLeadingCaption({
|
|
399
|
+
mediaUrls,
|
|
400
|
+
caption: replyText,
|
|
401
|
+
send: async ({ mediaUrl: url, caption }) => {
|
|
402
|
+
await loadAndSendMedia(monitor, target, url);
|
|
403
|
+
// OpenIM doesn't support inline captions on media; send separately.
|
|
404
|
+
if (caption) {
|
|
405
|
+
await monitor.sendText(target, caption);
|
|
406
|
+
}
|
|
407
|
+
},
|
|
408
|
+
onError: (error) => {
|
|
409
|
+
log?.warn?.(`[${accountId}] Media send failed: ${String(error)}`);
|
|
410
|
+
},
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
// If no media was sent, send text only.
|
|
414
|
+
if (!sentMedia && replyText) {
|
|
377
415
|
log?.info?.(
|
|
378
416
|
`[${accountId}] Sending reply to: ${replyTarget} (isGroup=${isGroup})`,
|
|
379
417
|
);
|
|
380
|
-
const target = normalizeTeamilyTarget(replyTarget);
|
|
381
418
|
await monitor.sendText(target, replyText);
|
|
382
419
|
}
|
|
383
420
|
},
|
|
@@ -457,6 +494,66 @@ function applyTeamilyAccountConfig(params: {
|
|
|
457
494
|
} as CoreConfig;
|
|
458
495
|
}
|
|
459
496
|
|
|
497
|
+
/** Send a local media buffer using SDK's *ByFile methods (SDK handles upload). */
|
|
498
|
+
async function sendMediaBuffer(
|
|
499
|
+
monitor: TeamilyMonitor,
|
|
500
|
+
target: ReturnType<typeof normalizeTeamilyTarget>,
|
|
501
|
+
buffer: Buffer,
|
|
502
|
+
fileName: string,
|
|
503
|
+
contentType: string,
|
|
504
|
+
originalPath: string,
|
|
505
|
+
): Promise<string> {
|
|
506
|
+
const category = detectMediaCategory(originalPath);
|
|
507
|
+
switch (category) {
|
|
508
|
+
case "video":
|
|
509
|
+
return monitor.sendVideoBuffer(target, buffer, fileName, contentType);
|
|
510
|
+
case "audio":
|
|
511
|
+
return monitor.sendAudioBuffer(target, buffer, fileName, contentType);
|
|
512
|
+
case "file":
|
|
513
|
+
return monitor.sendFileBuffer(target, buffer, fileName, contentType);
|
|
514
|
+
default:
|
|
515
|
+
return monitor.sendImageBuffer(target, buffer, fileName, contentType);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** Send media by remote URL using existing *ByURL methods. */
|
|
520
|
+
async function sendMediaByUrl(
|
|
521
|
+
monitor: TeamilyMonitor,
|
|
522
|
+
target: ReturnType<typeof normalizeTeamilyTarget>,
|
|
523
|
+
url: string,
|
|
524
|
+
): Promise<string> {
|
|
525
|
+
const category = detectMediaCategory(url);
|
|
526
|
+
switch (category) {
|
|
527
|
+
case "video":
|
|
528
|
+
return monitor.sendVideo(target, url);
|
|
529
|
+
case "audio":
|
|
530
|
+
return monitor.sendAudio(target, url);
|
|
531
|
+
case "file":
|
|
532
|
+
return monitor.sendFile(target, url);
|
|
533
|
+
default:
|
|
534
|
+
return monitor.sendImage(target, url);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Load a local media file and send it via SDK buffer upload.
|
|
540
|
+
* For remote URLs, send directly via *ByURL methods.
|
|
541
|
+
*/
|
|
542
|
+
async function loadAndSendMedia(
|
|
543
|
+
monitor: TeamilyMonitor,
|
|
544
|
+
target: ReturnType<typeof normalizeTeamilyTarget>,
|
|
545
|
+
mediaUrl: string,
|
|
546
|
+
): Promise<void> {
|
|
547
|
+
if (isLocalMediaPath(mediaUrl)) {
|
|
548
|
+
const media = await loadOutboundMediaFromUrl(mediaUrl);
|
|
549
|
+
const fileName = media.fileName || path.basename(mediaUrl) || "media";
|
|
550
|
+
const contentType = media.contentType || guessContentType(mediaUrl);
|
|
551
|
+
await sendMediaBuffer(monitor, target, media.buffer, fileName, contentType, mediaUrl);
|
|
552
|
+
} else {
|
|
553
|
+
await sendMediaByUrl(monitor, target, mediaUrl);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
460
557
|
function requireMonitor(accountId?: string | null) {
|
|
461
558
|
const id = accountId || "default";
|
|
462
559
|
const monitor = getTeamilyMonitor(id);
|
package/src/monitor.ts
CHANGED
|
@@ -36,11 +36,6 @@ async function loadSDK() {
|
|
|
36
36
|
* reconnection to the official OpenIM SDK. Also exposes send methods
|
|
37
37
|
* so outbound replies flow through the same WebSocket connection.
|
|
38
38
|
*/
|
|
39
|
-
/** Returns true when the string looks like an HTTP(S) URL rather than a local file path. */
|
|
40
|
-
function isHttpUrl(s: string): boolean {
|
|
41
|
-
return s.startsWith("http://") || s.startsWith("https://");
|
|
42
|
-
}
|
|
43
|
-
|
|
44
39
|
export class TeamilyMonitor {
|
|
45
40
|
private account: ResolvedTeamilyAccount;
|
|
46
41
|
private onMessage: TeamilyMessageHandler;
|
|
@@ -131,29 +126,9 @@ export class TeamilyMonitor {
|
|
|
131
126
|
return result.data?.serverMsgID || result.data?.clientMsgID || "";
|
|
132
127
|
}
|
|
133
128
|
|
|
134
|
-
/**
|
|
135
|
-
* Upload a local file to the OpenIM server and return its download URL.
|
|
136
|
-
* Uses the SDK's built-in uploadFile which handles multipart upload to
|
|
137
|
-
* the server's object storage. Requires Node.js 20+ for global File.
|
|
138
|
-
*/
|
|
139
|
-
async uploadLocalFile(localPath: string, contentType?: string): Promise<string> {
|
|
140
|
-
const sdk = this.requireSdk();
|
|
141
|
-
const { readFileSync } = await import("node:fs");
|
|
142
|
-
const { basename, extname } = await import("node:path");
|
|
143
|
-
const buffer = readFileSync(localPath);
|
|
144
|
-
const fileName = basename(localPath);
|
|
145
|
-
const mime = contentType || guessMimeType(extname(localPath));
|
|
146
|
-
const file = new File([buffer], fileName, { type: mime });
|
|
147
|
-
const result = await sdk.uploadFile({ file, name: fileName, contentType: mime });
|
|
148
|
-
const url = result.data?.url;
|
|
149
|
-
if (!url) throw new Error(`Upload failed for ${localPath}: no URL returned`);
|
|
150
|
-
return url;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
129
|
/** Send an image message through the SDK WebSocket connection. */
|
|
154
|
-
async sendImage(target: TeamilyMessageTarget,
|
|
130
|
+
async sendImage(target: TeamilyMessageTarget, url: string): Promise<string> {
|
|
155
131
|
const sdk = this.requireSdk();
|
|
156
|
-
const url = isHttpUrl(urlOrPath) ? urlOrPath : await this.uploadLocalFile(urlOrPath);
|
|
157
132
|
const picInfo = { uuid: "", type: "", width: 0, height: 0, size: 0, url };
|
|
158
133
|
const created = await sdk.createImageMessageByURL({
|
|
159
134
|
sourcePicture: picInfo,
|
|
@@ -170,9 +145,8 @@ export class TeamilyMonitor {
|
|
|
170
145
|
}
|
|
171
146
|
|
|
172
147
|
/** Send a video message through the SDK WebSocket connection. */
|
|
173
|
-
async sendVideo(target: TeamilyMessageTarget,
|
|
148
|
+
async sendVideo(target: TeamilyMessageTarget, url: string): Promise<string> {
|
|
174
149
|
const sdk = this.requireSdk();
|
|
175
|
-
const url = isHttpUrl(urlOrPath) ? urlOrPath : await this.uploadLocalFile(urlOrPath);
|
|
176
150
|
const created = await sdk.createVideoMessageByURL({
|
|
177
151
|
videoPath: "",
|
|
178
152
|
duration: 0,
|
|
@@ -196,9 +170,8 @@ export class TeamilyMonitor {
|
|
|
196
170
|
}
|
|
197
171
|
|
|
198
172
|
/** Send a sound/audio message through the SDK WebSocket connection. */
|
|
199
|
-
async sendAudio(target: TeamilyMessageTarget,
|
|
173
|
+
async sendAudio(target: TeamilyMessageTarget, url: string): Promise<string> {
|
|
200
174
|
const sdk = this.requireSdk();
|
|
201
|
-
const url = isHttpUrl(urlOrPath) ? urlOrPath : await this.uploadLocalFile(urlOrPath);
|
|
202
175
|
const created = await sdk.createSoundMessageByURL({
|
|
203
176
|
uuid: "",
|
|
204
177
|
soundPath: "",
|
|
@@ -215,12 +188,11 @@ export class TeamilyMonitor {
|
|
|
215
188
|
}
|
|
216
189
|
|
|
217
190
|
/** Send a file message through the SDK WebSocket connection. */
|
|
218
|
-
async sendFile(target: TeamilyMessageTarget,
|
|
191
|
+
async sendFile(target: TeamilyMessageTarget, url: string, fileName?: string): Promise<string> {
|
|
219
192
|
const sdk = this.requireSdk();
|
|
220
|
-
const url = isHttpUrl(urlOrPath) ? urlOrPath : await this.uploadLocalFile(urlOrPath);
|
|
221
193
|
const created = await sdk.createFileMessageByURL({
|
|
222
194
|
filePath: "",
|
|
223
|
-
fileName: fileName ||
|
|
195
|
+
fileName: fileName || url.split("/").pop() || "file",
|
|
224
196
|
uuid: "",
|
|
225
197
|
sourceUrl: url,
|
|
226
198
|
fileSize: 0,
|
|
@@ -233,6 +205,126 @@ export class TeamilyMonitor {
|
|
|
233
205
|
return result.data?.serverMsgID || result.data?.clientMsgID || "";
|
|
234
206
|
}
|
|
235
207
|
|
|
208
|
+
// ---- Buffer-based send methods (local file → SDK upload) ----
|
|
209
|
+
// Like Telegram's InputFile(buffer), pass a buffer and let the SDK upload.
|
|
210
|
+
|
|
211
|
+
/** Send an image from a local buffer. SDK handles the upload to object storage. */
|
|
212
|
+
async sendImageBuffer(
|
|
213
|
+
target: TeamilyMessageTarget,
|
|
214
|
+
buffer: Buffer,
|
|
215
|
+
fileName: string,
|
|
216
|
+
contentType: string,
|
|
217
|
+
): Promise<string> {
|
|
218
|
+
const sdk = this.requireSdk();
|
|
219
|
+
const file = new File([new Uint8Array(buffer)], fileName, { type: contentType });
|
|
220
|
+
const picInfo = {
|
|
221
|
+
uuid: "",
|
|
222
|
+
type: contentType,
|
|
223
|
+
width: 0,
|
|
224
|
+
height: 0,
|
|
225
|
+
size: buffer.length,
|
|
226
|
+
url: "",
|
|
227
|
+
};
|
|
228
|
+
const created = await sdk.createImageMessageByFile({
|
|
229
|
+
sourcePicture: picInfo,
|
|
230
|
+
bigPicture: picInfo,
|
|
231
|
+
snapshotPicture: picInfo,
|
|
232
|
+
sourcePath: "",
|
|
233
|
+
file,
|
|
234
|
+
});
|
|
235
|
+
const result = await sdk.sendMessage({
|
|
236
|
+
recvID: target.type === "user" ? target.id : "",
|
|
237
|
+
groupID: target.type === "group" ? target.id : "",
|
|
238
|
+
message: created.data,
|
|
239
|
+
});
|
|
240
|
+
return result.data?.serverMsgID || result.data?.clientMsgID || "";
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Send a video from a local buffer. SDK handles the upload to object storage. */
|
|
244
|
+
async sendVideoBuffer(
|
|
245
|
+
target: TeamilyMessageTarget,
|
|
246
|
+
buffer: Buffer,
|
|
247
|
+
fileName: string,
|
|
248
|
+
contentType: string,
|
|
249
|
+
): Promise<string> {
|
|
250
|
+
const sdk = this.requireSdk();
|
|
251
|
+
const videoFile = new File([new Uint8Array(buffer)], fileName, { type: contentType });
|
|
252
|
+
// snapshotFile is required by the SDK type but we have no thumbnail; use an empty file.
|
|
253
|
+
const snapshotFile = new File([], "snapshot.jpg", { type: "image/jpeg" });
|
|
254
|
+
const created = await sdk.createVideoMessageByFile({
|
|
255
|
+
videoPath: "",
|
|
256
|
+
duration: 0,
|
|
257
|
+
videoType: contentType,
|
|
258
|
+
snapshotPath: "",
|
|
259
|
+
videoUUID: "",
|
|
260
|
+
videoUrl: "",
|
|
261
|
+
videoSize: buffer.length,
|
|
262
|
+
snapshotUUID: "",
|
|
263
|
+
snapshotSize: 0,
|
|
264
|
+
snapshotUrl: "",
|
|
265
|
+
snapshotWidth: 0,
|
|
266
|
+
snapshotHeight: 0,
|
|
267
|
+
videoFile,
|
|
268
|
+
snapshotFile,
|
|
269
|
+
});
|
|
270
|
+
const result = await sdk.sendMessage({
|
|
271
|
+
recvID: target.type === "user" ? target.id : "",
|
|
272
|
+
groupID: target.type === "group" ? target.id : "",
|
|
273
|
+
message: created.data,
|
|
274
|
+
});
|
|
275
|
+
return result.data?.serverMsgID || result.data?.clientMsgID || "";
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Send an audio/sound from a local buffer. SDK handles the upload to object storage. */
|
|
279
|
+
async sendAudioBuffer(
|
|
280
|
+
target: TeamilyMessageTarget,
|
|
281
|
+
buffer: Buffer,
|
|
282
|
+
fileName: string,
|
|
283
|
+
contentType: string,
|
|
284
|
+
): Promise<string> {
|
|
285
|
+
const sdk = this.requireSdk();
|
|
286
|
+
const file = new File([new Uint8Array(buffer)], fileName, { type: contentType });
|
|
287
|
+
const created = await sdk.createSoundMessageByFile({
|
|
288
|
+
uuid: "",
|
|
289
|
+
soundPath: "",
|
|
290
|
+
sourceUrl: "",
|
|
291
|
+
dataSize: buffer.length,
|
|
292
|
+
duration: 0,
|
|
293
|
+
file,
|
|
294
|
+
});
|
|
295
|
+
const result = await sdk.sendMessage({
|
|
296
|
+
recvID: target.type === "user" ? target.id : "",
|
|
297
|
+
groupID: target.type === "group" ? target.id : "",
|
|
298
|
+
message: created.data,
|
|
299
|
+
});
|
|
300
|
+
return result.data?.serverMsgID || result.data?.clientMsgID || "";
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Send a document/file from a local buffer. SDK handles the upload to object storage. */
|
|
304
|
+
async sendFileBuffer(
|
|
305
|
+
target: TeamilyMessageTarget,
|
|
306
|
+
buffer: Buffer,
|
|
307
|
+
fileName: string,
|
|
308
|
+
contentType: string,
|
|
309
|
+
): Promise<string> {
|
|
310
|
+
const sdk = this.requireSdk();
|
|
311
|
+
const file = new File([new Uint8Array(buffer)], fileName, { type: contentType });
|
|
312
|
+
const created = await sdk.createFileMessageByFile({
|
|
313
|
+
filePath: "",
|
|
314
|
+
fileName,
|
|
315
|
+
uuid: "",
|
|
316
|
+
sourceUrl: "",
|
|
317
|
+
fileSize: buffer.length,
|
|
318
|
+
file,
|
|
319
|
+
});
|
|
320
|
+
const result = await sdk.sendMessage({
|
|
321
|
+
recvID: target.type === "user" ? target.id : "",
|
|
322
|
+
groupID: target.type === "group" ? target.id : "",
|
|
323
|
+
message: created.data,
|
|
324
|
+
});
|
|
325
|
+
return result.data?.serverMsgID || result.data?.clientMsgID || "";
|
|
326
|
+
}
|
|
327
|
+
|
|
236
328
|
private requireSdk(): SdkInstance {
|
|
237
329
|
if (!this.sdk) {
|
|
238
330
|
throw new Error("Teamily SDK not connected");
|
|
@@ -246,32 +338,6 @@ export class TeamilyMonitor {
|
|
|
246
338
|
}
|
|
247
339
|
}
|
|
248
340
|
|
|
249
|
-
// ---- MIME type helper ----
|
|
250
|
-
|
|
251
|
-
const MIME_MAP: Record<string, string> = {
|
|
252
|
-
".jpg": "image/jpeg",
|
|
253
|
-
".jpeg": "image/jpeg",
|
|
254
|
-
".png": "image/png",
|
|
255
|
-
".gif": "image/gif",
|
|
256
|
-
".webp": "image/webp",
|
|
257
|
-
".bmp": "image/bmp",
|
|
258
|
-
".mp4": "video/mp4",
|
|
259
|
-
".mov": "video/quicktime",
|
|
260
|
-
".webm": "video/webm",
|
|
261
|
-
".mp3": "audio/mpeg",
|
|
262
|
-
".m4a": "audio/mp4",
|
|
263
|
-
".wav": "audio/wav",
|
|
264
|
-
".ogg": "audio/ogg",
|
|
265
|
-
".pdf": "application/pdf",
|
|
266
|
-
".doc": "application/msword",
|
|
267
|
-
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
268
|
-
".zip": "application/zip",
|
|
269
|
-
};
|
|
270
|
-
|
|
271
|
-
function guessMimeType(ext: string): string {
|
|
272
|
-
return MIME_MAP[ext.toLowerCase()] || "application/octet-stream";
|
|
273
|
-
}
|
|
274
|
-
|
|
275
341
|
// ---- SDK message conversion helpers ----
|
|
276
342
|
|
|
277
343
|
import type { MessageItem } from "@openim/client-sdk";
|
|
@@ -289,13 +355,12 @@ function convertSdkMessage(msg: MessageItem, selfUserID: string): TeamilyMessage
|
|
|
289
355
|
// Determine whether this message @-mentions the bot
|
|
290
356
|
const isAtSelf =
|
|
291
357
|
contentType === CONTENT_TYPES.AT_TEXT &&
|
|
292
|
-
(msg.atTextElem?.isAtSelf === true ||
|
|
293
|
-
(msg.atTextElem?.atUserList ?? []).includes(selfUserID));
|
|
358
|
+
(msg.atTextElem?.isAtSelf === true || (msg.atTextElem?.atUserList ?? []).includes(selfUserID));
|
|
294
359
|
|
|
295
360
|
return {
|
|
296
361
|
serverMsgID: msg.serverMsgID || msg.clientMsgID || `${Date.now()}_${Math.random()}`,
|
|
297
362
|
sendID: msg.sendID || "unknown",
|
|
298
|
-
recvID: isGroupSession(sessionType) ?
|
|
363
|
+
recvID: isGroupSession(sessionType) ? msg.groupID || "" : msg.recvID || selfUserID,
|
|
299
364
|
content,
|
|
300
365
|
contentType,
|
|
301
366
|
sessionType,
|
package/src/upload.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
/** Returns true if the media URL is a local file path rather than a remote URL. */
|
|
4
|
+
export function isLocalMediaPath(mediaUrl: string): boolean {
|
|
5
|
+
return !/^https?:\/\//i.test(mediaUrl);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Detect the media category from a file extension. */
|
|
9
|
+
export type MediaCategory = "image" | "video" | "audio" | "file";
|
|
10
|
+
|
|
11
|
+
export function detectMediaCategory(filePath: string): MediaCategory {
|
|
12
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
13
|
+
if ([".mp4", ".mov", ".webm"].includes(ext)) return "video";
|
|
14
|
+
if ([".mp3", ".m4a", ".wav", ".ogg"].includes(ext)) return "audio";
|
|
15
|
+
if ([".pdf", ".doc", ".docx", ".zip"].includes(ext)) return "file";
|
|
16
|
+
return "image";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Guess a reasonable MIME content type from a file extension.
|
|
21
|
+
* Falls back to application/octet-stream for unknown types.
|
|
22
|
+
*/
|
|
23
|
+
export function guessContentType(filePath: string): string {
|
|
24
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
25
|
+
const map: Record<string, string> = {
|
|
26
|
+
".png": "image/png",
|
|
27
|
+
".jpg": "image/jpeg",
|
|
28
|
+
".jpeg": "image/jpeg",
|
|
29
|
+
".gif": "image/gif",
|
|
30
|
+
".webp": "image/webp",
|
|
31
|
+
".bmp": "image/bmp",
|
|
32
|
+
".svg": "image/svg+xml",
|
|
33
|
+
".mp4": "video/mp4",
|
|
34
|
+
".mov": "video/quicktime",
|
|
35
|
+
".webm": "video/webm",
|
|
36
|
+
".mp3": "audio/mpeg",
|
|
37
|
+
".m4a": "audio/mp4",
|
|
38
|
+
".wav": "audio/wav",
|
|
39
|
+
".ogg": "audio/ogg",
|
|
40
|
+
".pdf": "application/pdf",
|
|
41
|
+
".doc": "application/msword",
|
|
42
|
+
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
43
|
+
".zip": "application/zip",
|
|
44
|
+
};
|
|
45
|
+
return map[ext] ?? "application/octet-stream";
|
|
46
|
+
}
|
package/src/send.ts
DELETED
|
@@ -1,271 +0,0 @@
|
|
|
1
|
-
import { generateOperationID } from "./probe.js";
|
|
2
|
-
import type { ResolvedTeamilyAccount, TeamilyMessageTarget } from "./types.js";
|
|
3
|
-
import { CONTENT_TYPES, SESSION_TYPES } from "./types.js";
|
|
4
|
-
|
|
5
|
-
export interface SendTeamilyMessageParams {
|
|
6
|
-
account: ResolvedTeamilyAccount;
|
|
7
|
-
target: TeamilyMessageTarget;
|
|
8
|
-
text: string;
|
|
9
|
-
replyToId?: string;
|
|
10
|
-
fetchImpl?: typeof fetch;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export interface SendTeamilyMediaParams {
|
|
14
|
-
account: ResolvedTeamilyAccount;
|
|
15
|
-
target: TeamilyMessageTarget;
|
|
16
|
-
mediaUrl: string;
|
|
17
|
-
mediaType: "image" | "video" | "audio" | "file";
|
|
18
|
-
caption?: string;
|
|
19
|
-
fetchImpl?: typeof fetch;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export interface TeamilySendResult {
|
|
23
|
-
success: boolean;
|
|
24
|
-
messageId?: string;
|
|
25
|
-
serverMsgID?: string;
|
|
26
|
-
clientMsgID?: string;
|
|
27
|
-
error?: string;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Send a text message via Teamily REST API.
|
|
32
|
-
*
|
|
33
|
-
* @param params - Send parameters
|
|
34
|
-
* @returns Send result with message ID or error
|
|
35
|
-
*/
|
|
36
|
-
export async function sendMessageTeamily(
|
|
37
|
-
params: SendTeamilyMessageParams,
|
|
38
|
-
): Promise<TeamilySendResult> {
|
|
39
|
-
const { account, target, text, replyToId, fetchImpl = fetch } = params;
|
|
40
|
-
|
|
41
|
-
const url = `${account.apiURL}/msg/send_msg`;
|
|
42
|
-
|
|
43
|
-
const payload = buildSendMessagePayload({
|
|
44
|
-
sendID: account.userID,
|
|
45
|
-
target,
|
|
46
|
-
content: { content: text },
|
|
47
|
-
contentType: CONTENT_TYPES.TEXT,
|
|
48
|
-
replyToId,
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
try {
|
|
52
|
-
const response = await fetchImpl(url, {
|
|
53
|
-
method: "POST",
|
|
54
|
-
headers: {
|
|
55
|
-
"Content-Type": "application/json",
|
|
56
|
-
operationID: generateOperationID(),
|
|
57
|
-
token: account.token,
|
|
58
|
-
},
|
|
59
|
-
body: JSON.stringify(payload),
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
if (!response.ok) {
|
|
63
|
-
const errorText = await response.text();
|
|
64
|
-
return {
|
|
65
|
-
success: false,
|
|
66
|
-
error: `HTTP ${response.status}: ${errorText}`,
|
|
67
|
-
};
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const data = (await response.json()) as {
|
|
71
|
-
errCode: number;
|
|
72
|
-
errMsg: string;
|
|
73
|
-
data?: {
|
|
74
|
-
serverMsgID: string;
|
|
75
|
-
clientMsgID: string;
|
|
76
|
-
sendTime: number;
|
|
77
|
-
};
|
|
78
|
-
};
|
|
79
|
-
|
|
80
|
-
if (data.errCode !== 0) {
|
|
81
|
-
return {
|
|
82
|
-
success: false,
|
|
83
|
-
error: data.errMsg || "Send failed",
|
|
84
|
-
};
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
return {
|
|
88
|
-
success: true,
|
|
89
|
-
messageId: data.data?.serverMsgID,
|
|
90
|
-
serverMsgID: data.data?.serverMsgID,
|
|
91
|
-
clientMsgID: data.data?.clientMsgID,
|
|
92
|
-
};
|
|
93
|
-
} catch (error) {
|
|
94
|
-
return {
|
|
95
|
-
success: false,
|
|
96
|
-
error: error instanceof Error ? error.message : String(error),
|
|
97
|
-
};
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* Send a media message via Teamily REST API.
|
|
103
|
-
*
|
|
104
|
-
* @param params - Send parameters
|
|
105
|
-
* @returns Send result with message ID or error
|
|
106
|
-
*/
|
|
107
|
-
export async function sendMediaTeamily(params: SendTeamilyMediaParams): Promise<TeamilySendResult> {
|
|
108
|
-
const { account, target, mediaUrl, mediaType, caption, fetchImpl = fetch } = params;
|
|
109
|
-
|
|
110
|
-
const url = `${account.apiURL}/msg/send_msg`;
|
|
111
|
-
|
|
112
|
-
let content: Record<string, unknown>;
|
|
113
|
-
let contentType: number;
|
|
114
|
-
|
|
115
|
-
switch (mediaType) {
|
|
116
|
-
case "image":
|
|
117
|
-
contentType = CONTENT_TYPES.PICTURE;
|
|
118
|
-
content = {
|
|
119
|
-
sourcePicture: {
|
|
120
|
-
uuid: generateOperationID(),
|
|
121
|
-
type: "public",
|
|
122
|
-
width: 0,
|
|
123
|
-
height: 0,
|
|
124
|
-
url: mediaUrl,
|
|
125
|
-
},
|
|
126
|
-
};
|
|
127
|
-
break;
|
|
128
|
-
|
|
129
|
-
case "video":
|
|
130
|
-
contentType = CONTENT_TYPES.VIDEO;
|
|
131
|
-
content = {
|
|
132
|
-
videoPath: mediaUrl,
|
|
133
|
-
videoUUID: generateOperationID(),
|
|
134
|
-
videoUrl: mediaUrl,
|
|
135
|
-
videoType: "mp4",
|
|
136
|
-
videoSize: 0,
|
|
137
|
-
duration: 0,
|
|
138
|
-
snapshotUUID: `${generateOperationID()}_snap`,
|
|
139
|
-
snapshotUrl: "",
|
|
140
|
-
snapshotSize: 0,
|
|
141
|
-
snapshotWidth: 0,
|
|
142
|
-
snapshotHeight: 0,
|
|
143
|
-
};
|
|
144
|
-
break;
|
|
145
|
-
|
|
146
|
-
case "audio":
|
|
147
|
-
contentType = CONTENT_TYPES.VOICE;
|
|
148
|
-
content = {
|
|
149
|
-
uuid: generateOperationID(),
|
|
150
|
-
soundPath: mediaUrl,
|
|
151
|
-
sourceUrl: mediaUrl,
|
|
152
|
-
dataSize: 0,
|
|
153
|
-
duration: 0,
|
|
154
|
-
soundType: "mp3",
|
|
155
|
-
};
|
|
156
|
-
break;
|
|
157
|
-
|
|
158
|
-
case "file":
|
|
159
|
-
contentType = CONTENT_TYPES.FILE;
|
|
160
|
-
content = {
|
|
161
|
-
uuid: generateOperationID(),
|
|
162
|
-
fileName: mediaUrl.split("/").pop() || "file",
|
|
163
|
-
fileSize: 0,
|
|
164
|
-
sourceUrl: mediaUrl,
|
|
165
|
-
fileType: "application/octet-stream",
|
|
166
|
-
};
|
|
167
|
-
break;
|
|
168
|
-
|
|
169
|
-
default:
|
|
170
|
-
return {
|
|
171
|
-
success: false,
|
|
172
|
-
error: `Unsupported media type: ${mediaType}`,
|
|
173
|
-
};
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
// Add caption to text content for media messages
|
|
177
|
-
if (caption) {
|
|
178
|
-
content.text = caption;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
const payload = buildSendMessagePayload({
|
|
182
|
-
sendID: account.userID,
|
|
183
|
-
target,
|
|
184
|
-
content,
|
|
185
|
-
contentType,
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
try {
|
|
189
|
-
const response = await fetchImpl(url, {
|
|
190
|
-
method: "POST",
|
|
191
|
-
headers: {
|
|
192
|
-
"Content-Type": "application/json",
|
|
193
|
-
operationID: generateOperationID(),
|
|
194
|
-
token: account.token,
|
|
195
|
-
},
|
|
196
|
-
body: JSON.stringify(payload),
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
if (!response.ok) {
|
|
200
|
-
const errorText = await response.text();
|
|
201
|
-
return {
|
|
202
|
-
success: false,
|
|
203
|
-
error: `HTTP ${response.status}: ${errorText}`,
|
|
204
|
-
};
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
const data = (await response.json()) as {
|
|
208
|
-
errCode: number;
|
|
209
|
-
errMsg: string;
|
|
210
|
-
data?: {
|
|
211
|
-
serverMsgID: string;
|
|
212
|
-
clientMsgID: string;
|
|
213
|
-
sendTime: number;
|
|
214
|
-
};
|
|
215
|
-
};
|
|
216
|
-
|
|
217
|
-
if (data.errCode !== 0) {
|
|
218
|
-
return {
|
|
219
|
-
success: false,
|
|
220
|
-
error: data.errMsg || "Send failed",
|
|
221
|
-
};
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
return {
|
|
225
|
-
success: true,
|
|
226
|
-
messageId: data.data?.serverMsgID,
|
|
227
|
-
serverMsgID: data.data?.serverMsgID,
|
|
228
|
-
clientMsgID: data.data?.clientMsgID,
|
|
229
|
-
};
|
|
230
|
-
} catch (error) {
|
|
231
|
-
return {
|
|
232
|
-
success: false,
|
|
233
|
-
error: error instanceof Error ? error.message : String(error),
|
|
234
|
-
};
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
/**
|
|
239
|
-
* Build the send message payload for Teamily API.
|
|
240
|
-
*/
|
|
241
|
-
function buildSendMessagePayload(params: {
|
|
242
|
-
sendID: string;
|
|
243
|
-
target: TeamilyMessageTarget;
|
|
244
|
-
content: Record<string, unknown>;
|
|
245
|
-
contentType: number;
|
|
246
|
-
replyToId?: string;
|
|
247
|
-
}): Record<string, unknown> {
|
|
248
|
-
const { sendID, target, content, contentType, replyToId } = params;
|
|
249
|
-
|
|
250
|
-
const payload: Record<string, unknown> = {
|
|
251
|
-
sendID,
|
|
252
|
-
recvID: target.type === "user" ? target.id : "",
|
|
253
|
-
groupID: target.type === "group" ? target.id : "",
|
|
254
|
-
content,
|
|
255
|
-
contentType,
|
|
256
|
-
sessionType: target.type === "group" ? SESSION_TYPES.GROUP : SESSION_TYPES.SINGLE,
|
|
257
|
-
isOnlineOnly: false,
|
|
258
|
-
};
|
|
259
|
-
|
|
260
|
-
if (replyToId) {
|
|
261
|
-
payload.quote = {
|
|
262
|
-
text: "",
|
|
263
|
-
content: {},
|
|
264
|
-
isReact: false,
|
|
265
|
-
userID: "",
|
|
266
|
-
msgID: replyToId,
|
|
267
|
-
};
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
return payload;
|
|
271
|
-
}
|