@ascegu/teamily 1.0.19 → 1.0.23
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 +33 -35
- package/package.json +1 -1
- package/src/accounts.ts +0 -1
- package/src/channel.ts +229 -132
- package/src/config-schema.ts +0 -1
- package/src/monitor.ts +93 -0
- package/src/types.ts +0 -2
- package/src/upload.ts +46 -0
package/README.md
CHANGED
|
@@ -26,29 +26,28 @@ openclaw channel configure teamily
|
|
|
26
26
|
|
|
27
27
|
### Server Settings
|
|
28
28
|
|
|
29
|
-
| Field
|
|
30
|
-
|
|
31
|
-
| `
|
|
32
|
-
| `
|
|
33
|
-
| `wsURL` | Teamily WebSocket URL | `wss://imserver-test.teamily.ai/msg_gateway` |
|
|
29
|
+
| Field | Description | Default |
|
|
30
|
+
| -------- | --------------------- | -------------------------------------------- |
|
|
31
|
+
| `apiURL` | Teamily REST API URL | `https://imserver-test.teamily.ai/im_api` |
|
|
32
|
+
| `wsURL` | Teamily WebSocket URL | `wss://imserver-test.teamily.ai/msg_gateway` |
|
|
34
33
|
|
|
35
34
|
### Bot Account Settings
|
|
36
35
|
|
|
37
|
-
| Field | Required | Description
|
|
38
|
-
|
|
39
|
-
| `userID` | Yes | User ID for the bot account
|
|
40
|
-
| `token` | Yes | User token for authentication
|
|
41
|
-
| `nickname` | No | Display nickname
|
|
42
|
-
| `faceURL` | No | Avatar URL
|
|
36
|
+
| Field | Required | Description |
|
|
37
|
+
| ---------- | -------- | ----------------------------- |
|
|
38
|
+
| `userID` | Yes | User ID for the bot account |
|
|
39
|
+
| `token` | Yes | User token for authentication |
|
|
40
|
+
| `nickname` | No | Display nickname |
|
|
41
|
+
| `faceURL` | No | Avatar URL |
|
|
43
42
|
|
|
44
43
|
### DM Security
|
|
45
44
|
|
|
46
45
|
Per-account or channel-level DM security can be configured:
|
|
47
46
|
|
|
48
|
-
| Field
|
|
49
|
-
|
|
50
|
-
| `dm.policy`
|
|
51
|
-
| `dm.allowFrom`
|
|
47
|
+
| Field | Description |
|
|
48
|
+
| -------------- | --------------------------------------------------- |
|
|
49
|
+
| `dm.policy` | DM security policy (`pairing`, `allowlist`, `open`) |
|
|
50
|
+
| `dm.allowFrom` | List of allowed sender IDs |
|
|
52
51
|
|
|
53
52
|
### Example Configuration
|
|
54
53
|
|
|
@@ -57,7 +56,6 @@ channels:
|
|
|
57
56
|
teamily:
|
|
58
57
|
enabled: true
|
|
59
58
|
server:
|
|
60
|
-
platformUrl: https://imserver-test.teamily.ai/im_api
|
|
61
59
|
apiURL: https://imserver-test.teamily.ai/im_api
|
|
62
60
|
wsURL: wss://imserver-test.teamily.ai/msg_gateway
|
|
63
61
|
accounts:
|
|
@@ -91,12 +89,12 @@ openclaw message send teamily:user:userID --media /path/to/image.jpg
|
|
|
91
89
|
|
|
92
90
|
Supported media types are auto-detected by file extension:
|
|
93
91
|
|
|
94
|
-
| Extension
|
|
95
|
-
|
|
96
|
-
| `.jpg`, `.png`, `.gif`, etc.
|
|
97
|
-
| `.mp4`, `.mov`, `.webm`
|
|
98
|
-
| `.mp3`, `.m4a`, `.wav`
|
|
99
|
-
| `.pdf`, `.doc`, `.docx`, `.zip` | File
|
|
92
|
+
| Extension | Type |
|
|
93
|
+
| ------------------------------- | ----- |
|
|
94
|
+
| `.jpg`, `.png`, `.gif`, etc. | Image |
|
|
95
|
+
| `.mp4`, `.mov`, `.webm` | Video |
|
|
96
|
+
| `.mp3`, `.m4a`, `.wav` | Audio |
|
|
97
|
+
| `.pdf`, `.doc`, `.docx`, `.zip` | File |
|
|
100
98
|
|
|
101
99
|
## Group Chat Behavior
|
|
102
100
|
|
|
@@ -107,19 +105,19 @@ Supported media types are auto-detected by file extension:
|
|
|
107
105
|
|
|
108
106
|
## Capabilities
|
|
109
107
|
|
|
110
|
-
| Feature
|
|
111
|
-
|
|
112
|
-
| Direct messaging
|
|
113
|
-
| Group messaging
|
|
114
|
-
| Text messages
|
|
115
|
-
| Media (image/video/audio/file) | Yes
|
|
116
|
-
| @-mention gating (groups)
|
|
117
|
-
| WebSocket real-time monitoring | Yes
|
|
118
|
-
| Automatic reconnection
|
|
119
|
-
| Connection health probes
|
|
120
|
-
| Reactions
|
|
121
|
-
| Threads
|
|
122
|
-
| Polls
|
|
108
|
+
| Feature | Supported |
|
|
109
|
+
| ------------------------------ | --------- |
|
|
110
|
+
| Direct messaging | Yes |
|
|
111
|
+
| Group messaging | Yes |
|
|
112
|
+
| Text messages | Yes |
|
|
113
|
+
| Media (image/video/audio/file) | Yes |
|
|
114
|
+
| @-mention gating (groups) | Yes |
|
|
115
|
+
| WebSocket real-time monitoring | Yes |
|
|
116
|
+
| Automatic reconnection | Yes |
|
|
117
|
+
| Connection health probes | Yes |
|
|
118
|
+
| Reactions | No |
|
|
119
|
+
| Threads | No |
|
|
120
|
+
| Polls | No |
|
|
123
121
|
|
|
124
122
|
## Architecture
|
|
125
123
|
|
package/package.json
CHANGED
package/src/accounts.ts
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,13 @@ 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 { getTeamilyMonitor, startTeamilyMonitoring, stopTeamilyMonitoring } from "./monitor.js";
|
|
33
|
+
import { getTeamilyMonitor, startTeamilyMonitoring, stopTeamilyMonitoring, type TeamilyMonitor } from "./monitor.js";
|
|
30
34
|
import { normalizeTeamilyTarget, normalizeTeamilyAllowEntry } from "./normalize.js";
|
|
31
35
|
import { probeTeamily } from "./probe.js";
|
|
32
36
|
import { getTeamilyRuntime } from "./runtime.js";
|
|
33
37
|
import type { ResolvedTeamilyAccount } from "./types.js";
|
|
34
38
|
import { isGroupSession } from "./types.js";
|
|
39
|
+
import { detectMediaCategory, guessContentType, isLocalMediaPath } from "./upload.js";
|
|
35
40
|
|
|
36
41
|
const meta = {
|
|
37
42
|
id: "teamily",
|
|
@@ -178,18 +183,22 @@ export const teamilyPlugin: ChannelPlugin<ResolvedTeamilyAccount> = {
|
|
|
178
183
|
const monitor = requireMonitor(accountId);
|
|
179
184
|
const target = normalizeTeamilyTarget(to);
|
|
180
185
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
if (
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
186
|
+
// Local file path: load buffer and send via SDK's *ByFile methods
|
|
187
|
+
// (SDK handles the upload internally, like Telegram's InputFile).
|
|
188
|
+
if (isLocalMediaPath(mediaUrl)) {
|
|
189
|
+
const media = await loadOutboundMediaFromUrl(mediaUrl, {
|
|
190
|
+
mediaLocalRoots: ctx.mediaLocalRoots,
|
|
191
|
+
});
|
|
192
|
+
const fileName = media.fileName || path.basename(mediaUrl) || "media";
|
|
193
|
+
const contentType = media.contentType || guessContentType(mediaUrl);
|
|
194
|
+
const messageId = await sendMediaBuffer(
|
|
195
|
+
monitor, target, media.buffer, fileName, contentType, mediaUrl,
|
|
196
|
+
);
|
|
197
|
+
return { channel: "teamily" as const, messageId };
|
|
191
198
|
}
|
|
192
199
|
|
|
200
|
+
// Remote URL: use existing *ByURL methods.
|
|
201
|
+
const messageId = await sendMediaByUrl(monitor, target, mediaUrl);
|
|
193
202
|
return { channel: "teamily" as const, messageId };
|
|
194
203
|
},
|
|
195
204
|
},
|
|
@@ -248,140 +257,167 @@ export const teamilyPlugin: ChannelPlugin<ResolvedTeamilyAccount> = {
|
|
|
248
257
|
|
|
249
258
|
const stopFn = startTeamilyMonitoring(account, async (message) => {
|
|
250
259
|
try {
|
|
251
|
-
|
|
252
|
-
|
|
260
|
+
const rt = getTeamilyRuntime();
|
|
261
|
+
const currentCfg = rt.config.loadConfig();
|
|
253
262
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
263
|
+
const isGroup = isGroupSession(message.sessionType);
|
|
264
|
+
const from = message.sendID;
|
|
265
|
+
const rawText = message.content?.text || "";
|
|
257
266
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
267
|
+
log?.info?.(
|
|
268
|
+
`[${accountId}] Incoming message: sessionType=${message.sessionType}, isGroup=${isGroup}, ` +
|
|
269
|
+
`from=${from}, recvID=${message.recvID}, isAtSelf=${message.isAtSelf ?? false}`,
|
|
270
|
+
);
|
|
262
271
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
272
|
+
const historyKey = isGroup ? `teamily:group:${message.recvID}` : undefined;
|
|
273
|
+
|
|
274
|
+
// In group chats, buffer non-@-mention text-only messages for context.
|
|
275
|
+
// Media messages (picture/video/audio) are always dispatched — OpenIM sends
|
|
276
|
+
// @-mention text and media as separate messages, so a PICTURE following an
|
|
277
|
+
// AT_TEXT won't have isAtSelf=true. Dispatching media keeps group image
|
|
278
|
+
// handling consistent with DM behavior.
|
|
279
|
+
const hasMedia = !!(
|
|
280
|
+
message.content?.picture ||
|
|
281
|
+
message.content?.video ||
|
|
282
|
+
message.content?.audio
|
|
283
|
+
);
|
|
284
|
+
if (isGroup && !message.isAtSelf && !hasMedia) {
|
|
285
|
+
if (historyKey && rawText) {
|
|
286
|
+
recordPendingHistoryEntryIfEnabled({
|
|
287
|
+
historyMap: groupHistories,
|
|
288
|
+
historyKey,
|
|
289
|
+
limit: historyLimit,
|
|
290
|
+
entry: {
|
|
291
|
+
sender: from,
|
|
292
|
+
body: rawText,
|
|
293
|
+
timestamp: message.sendTime,
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
return;
|
|
287
298
|
}
|
|
288
|
-
return;
|
|
289
|
-
}
|
|
290
299
|
|
|
291
|
-
|
|
300
|
+
const sessionKey = isGroup ? `teamily:group:${message.recvID}` : `teamily:${from}`;
|
|
292
301
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
302
|
+
let mediaUrl: string | undefined;
|
|
303
|
+
if (message.content?.picture?.sourcePicture?.url) {
|
|
304
|
+
mediaUrl = message.content.picture.sourcePicture.url;
|
|
305
|
+
} else if (message.content?.video?.videoUrl) {
|
|
306
|
+
mediaUrl = message.content.video.videoUrl;
|
|
307
|
+
} else if (message.content?.audio?.sourceUrl) {
|
|
308
|
+
mediaUrl = message.content.audio.sourceUrl;
|
|
309
|
+
}
|
|
301
310
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
311
|
+
// Download remote media to a local temp file so the agent recognises
|
|
312
|
+
// image-only messages (hasMediaAttachment checks MediaPath, not MediaUrl).
|
|
313
|
+
let mediaPath: string | undefined;
|
|
314
|
+
let mediaType: string | undefined;
|
|
315
|
+
if (mediaUrl) {
|
|
316
|
+
try {
|
|
317
|
+
const fetched = await rt.channel.media.fetchRemoteMedia({
|
|
318
|
+
url: mediaUrl,
|
|
319
|
+
maxBytes: MEDIA_MAX_BYTES,
|
|
320
|
+
});
|
|
321
|
+
const saved = await rt.channel.media.saveMediaBuffer(
|
|
322
|
+
fetched.buffer,
|
|
323
|
+
fetched.contentType,
|
|
324
|
+
"inbound",
|
|
325
|
+
MEDIA_MAX_BYTES,
|
|
326
|
+
);
|
|
327
|
+
mediaPath = saved.path;
|
|
328
|
+
mediaType = saved.contentType;
|
|
329
|
+
} catch (err) {
|
|
330
|
+
log?.warn?.(`[${accountId}] Failed to download Teamily media: ${String(err)}`);
|
|
331
|
+
}
|
|
319
332
|
}
|
|
320
|
-
}
|
|
321
333
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
const replyTarget = isGroup ? `group:${message.recvID}` : from;
|
|
335
|
-
const msgCtx = {
|
|
336
|
-
Body: body,
|
|
337
|
-
From: from,
|
|
338
|
-
To: account.userID,
|
|
339
|
-
SessionKey: sessionKey,
|
|
340
|
-
AccountId: accountId,
|
|
341
|
-
Provider: "teamily" as const,
|
|
342
|
-
Surface: "teamily" as const,
|
|
343
|
-
OriginatingChannel: "teamily" as const,
|
|
344
|
-
OriginatingTo: replyTarget,
|
|
345
|
-
ChatType: isGroup ? "group" : "direct",
|
|
346
|
-
MediaUrl: mediaUrl,
|
|
347
|
-
MediaPath: mediaPath,
|
|
348
|
-
MediaType: mediaType,
|
|
349
|
-
};
|
|
334
|
+
// For group @-mention messages, prepend buffered history as context.
|
|
335
|
+
const body =
|
|
336
|
+
isGroup && historyKey
|
|
337
|
+
? buildPendingHistoryContextFromMap({
|
|
338
|
+
historyMap: groupHistories,
|
|
339
|
+
historyKey,
|
|
340
|
+
limit: historyLimit,
|
|
341
|
+
currentMessage: rawText,
|
|
342
|
+
formatEntry: (entry) => `${entry.sender}: ${entry.body}`,
|
|
343
|
+
})
|
|
344
|
+
: rawText;
|
|
350
345
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
346
|
+
const replyTarget = isGroup ? `group:${message.recvID}` : from;
|
|
347
|
+
const msgCtx = {
|
|
348
|
+
Body: body,
|
|
349
|
+
From: from,
|
|
350
|
+
To: account.userID,
|
|
351
|
+
SessionKey: sessionKey,
|
|
352
|
+
AccountId: accountId,
|
|
353
|
+
Provider: "teamily" as const,
|
|
354
|
+
Surface: "teamily" as const,
|
|
355
|
+
OriginatingChannel: "teamily" as const,
|
|
356
|
+
OriginatingTo: replyTarget,
|
|
357
|
+
ChatType: isGroup ? "group" : "direct",
|
|
358
|
+
MediaUrl: mediaUrl,
|
|
359
|
+
MediaPath: mediaPath,
|
|
360
|
+
MediaType: mediaType,
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
log?.info?.(
|
|
364
|
+
`[${accountId}] Dispatching reply: sessionKey=${sessionKey}, chatType=${isGroup ? "group" : "direct"}, replyTarget=${replyTarget}`,
|
|
365
|
+
);
|
|
354
366
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
const replyText = payload?.text ?? payload?.body;
|
|
361
|
-
if (replyText) {
|
|
367
|
+
await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
368
|
+
ctx: msgCtx,
|
|
369
|
+
cfg: currentCfg,
|
|
370
|
+
dispatcherOptions: {
|
|
371
|
+
deliver: async (payload) => {
|
|
362
372
|
const monitor = getTeamilyMonitor(accountId);
|
|
363
|
-
if (!monitor)
|
|
364
|
-
|
|
373
|
+
if (!monitor)
|
|
374
|
+
throw new Error(`Teamily monitor not running for account ${accountId}`);
|
|
365
375
|
const target = normalizeTeamilyTarget(replyTarget);
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
onReplyStart: () => {
|
|
370
|
-
log?.info?.(`Agent reply started for ${from}`);
|
|
371
|
-
},
|
|
372
|
-
},
|
|
373
|
-
});
|
|
376
|
+
const replyText = (payload as Record<string, unknown>)?.text as string | undefined
|
|
377
|
+
?? (payload as Record<string, unknown>)?.body as string | undefined
|
|
378
|
+
?? "";
|
|
374
379
|
|
|
375
|
-
|
|
380
|
+
// Send media attachments (first with caption, rest without).
|
|
381
|
+
const mediaUrls = resolveOutboundMediaUrls(payload as Record<string, unknown>);
|
|
382
|
+
const sentMedia = await sendMediaWithLeadingCaption({
|
|
383
|
+
mediaUrls,
|
|
384
|
+
caption: replyText,
|
|
385
|
+
send: async ({ mediaUrl: url, caption }) => {
|
|
386
|
+
await loadAndSendMedia(monitor, target, url);
|
|
387
|
+
// OpenIM doesn't support inline captions on media; send separately.
|
|
388
|
+
if (caption) {
|
|
389
|
+
await monitor.sendText(target, caption);
|
|
390
|
+
}
|
|
391
|
+
},
|
|
392
|
+
onError: (error) => {
|
|
393
|
+
log?.warn?.(`[${accountId}] Media send failed: ${String(error)}`);
|
|
394
|
+
},
|
|
395
|
+
});
|
|
376
396
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
397
|
+
// If no media was sent, send text only.
|
|
398
|
+
if (!sentMedia && replyText) {
|
|
399
|
+
log?.info?.(
|
|
400
|
+
`[${accountId}] Sending reply to: ${replyTarget} (isGroup=${isGroup})`,
|
|
401
|
+
);
|
|
402
|
+
await monitor.sendText(target, replyText);
|
|
403
|
+
}
|
|
404
|
+
},
|
|
405
|
+
onReplyStart: () => {
|
|
406
|
+
log?.info?.(`Agent reply started for ${from}`);
|
|
407
|
+
},
|
|
408
|
+
},
|
|
383
409
|
});
|
|
384
|
-
|
|
410
|
+
|
|
411
|
+
log?.info?.(`[${accountId}] Dispatch completed for ${sessionKey}`);
|
|
412
|
+
|
|
413
|
+
// Clear history buffer after dispatch so context doesn't repeat.
|
|
414
|
+
if (isGroup && historyKey) {
|
|
415
|
+
clearHistoryEntriesIfEnabled({
|
|
416
|
+
historyMap: groupHistories,
|
|
417
|
+
historyKey,
|
|
418
|
+
limit: historyLimit,
|
|
419
|
+
});
|
|
420
|
+
}
|
|
385
421
|
} catch (err) {
|
|
386
422
|
log?.error?.(
|
|
387
423
|
`[${accountId}] Error handling message from ${message.sendID}: ${err instanceof Error ? err.stack || err.message : String(err)}`,
|
|
@@ -410,7 +446,7 @@ function applyTeamilyAccountConfig(params: {
|
|
|
410
446
|
}): CoreConfig {
|
|
411
447
|
const { cfg, accountId, input } = params;
|
|
412
448
|
const existing = cfg.channels?.teamily;
|
|
413
|
-
const server = existing?.server ?? {
|
|
449
|
+
const server = existing?.server ?? { apiURL: "", wsURL: "" };
|
|
414
450
|
const accounts = existing?.accounts ?? {};
|
|
415
451
|
|
|
416
452
|
const accountUpdate: Record<string, unknown> = {};
|
|
@@ -420,7 +456,6 @@ function applyTeamilyAccountConfig(params: {
|
|
|
420
456
|
if (input.faceURL) accountUpdate.faceURL = String(input.faceURL);
|
|
421
457
|
|
|
422
458
|
const serverUpdate: Record<string, string> = {};
|
|
423
|
-
if (input.platformUrl) serverUpdate.platformUrl = String(input.platformUrl);
|
|
424
459
|
if (input.apiURL) serverUpdate.apiURL = String(input.apiURL);
|
|
425
460
|
if (input.wsURL) serverUpdate.wsURL = String(input.wsURL);
|
|
426
461
|
|
|
@@ -443,11 +478,73 @@ function applyTeamilyAccountConfig(params: {
|
|
|
443
478
|
} as CoreConfig;
|
|
444
479
|
}
|
|
445
480
|
|
|
481
|
+
/** Send a local media buffer using SDK's *ByFile methods (SDK handles upload). */
|
|
482
|
+
async function sendMediaBuffer(
|
|
483
|
+
monitor: TeamilyMonitor,
|
|
484
|
+
target: ReturnType<typeof normalizeTeamilyTarget>,
|
|
485
|
+
buffer: Buffer,
|
|
486
|
+
fileName: string,
|
|
487
|
+
contentType: string,
|
|
488
|
+
originalPath: string,
|
|
489
|
+
): Promise<string> {
|
|
490
|
+
const category = detectMediaCategory(originalPath);
|
|
491
|
+
switch (category) {
|
|
492
|
+
case "video":
|
|
493
|
+
return monitor.sendVideoBuffer(target, buffer, fileName, contentType);
|
|
494
|
+
case "audio":
|
|
495
|
+
return monitor.sendAudioBuffer(target, buffer, fileName, contentType);
|
|
496
|
+
case "file":
|
|
497
|
+
return monitor.sendFileBuffer(target, buffer, fileName, contentType);
|
|
498
|
+
default:
|
|
499
|
+
return monitor.sendImageBuffer(target, buffer, fileName, contentType);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** Send media by remote URL using existing *ByURL methods. */
|
|
504
|
+
async function sendMediaByUrl(
|
|
505
|
+
monitor: TeamilyMonitor,
|
|
506
|
+
target: ReturnType<typeof normalizeTeamilyTarget>,
|
|
507
|
+
url: string,
|
|
508
|
+
): Promise<string> {
|
|
509
|
+
const category = detectMediaCategory(url);
|
|
510
|
+
switch (category) {
|
|
511
|
+
case "video":
|
|
512
|
+
return monitor.sendVideo(target, url);
|
|
513
|
+
case "audio":
|
|
514
|
+
return monitor.sendAudio(target, url);
|
|
515
|
+
case "file":
|
|
516
|
+
return monitor.sendFile(target, url);
|
|
517
|
+
default:
|
|
518
|
+
return monitor.sendImage(target, url);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Load a local media file and send it via SDK buffer upload.
|
|
524
|
+
* For remote URLs, send directly via *ByURL methods.
|
|
525
|
+
*/
|
|
526
|
+
async function loadAndSendMedia(
|
|
527
|
+
monitor: TeamilyMonitor,
|
|
528
|
+
target: ReturnType<typeof normalizeTeamilyTarget>,
|
|
529
|
+
mediaUrl: string,
|
|
530
|
+
): Promise<void> {
|
|
531
|
+
if (isLocalMediaPath(mediaUrl)) {
|
|
532
|
+
const media = await loadOutboundMediaFromUrl(mediaUrl);
|
|
533
|
+
const fileName = media.fileName || path.basename(mediaUrl) || "media";
|
|
534
|
+
const contentType = media.contentType || guessContentType(mediaUrl);
|
|
535
|
+
await sendMediaBuffer(monitor, target, media.buffer, fileName, contentType, mediaUrl);
|
|
536
|
+
} else {
|
|
537
|
+
await sendMediaByUrl(monitor, target, mediaUrl);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
446
541
|
function requireMonitor(accountId?: string | null) {
|
|
447
542
|
const id = accountId || "default";
|
|
448
543
|
const monitor = getTeamilyMonitor(id);
|
|
449
544
|
if (!monitor) {
|
|
450
|
-
throw new Error(
|
|
545
|
+
throw new Error(
|
|
546
|
+
`Teamily gateway not running for account "${id}" — outbound requires an active gateway`,
|
|
547
|
+
);
|
|
451
548
|
}
|
|
452
549
|
return monitor;
|
|
453
550
|
}
|
package/src/config-schema.ts
CHANGED
|
@@ -5,7 +5,6 @@ import type { TeamilyConfig } from "./types.js";
|
|
|
5
5
|
|
|
6
6
|
// Server configuration schema
|
|
7
7
|
export const TeamilyServerConfigSchema = z.object({
|
|
8
|
-
platformUrl: z.string().url().default("http://localhost:10002").describe("Teamily platform URL"),
|
|
9
8
|
apiURL: z.string().url().default("http://localhost:10002").describe("Teamily REST API URL"),
|
|
10
9
|
wsURL: z.string().url().default("ws://localhost:10001").describe("Teamily WebSocket URL"),
|
|
11
10
|
});
|
package/src/monitor.ts
CHANGED
|
@@ -205,6 +205,99 @@ export class TeamilyMonitor {
|
|
|
205
205
|
return result.data?.serverMsgID || result.data?.clientMsgID || "";
|
|
206
206
|
}
|
|
207
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(target: TeamilyMessageTarget, buffer: Buffer, fileName: string, contentType: string): Promise<string> {
|
|
213
|
+
const sdk = this.requireSdk();
|
|
214
|
+
const file = new File([new Uint8Array(buffer)], fileName, { type: contentType });
|
|
215
|
+
const picInfo = { uuid: "", type: contentType, width: 0, height: 0, size: buffer.length, url: "" };
|
|
216
|
+
const created = await sdk.createImageMessageByFile({
|
|
217
|
+
sourcePicture: picInfo,
|
|
218
|
+
bigPicture: picInfo,
|
|
219
|
+
snapshotPicture: picInfo,
|
|
220
|
+
sourcePath: "",
|
|
221
|
+
file,
|
|
222
|
+
});
|
|
223
|
+
const result = await sdk.sendMessage({
|
|
224
|
+
recvID: target.type === "user" ? target.id : "",
|
|
225
|
+
groupID: target.type === "group" ? target.id : "",
|
|
226
|
+
message: created.data,
|
|
227
|
+
});
|
|
228
|
+
return result.data?.serverMsgID || result.data?.clientMsgID || "";
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Send a video from a local buffer. SDK handles the upload to object storage. */
|
|
232
|
+
async sendVideoBuffer(target: TeamilyMessageTarget, buffer: Buffer, fileName: string, contentType: string): Promise<string> {
|
|
233
|
+
const sdk = this.requireSdk();
|
|
234
|
+
const videoFile = new File([new Uint8Array(buffer)], fileName, { type: contentType });
|
|
235
|
+
// snapshotFile is required by the SDK type but we have no thumbnail; use an empty file.
|
|
236
|
+
const snapshotFile = new File([], "snapshot.jpg", { type: "image/jpeg" });
|
|
237
|
+
const created = await sdk.createVideoMessageByFile({
|
|
238
|
+
videoPath: "",
|
|
239
|
+
duration: 0,
|
|
240
|
+
videoType: contentType,
|
|
241
|
+
snapshotPath: "",
|
|
242
|
+
videoUUID: "",
|
|
243
|
+
videoUrl: "",
|
|
244
|
+
videoSize: buffer.length,
|
|
245
|
+
snapshotUUID: "",
|
|
246
|
+
snapshotSize: 0,
|
|
247
|
+
snapshotUrl: "",
|
|
248
|
+
snapshotWidth: 0,
|
|
249
|
+
snapshotHeight: 0,
|
|
250
|
+
videoFile,
|
|
251
|
+
snapshotFile,
|
|
252
|
+
});
|
|
253
|
+
const result = await sdk.sendMessage({
|
|
254
|
+
recvID: target.type === "user" ? target.id : "",
|
|
255
|
+
groupID: target.type === "group" ? target.id : "",
|
|
256
|
+
message: created.data,
|
|
257
|
+
});
|
|
258
|
+
return result.data?.serverMsgID || result.data?.clientMsgID || "";
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Send an audio/sound from a local buffer. SDK handles the upload to object storage. */
|
|
262
|
+
async sendAudioBuffer(target: TeamilyMessageTarget, buffer: Buffer, fileName: string, contentType: string): Promise<string> {
|
|
263
|
+
const sdk = this.requireSdk();
|
|
264
|
+
const file = new File([new Uint8Array(buffer)], fileName, { type: contentType });
|
|
265
|
+
const created = await sdk.createSoundMessageByFile({
|
|
266
|
+
uuid: "",
|
|
267
|
+
soundPath: "",
|
|
268
|
+
sourceUrl: "",
|
|
269
|
+
dataSize: buffer.length,
|
|
270
|
+
duration: 0,
|
|
271
|
+
file,
|
|
272
|
+
});
|
|
273
|
+
const result = await sdk.sendMessage({
|
|
274
|
+
recvID: target.type === "user" ? target.id : "",
|
|
275
|
+
groupID: target.type === "group" ? target.id : "",
|
|
276
|
+
message: created.data,
|
|
277
|
+
});
|
|
278
|
+
return result.data?.serverMsgID || result.data?.clientMsgID || "";
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Send a document/file from a local buffer. SDK handles the upload to object storage. */
|
|
282
|
+
async sendFileBuffer(target: TeamilyMessageTarget, buffer: Buffer, fileName: string, contentType: string): Promise<string> {
|
|
283
|
+
const sdk = this.requireSdk();
|
|
284
|
+
const file = new File([new Uint8Array(buffer)], fileName, { type: contentType });
|
|
285
|
+
const created = await sdk.createFileMessageByFile({
|
|
286
|
+
filePath: "",
|
|
287
|
+
fileName,
|
|
288
|
+
uuid: "",
|
|
289
|
+
sourceUrl: "",
|
|
290
|
+
fileSize: buffer.length,
|
|
291
|
+
file,
|
|
292
|
+
});
|
|
293
|
+
const result = await sdk.sendMessage({
|
|
294
|
+
recvID: target.type === "user" ? target.id : "",
|
|
295
|
+
groupID: target.type === "group" ? target.id : "",
|
|
296
|
+
message: created.data,
|
|
297
|
+
});
|
|
298
|
+
return result.data?.serverMsgID || result.data?.clientMsgID || "";
|
|
299
|
+
}
|
|
300
|
+
|
|
208
301
|
private requireSdk(): SdkInstance {
|
|
209
302
|
if (!this.sdk) {
|
|
210
303
|
throw new Error("Teamily SDK not connected");
|
package/src/types.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
// Core configuration types for Teamily channel
|
|
2
2
|
|
|
3
3
|
export interface TeamilyServerConfig {
|
|
4
|
-
platformUrl: string;
|
|
5
4
|
apiURL: string;
|
|
6
5
|
wsURL: string;
|
|
7
6
|
}
|
|
@@ -29,7 +28,6 @@ export interface TeamilyConfig {
|
|
|
29
28
|
export interface ResolvedTeamilyAccount {
|
|
30
29
|
accountId: string;
|
|
31
30
|
enabled: boolean;
|
|
32
|
-
platformUrl: string;
|
|
33
31
|
apiURL: string;
|
|
34
32
|
wsURL: string;
|
|
35
33
|
userID: string;
|
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
|
+
}
|