@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 +38 -51
- package/package.json +1 -1
- package/src/accounts.ts +0 -1
- package/src/channel.ts +469 -0
- package/src/config-schema.ts +0 -1
- package/src/monitor.ts +59 -5
- package/src/types.ts +0 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Teamily Channel Plugin for OpenClaw
|
|
2
2
|
|
|
3
|
-
Integrates [Teamily](https://teamily.ai/)
|
|
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
|
|
30
|
-
|
|
31
|
-
| `
|
|
32
|
-
| `
|
|
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
|
|
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,9 +56,8 @@ channels:
|
|
|
57
56
|
teamily:
|
|
58
57
|
enabled: true
|
|
59
58
|
server:
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
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
|
|
|
103
|
-
-
|
|
104
|
-
-
|
|
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
|
|
111
|
-
|
|
112
|
-
| Direct messaging
|
|
113
|
-
| Group messaging
|
|
114
|
-
| Text messages
|
|
115
|
-
| Media (image/video/audio/file) | Yes
|
|
116
|
-
| @-mention gating (groups)
|
|
117
|
-
|
|
|
118
|
-
|
|
|
119
|
-
|
|
|
120
|
-
|
|
|
121
|
-
|
|
|
122
|
-
|
|
|
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
package/src/accounts.ts
CHANGED
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
|
+
}
|
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
|
@@ -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,
|
|
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,
|
|
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,
|
|
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,
|
|
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 ||
|
|
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;
|