@ascegu/teamily 1.0.18 → 1.0.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Teamily Channel Plugin for OpenClaw
2
2
 
3
- Integrates [Teamily](https://teamily.ai/) (based on OpenIM) with OpenClaw as a self-hosted team messaging channel.
3
+ Integrates [Teamily](https://teamily.ai/) with OpenClaw as a self-hosted team messaging channel.
4
4
 
5
5
  ## Installation
6
6
 
@@ -26,29 +26,28 @@ openclaw channel configure teamily
26
26
 
27
27
  ### Server Settings
28
28
 
29
- | Field | Description | Default |
30
- |---------------|--------------------------|---------------------------|
31
- | `platformUrl` | Teamily platform URL | `http://localhost:10002` |
32
- | `apiURL` | Teamily REST API URL | `http://localhost:10002` |
33
- | `wsURL` | Teamily WebSocket URL | `ws://localhost:10001` |
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 | Description |
49
- |--------------------|------------------------------------------------------|
50
- | `dm.policy` | DM security policy (`pairing`, `allowlist`, `open`) |
51
- | `dm.allowFrom` | List of allowed sender IDs |
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,9 +56,8 @@ channels:
57
56
  teamily:
58
57
  enabled: true
59
58
  server:
60
- platformUrl: http://your-server:10002
61
- apiURL: http://your-server:10002
62
- wsURL: ws://your-server:10001
59
+ apiURL: https://imserver-test.teamily.ai/im_api
60
+ wsURL: wss://imserver-test.teamily.ai/msg_gateway
63
61
  accounts:
64
62
  default:
65
63
  userID: "bot-user-id"
@@ -91,42 +89,35 @@ 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 | Type |
95
- |------------------------------|-------|
96
- | `.jpg`, `.png`, `.gif`, etc. | Image |
97
- | `.mp4`, `.mov`, `.webm` | Video |
98
- | `.mp3`, `.m4a`, `.wav` | Audio |
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
 
103
- - The bot only **replies** to messages where it is **@-mentioned** (`@BotName`).
104
- - Non-@-mention group messages are **buffered in memory** (up to 50 per group) and injected as conversation context when an @-mention triggers a reply. The buffer is cleared after each dispatch so context doesn't repeat.
101
+ - All group messages are received and dispatched to the agent for context accumulation.
102
+ - The bot only **replies** when it is **@-mentioned** in the group (`@BotName`).
105
103
  - In direct messages, the bot always replies.
106
104
  - Both regular groups (`sessionType=3`) and super groups (`sessionType=2`) are supported.
107
105
 
108
106
  ## Capabilities
109
107
 
110
- | Feature | Supported |
111
- |---------------------------|-----------|
112
- | Direct messaging | Yes |
113
- | Group messaging | Yes |
114
- | Text messages | Yes |
115
- | Media (image/video/audio/file) | Yes |
116
- | @-mention gating (groups) | Yes |
117
- | Group history context (50 msg buffer) | Yes |
118
- | WebSocket real-time monitoring | Yes |
119
- | Automatic reconnection | Yes |
120
- | Connection health probes | Yes |
121
- | Reactions | No |
122
- | Threads | No |
123
- | Polls | No |
124
-
125
- ## Setting Up Teamily Server
126
-
127
- 1. **Deploy OpenIM server** -- follow the [OpenIM Quick Start](https://docs.openim.io/guides/gettingStarted/introduction) guide.
128
- 2. **Create a bot user** -- use the Teamily management API to create a bot account and obtain its `userID` and `token`.
129
- 3. **Configure OpenClaw** -- run `openclaw channel configure teamily` and provide the server URLs and bot credentials.
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 |
130
121
 
131
122
  ## Architecture
132
123
 
@@ -144,10 +135,6 @@ src/
144
135
  send.ts REST API message/media send (fallback path)
145
136
  ```
146
137
 
147
- ## Compatibility
148
-
149
- Designed for OpenIM API v2/v3. Requires `@openim/client-sdk` ^3.8.3.
150
-
151
138
  ## License
152
139
 
153
140
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ascegu/teamily",
3
- "version": "1.0.18",
3
+ "version": "1.0.22",
4
4
  "description": "OpenClaw Teamily channel plugin - Team instant messaging server integration",
5
5
  "keywords": [
6
6
  "channel",
package/src/accounts.ts CHANGED
@@ -38,7 +38,6 @@ export function resolveTeamilyAccount(
38
38
  return {
39
39
  accountId: targetAccountId,
40
40
  enabled: true,
41
- platformUrl: config.server.platformUrl,
42
41
  apiURL: config.server.apiURL,
43
42
  wsURL: config.server.wsURL,
44
43
  userID: account.userID,
package/src/channel.ts ADDED
@@ -0,0 +1,469 @@
1
+ import {
2
+ applyAccountNameToChannelSection,
3
+ buildChannelConfigSchema,
4
+ buildPendingHistoryContextFromMap,
5
+ clearHistoryEntriesIfEnabled,
6
+ DEFAULT_ACCOUNT_ID,
7
+ DEFAULT_GROUP_HISTORY_LIMIT,
8
+ deleteAccountFromConfigSection,
9
+ normalizeAccountId,
10
+ PAIRING_APPROVED_MESSAGE,
11
+ recordPendingHistoryEntryIfEnabled,
12
+ setAccountEnabledInConfigSection,
13
+ type ChannelPlugin,
14
+ type ChannelOutboundContext,
15
+ type ChannelStatusIssue,
16
+ type HistoryEntry,
17
+ } from "openclaw/plugin-sdk";
18
+ import {
19
+ buildAccountScopedDmSecurityPolicy,
20
+ createScopedAccountConfigAccessors,
21
+ } from "openclaw/plugin-sdk/compat";
22
+ import {
23
+ listTeamilyAccountIds,
24
+ resolveDefaultTeamilyAccountId,
25
+ resolveTeamilyAccount,
26
+ } from "./accounts.js";
27
+ import { TeamilyConfigSchema } from "./config-schema.js";
28
+ import type { CoreConfig } from "./config-schema.js";
29
+ import { getTeamilyMonitor, startTeamilyMonitoring, stopTeamilyMonitoring } from "./monitor.js";
30
+ import { normalizeTeamilyTarget, normalizeTeamilyAllowEntry } from "./normalize.js";
31
+ import { probeTeamily } from "./probe.js";
32
+ import { getTeamilyRuntime } from "./runtime.js";
33
+ import type { ResolvedTeamilyAccount } from "./types.js";
34
+ import { isGroupSession } from "./types.js";
35
+
36
+ const meta = {
37
+ id: "teamily",
38
+ label: "Teamily",
39
+ selectionLabel: "Teamily (self-hosted)",
40
+ docsPath: "/channels/teamily",
41
+ docsLabel: "teamily",
42
+ blurb: "Team instant messaging server",
43
+ order: 75,
44
+ quickstartAllowFrom: true,
45
+ };
46
+
47
+ const teamilyConfigAccessors = createScopedAccountConfigAccessors({
48
+ resolveAccount: ({ cfg, accountId }) => resolveTeamilyAccount(cfg as CoreConfig, accountId),
49
+ resolveAllowFrom: (account) => account.dm?.allowFrom,
50
+ formatAllowFrom: (allowFrom) => allowFrom.map((id) => String(id)),
51
+ });
52
+
53
+ export const teamilyPlugin: ChannelPlugin<ResolvedTeamilyAccount> = {
54
+ id: "teamily",
55
+ meta,
56
+ capabilities: {
57
+ chatTypes: ["direct", "group"],
58
+ media: true,
59
+ reactions: false,
60
+ threads: false,
61
+ polls: false,
62
+ },
63
+ reload: { configPrefixes: ["channels.teamily"] },
64
+ setup: {
65
+ resolveAccountId: ({ accountId, input }) => {
66
+ if (accountId) return accountId;
67
+ if (input?.name) return normalizeAccountId(String(input.name));
68
+ return DEFAULT_ACCOUNT_ID;
69
+ },
70
+ applyAccountName: ({ cfg, accountId, name }) =>
71
+ applyAccountNameToChannelSection({
72
+ cfg,
73
+ channelKey: "teamily",
74
+ accountId,
75
+ name,
76
+ }),
77
+ applyAccountConfig: ({ cfg, accountId, input }) =>
78
+ applyTeamilyAccountConfig({
79
+ cfg: cfg as CoreConfig,
80
+ accountId,
81
+ input: input as Record<string, unknown>,
82
+ }),
83
+ },
84
+ pairing: {
85
+ idLabel: "teamilyUserId",
86
+ normalizeAllowEntry: (entry) => {
87
+ try {
88
+ return normalizeTeamilyAllowEntry(entry);
89
+ } catch {
90
+ return entry;
91
+ }
92
+ },
93
+ notifyApproval: async ({ id, cfg }) => {
94
+ try {
95
+ const accountId = resolveDefaultTeamilyAccountId(cfg as CoreConfig);
96
+ const target = normalizeTeamilyTarget(id);
97
+ const monitor = getTeamilyMonitor(accountId);
98
+ if (monitor) {
99
+ await monitor.sendText(target, PAIRING_APPROVED_MESSAGE);
100
+ }
101
+ // If monitor isn't running, skip silently — pairing was still approved
102
+ } catch {
103
+ // Silently fail on notification
104
+ }
105
+ },
106
+ },
107
+ configSchema: buildChannelConfigSchema(TeamilyConfigSchema),
108
+ config: {
109
+ listAccountIds: (cfg) => listTeamilyAccountIds(cfg as CoreConfig),
110
+ resolveAccount: (cfg, accountId) => resolveTeamilyAccount(cfg as CoreConfig, accountId),
111
+ defaultAccountId: (cfg) => resolveDefaultTeamilyAccountId(cfg as CoreConfig),
112
+ setAccountEnabled: ({ cfg, accountId, enabled }) =>
113
+ setAccountEnabledInConfigSection({
114
+ cfg: cfg as CoreConfig,
115
+ sectionKey: "teamily",
116
+ accountId,
117
+ enabled,
118
+ allowTopLevel: true,
119
+ }),
120
+ deleteAccount: ({ cfg, accountId }) =>
121
+ deleteAccountFromConfigSection({
122
+ cfg: cfg as CoreConfig,
123
+ sectionKey: "teamily",
124
+ accountId,
125
+ clearBaseFields: ["name", "userID", "token", "nickname", "faceURL"],
126
+ }),
127
+ isConfigured: (account) => !!account.token,
128
+ describeAccount: (account) => ({
129
+ accountId: account.accountId,
130
+ name: account.nickname || account.userID,
131
+ enabled: account.enabled,
132
+ configured: !!account.token,
133
+ }),
134
+ ...teamilyConfigAccessors,
135
+ },
136
+ security: {
137
+ resolveDmPolicy: ({ cfg, accountId, account }) => {
138
+ return buildAccountScopedDmSecurityPolicy({
139
+ cfg: cfg as CoreConfig,
140
+ channelKey: "teamily",
141
+ accountId,
142
+ fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID,
143
+ policy: account.dm?.policy,
144
+ allowFrom: account.dm?.allowFrom ?? [],
145
+ allowFromPathSuffix: "dm.",
146
+ normalizeEntry: (raw) => normalizeTeamilyAllowEntry(raw),
147
+ });
148
+ },
149
+ },
150
+ outbound: {
151
+ deliveryMode: "gateway",
152
+ resolveTarget: ({ to }) => {
153
+ if (!to?.trim()) {
154
+ return { ok: false, error: new Error("Teamily requires --to <userId|group:groupId>") };
155
+ }
156
+ try {
157
+ const target = normalizeTeamilyTarget(to);
158
+ // Preserve the full target format so sendText/sendMedia can distinguish user vs group
159
+ const resolved = target.type === "group" ? `group:${target.id}` : target.id;
160
+ return { ok: true, to: resolved };
161
+ } catch (err) {
162
+ return { ok: false, error: err instanceof Error ? err : new Error(String(err)) };
163
+ }
164
+ },
165
+ sendText: async (ctx: ChannelOutboundContext) => {
166
+ const { to, text, accountId } = ctx;
167
+ const monitor = requireMonitor(accountId);
168
+ const target = normalizeTeamilyTarget(to);
169
+ const messageId = await monitor.sendText(target, text);
170
+ return { channel: "teamily" as const, messageId };
171
+ },
172
+ sendMedia: async (ctx: ChannelOutboundContext) => {
173
+ const { to, accountId } = ctx;
174
+ const mediaUrl = ctx.mediaUrl;
175
+ if (!mediaUrl) {
176
+ throw new Error("Media URL is required");
177
+ }
178
+ const monitor = requireMonitor(accountId);
179
+ const target = normalizeTeamilyTarget(to);
180
+
181
+ let messageId: string;
182
+ const urlLower = mediaUrl.toLowerCase();
183
+ if (urlLower.endsWith(".mp4") || urlLower.endsWith(".mov") || urlLower.endsWith(".webm")) {
184
+ messageId = await monitor.sendVideo(target, mediaUrl);
185
+ } else if (
186
+ urlLower.endsWith(".mp3") ||
187
+ urlLower.endsWith(".m4a") ||
188
+ urlLower.endsWith(".wav")
189
+ ) {
190
+ messageId = await monitor.sendAudio(target, mediaUrl);
191
+ } else if (
192
+ urlLower.endsWith(".pdf") ||
193
+ urlLower.endsWith(".doc") ||
194
+ urlLower.endsWith(".docx") ||
195
+ urlLower.endsWith(".zip")
196
+ ) {
197
+ messageId = await monitor.sendFile(target, mediaUrl);
198
+ } else {
199
+ messageId = await monitor.sendImage(target, mediaUrl);
200
+ }
201
+
202
+ return { channel: "teamily" as const, messageId };
203
+ },
204
+ },
205
+ status: {
206
+ probeAccount: async ({ account }) => {
207
+ const result = await probeTeamily(account);
208
+ if (!result.connected) {
209
+ return {
210
+ ok: false,
211
+ error: result.error || "Failed to connect to Teamily server",
212
+ };
213
+ }
214
+ return { ok: true };
215
+ },
216
+ buildAccountSnapshot: ({ account, runtime, probe }) => ({
217
+ accountId: account.accountId,
218
+ name: account.nickname || account.userID,
219
+ enabled: account.enabled,
220
+ configured: !!account.token,
221
+ running: runtime?.running ?? false,
222
+ lastStartAt: runtime?.lastStartAt ?? null,
223
+ lastStopAt: runtime?.lastStopAt ?? null,
224
+ lastError: runtime?.lastError ?? null,
225
+ probe,
226
+ }),
227
+ collectStatusIssues: (accounts) => {
228
+ const issues: ChannelStatusIssue[] = [];
229
+ for (const snap of accounts) {
230
+ if (snap.lastError) {
231
+ issues.push({
232
+ channel: "teamily",
233
+ accountId: snap.accountId,
234
+ kind: "runtime",
235
+ message: snap.lastError,
236
+ fix: "Check that the Teamily server is running and accessible",
237
+ });
238
+ }
239
+ }
240
+ return issues;
241
+ },
242
+ },
243
+ gateway: {
244
+ startAccount: async (ctx) => {
245
+ const { accountId, account, log } = ctx;
246
+
247
+ if (!account.token) {
248
+ log?.warn?.(`Teamily account ${accountId} not configured (missing token)`);
249
+ return;
250
+ }
251
+
252
+ log?.info?.(`Starting Teamily channel (account: ${accountId})`);
253
+
254
+ const MEDIA_MAX_BYTES = 5 * 1024 * 1024; // 5 MB
255
+ const historyLimit = DEFAULT_GROUP_HISTORY_LIMIT; // 50 messages per group
256
+ const groupHistories = new Map<string, HistoryEntry[]>();
257
+
258
+ const stopFn = startTeamilyMonitoring(account, async (message) => {
259
+ try {
260
+ const rt = getTeamilyRuntime();
261
+ const currentCfg = rt.config.loadConfig();
262
+
263
+ const isGroup = isGroupSession(message.sessionType);
264
+ const from = message.sendID;
265
+ const rawText = message.content?.text || "";
266
+
267
+ log?.info?.(
268
+ `[${accountId}] Incoming message: sessionType=${message.sessionType}, isGroup=${isGroup}, ` +
269
+ `from=${from}, recvID=${message.recvID}, isAtSelf=${message.isAtSelf ?? false}`,
270
+ );
271
+
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;
298
+ }
299
+
300
+ const sessionKey = isGroup ? `teamily:group:${message.recvID}` : `teamily:${from}`;
301
+
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
+ }
310
+
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
+ }
332
+ }
333
+
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;
345
+
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
+ );
366
+
367
+ await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
368
+ ctx: msgCtx,
369
+ cfg: currentCfg,
370
+ dispatcherOptions: {
371
+ deliver: async (payload: { text?: string; body?: string }) => {
372
+ const replyText = payload?.text ?? payload?.body;
373
+ if (replyText) {
374
+ const monitor = getTeamilyMonitor(accountId);
375
+ if (!monitor)
376
+ throw new Error(`Teamily monitor not running for account ${accountId}`);
377
+ log?.info?.(
378
+ `[${accountId}] Sending reply to: ${replyTarget} (isGroup=${isGroup})`,
379
+ );
380
+ const target = normalizeTeamilyTarget(replyTarget);
381
+ await monitor.sendText(target, replyText);
382
+ }
383
+ },
384
+ onReplyStart: () => {
385
+ log?.info?.(`Agent reply started for ${from}`);
386
+ },
387
+ },
388
+ });
389
+
390
+ log?.info?.(`[${accountId}] Dispatch completed for ${sessionKey}`);
391
+
392
+ // Clear history buffer after dispatch so context doesn't repeat.
393
+ if (isGroup && historyKey) {
394
+ clearHistoryEntriesIfEnabled({
395
+ historyMap: groupHistories,
396
+ historyKey,
397
+ limit: historyLimit,
398
+ });
399
+ }
400
+ } catch (err) {
401
+ log?.error?.(
402
+ `[${accountId}] Error handling message from ${message.sendID}: ${err instanceof Error ? err.stack || err.message : String(err)}`,
403
+ );
404
+ }
405
+ });
406
+
407
+ // Respect abort signal from the gateway framework
408
+ ctx.abortSignal.addEventListener("abort", () => {
409
+ stopFn();
410
+ stopTeamilyMonitoring(accountId);
411
+ });
412
+
413
+ // Block until aborted — monitor runs indefinitely
414
+ await new Promise<void>((resolve) => {
415
+ ctx.abortSignal.addEventListener("abort", () => resolve());
416
+ });
417
+ },
418
+ },
419
+ };
420
+
421
+ function applyTeamilyAccountConfig(params: {
422
+ cfg: CoreConfig;
423
+ accountId: string;
424
+ input: Record<string, unknown>;
425
+ }): CoreConfig {
426
+ const { cfg, accountId, input } = params;
427
+ const existing = cfg.channels?.teamily;
428
+ const server = existing?.server ?? { apiURL: "", wsURL: "" };
429
+ const accounts = existing?.accounts ?? {};
430
+
431
+ const accountUpdate: Record<string, unknown> = {};
432
+ if (input.userID) accountUpdate.userID = String(input.userID);
433
+ if (input.token) accountUpdate.token = String(input.token);
434
+ if (input.nickname) accountUpdate.nickname = String(input.nickname);
435
+ if (input.faceURL) accountUpdate.faceURL = String(input.faceURL);
436
+
437
+ const serverUpdate: Record<string, string> = {};
438
+ if (input.apiURL) serverUpdate.apiURL = String(input.apiURL);
439
+ if (input.wsURL) serverUpdate.wsURL = String(input.wsURL);
440
+
441
+ return {
442
+ ...cfg,
443
+ channels: {
444
+ ...cfg.channels,
445
+ teamily: {
446
+ enabled: true,
447
+ server: { ...server, ...serverUpdate },
448
+ accounts: {
449
+ ...accounts,
450
+ [accountId]: {
451
+ ...accounts[accountId],
452
+ ...accountUpdate,
453
+ },
454
+ },
455
+ },
456
+ },
457
+ } as CoreConfig;
458
+ }
459
+
460
+ function requireMonitor(accountId?: string | null) {
461
+ const id = accountId || "default";
462
+ const monitor = getTeamilyMonitor(id);
463
+ if (!monitor) {
464
+ throw new Error(
465
+ `Teamily gateway not running for account "${id}" — outbound requires an active gateway`,
466
+ );
467
+ }
468
+ return monitor;
469
+ }
@@ -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
@@ -36,6 +36,11 @@ 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
+
39
44
  export class TeamilyMonitor {
40
45
  private account: ResolvedTeamilyAccount;
41
46
  private onMessage: TeamilyMessageHandler;
@@ -126,9 +131,29 @@ export class TeamilyMonitor {
126
131
  return result.data?.serverMsgID || result.data?.clientMsgID || "";
127
132
  }
128
133
 
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
+
129
153
  /** Send an image message through the SDK WebSocket connection. */
130
- async sendImage(target: TeamilyMessageTarget, url: string): Promise<string> {
154
+ async sendImage(target: TeamilyMessageTarget, urlOrPath: string): Promise<string> {
131
155
  const sdk = this.requireSdk();
156
+ const url = isHttpUrl(urlOrPath) ? urlOrPath : await this.uploadLocalFile(urlOrPath);
132
157
  const picInfo = { uuid: "", type: "", width: 0, height: 0, size: 0, url };
133
158
  const created = await sdk.createImageMessageByURL({
134
159
  sourcePicture: picInfo,
@@ -145,8 +170,9 @@ export class TeamilyMonitor {
145
170
  }
146
171
 
147
172
  /** Send a video message through the SDK WebSocket connection. */
148
- async sendVideo(target: TeamilyMessageTarget, url: string): Promise<string> {
173
+ async sendVideo(target: TeamilyMessageTarget, urlOrPath: string): Promise<string> {
149
174
  const sdk = this.requireSdk();
175
+ const url = isHttpUrl(urlOrPath) ? urlOrPath : await this.uploadLocalFile(urlOrPath);
150
176
  const created = await sdk.createVideoMessageByURL({
151
177
  videoPath: "",
152
178
  duration: 0,
@@ -170,8 +196,9 @@ export class TeamilyMonitor {
170
196
  }
171
197
 
172
198
  /** Send a sound/audio message through the SDK WebSocket connection. */
173
- async sendAudio(target: TeamilyMessageTarget, url: string): Promise<string> {
199
+ async sendAudio(target: TeamilyMessageTarget, urlOrPath: string): Promise<string> {
174
200
  const sdk = this.requireSdk();
201
+ const url = isHttpUrl(urlOrPath) ? urlOrPath : await this.uploadLocalFile(urlOrPath);
175
202
  const created = await sdk.createSoundMessageByURL({
176
203
  uuid: "",
177
204
  soundPath: "",
@@ -188,11 +215,12 @@ export class TeamilyMonitor {
188
215
  }
189
216
 
190
217
  /** Send a file message through the SDK WebSocket connection. */
191
- async sendFile(target: TeamilyMessageTarget, url: string, fileName?: string): Promise<string> {
218
+ async sendFile(target: TeamilyMessageTarget, urlOrPath: string, fileName?: string): Promise<string> {
192
219
  const sdk = this.requireSdk();
220
+ const url = isHttpUrl(urlOrPath) ? urlOrPath : await this.uploadLocalFile(urlOrPath);
193
221
  const created = await sdk.createFileMessageByURL({
194
222
  filePath: "",
195
- fileName: fileName || url.split("/").pop() || "file",
223
+ fileName: fileName || urlOrPath.split("/").pop() || "file",
196
224
  uuid: "",
197
225
  sourceUrl: url,
198
226
  fileSize: 0,
@@ -218,6 +246,32 @@ export class TeamilyMonitor {
218
246
  }
219
247
  }
220
248
 
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
+
221
275
  // ---- SDK message conversion helpers ----
222
276
 
223
277
  import type { MessageItem } from "@openim/client-sdk";
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;