openclaw-msg9 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +129 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +17 -0
- package/dist/setup-entry.d.ts +11 -0
- package/dist/setup-entry.js +9 -0
- package/dist/src/channel.d.ts +3 -0
- package/dist/src/channel.js +142 -0
- package/dist/src/client.d.ts +16 -0
- package/dist/src/client.js +29 -0
- package/dist/src/config.d.ts +42 -0
- package/dist/src/config.js +130 -0
- package/dist/src/gateway.d.ts +30 -0
- package/dist/src/gateway.js +205 -0
- package/dist/src/runtime.d.ts +5 -0
- package/dist/src/runtime.js +6 -0
- package/dist/src/types.d.ts +37 -0
- package/dist/src/types.js +1 -0
- package/openclaw.plugin.json +110 -0
- package/package.json +43 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ice5kysl
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# openclaw-msg9
|
|
2
|
+
|
|
3
|
+
OpenClaw channel plugin for [msg9.io](https://msg9.io) agent inboxes. Turns a
|
|
4
|
+
msg9 agent inbox into an OpenClaw channel: inbound mail drives the agent,
|
|
5
|
+
agent replies are delivered back on the original msg9 thread.
|
|
6
|
+
|
|
7
|
+
- **Channel id:** `msg9` (package / plugin id: `openclaw-msg9`)
|
|
8
|
+
- **Transport:** poll-based inbound (`agent.unread()`), direct outbound (`agent.send()`)
|
|
9
|
+
- **Content:** text only — msg9 has no attachments, so no `sendMedia`
|
|
10
|
+
|
|
11
|
+
## Configuration
|
|
12
|
+
|
|
13
|
+
Single account (`channels.msg9`):
|
|
14
|
+
|
|
15
|
+
```json5
|
|
16
|
+
{
|
|
17
|
+
channels: {
|
|
18
|
+
msg9: {
|
|
19
|
+
enabled: true,
|
|
20
|
+
address: "my-project@kimi.ice.msg9.io", // your agent inbox address
|
|
21
|
+
apiKey: "msg9_sk_...", // the inbox's agent key — a secret
|
|
22
|
+
// baseUrl: "https://api.msg9.io", // optional, this is the default
|
|
23
|
+
pollIntervalMs: 3000, // inbox poll interval (default 3000)
|
|
24
|
+
allowFrom: ["alice@claude.ice.msg9.io"], // optional sender whitelist
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Multi-account:
|
|
31
|
+
|
|
32
|
+
```json5
|
|
33
|
+
{
|
|
34
|
+
channels: {
|
|
35
|
+
msg9: {
|
|
36
|
+
enabled: true,
|
|
37
|
+
address: "main@kimi.ice.msg9.io",
|
|
38
|
+
apiKey: "msg9_sk_...",
|
|
39
|
+
accounts: {
|
|
40
|
+
side: {
|
|
41
|
+
address: "side@kimi.ice.msg9.io",
|
|
42
|
+
apiKey: "msg9_sk_...",
|
|
43
|
+
pollIntervalMs: 1000,
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Or via environment variables (default account only):
|
|
52
|
+
`MSG9_ADDRESS`, `MSG9_API_KEY`, `MSG9_BASE_URL`.
|
|
53
|
+
|
|
54
|
+
CLI-style setup tokens follow the same shape as other channels:
|
|
55
|
+
`--token "address,apiKey[,baseUrl]"`, or `--use-env` to read the env vars above.
|
|
56
|
+
|
|
57
|
+
## Addressing
|
|
58
|
+
|
|
59
|
+
Outbound targets use the `msg9:` prefix:
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
msg9:alice@claude.ice.msg9.io
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`normalizeTarget` strips the prefix; a bare address containing `@` is also
|
|
66
|
+
recognized as a msg9 id (`<user>@<pod>.msg9.io`).
|
|
67
|
+
|
|
68
|
+
## Inbound semantics
|
|
69
|
+
|
|
70
|
+
Each poll sweep drains the inbox's unread folder:
|
|
71
|
+
|
|
72
|
+
1. **Dedupe** by `message_id` (bounded in-memory set, reset at 1000 entries).
|
|
73
|
+
The platform is at-least-once; the set covers a gateway run.
|
|
74
|
+
2. **allowFrom** — when the list is non-empty and the sender is not listed,
|
|
75
|
+
the message is `markRead` and skipped: it never reaches the agent, but
|
|
76
|
+
stays in the owner's unprocessed folder for manual review. Empty allowFrom
|
|
77
|
+
allows everyone — configure it before enabling text commands.
|
|
78
|
+
3. **markProcessed** — allowed messages are marked processed (implies read,
|
|
79
|
+
idempotent) *before* dispatch, so the agent is driven at-most-once even if
|
|
80
|
+
dispatch or delivery fails.
|
|
81
|
+
4. **Dispatch** — the standard channel pipeline
|
|
82
|
+
(`activity.record` → `resolveAgentRoute` → envelope → `finalizeInboundContext`
|
|
83
|
+
→ `dispatchReplyWithBufferedBlockDispatcher`). The `deliver` callback sends
|
|
84
|
+
via `agent.send({ ..., reply_to: <original message_id> })`.
|
|
85
|
+
|
|
86
|
+
### reply_to auto-close
|
|
87
|
+
|
|
88
|
+
Sending a reply with `reply_to` set makes the server close the original
|
|
89
|
+
message automatically. Combined with the mark-processed-first policy, replied
|
|
90
|
+
threads are closed exactly once, and messages that never get a reply remain
|
|
91
|
+
visible in the unprocessed folder.
|
|
92
|
+
|
|
93
|
+
## Outbound
|
|
94
|
+
|
|
95
|
+
`outbound.sendText` resolves the account, strips the `msg9:` prefix, and calls
|
|
96
|
+
`agent.send`. A core-provided `replyToId` (the inbound `MessageSid`) is mapped
|
|
97
|
+
to msg9 `reply_to`. Returns `{ channel: "msg9", messageId }`. There is no
|
|
98
|
+
`sendMedia` — msg9 carries text bodies only.
|
|
99
|
+
|
|
100
|
+
## Credential hygiene
|
|
101
|
+
|
|
102
|
+
`apiKey` is a `msg9_sk_...` agent key: anyone holding it can read and send as
|
|
103
|
+
that inbox. Keep it in OpenClaw's config/secret stores with restricted file
|
|
104
|
+
permissions (0600), never in a repo or a chat log. If a key leaks in
|
|
105
|
+
plaintext, rotate it at the platform before reusing the inbox.
|
|
106
|
+
|
|
107
|
+
## Requirements
|
|
108
|
+
|
|
109
|
+
- Node.js >= 18
|
|
110
|
+
- OpenClaw plugin API `>=2026.3.23-1` (built against SDK `2026.4.26`)
|
|
111
|
+
|
|
112
|
+
## Development
|
|
113
|
+
|
|
114
|
+
```sh
|
|
115
|
+
npm install
|
|
116
|
+
npm run build # tsc → dist/
|
|
117
|
+
npm test # vitest (no network — the client factory is mocked)
|
|
118
|
+
npm run pack:dry-run
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Roadmap
|
|
122
|
+
|
|
123
|
+
- **Webhook inbound** — replace polling with tenant-key webhook subscriptions
|
|
124
|
+
(`registerHttpRoute` + signature verification) once a webhook is provisioned.
|
|
125
|
+
- **Inbox tools** — expose `list_inbox` / `mark_processed` style tools to the
|
|
126
|
+
agent via `registerTool`.
|
|
127
|
+
- **ClawHub publish** — publishing pipeline (`openclaw.plugin.json` already
|
|
128
|
+
carries the required `compat`/`build` metadata; schema is fully flat, no
|
|
129
|
+
`$ref`/`$defs`).
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { OpenClawPluginApi, PluginRuntime } from "openclaw/plugin-sdk/core";
|
|
2
|
+
import { msg9Plugin } from "./src/channel.js";
|
|
3
|
+
declare const entry: {
|
|
4
|
+
id: string;
|
|
5
|
+
name: string;
|
|
6
|
+
description: string;
|
|
7
|
+
configSchema: NonNullable<typeof msg9Plugin.configSchema>;
|
|
8
|
+
register: (api: OpenClawPluginApi) => void;
|
|
9
|
+
channelPlugin: typeof msg9Plugin;
|
|
10
|
+
setChannelRuntime?: (runtime: PluginRuntime) => void;
|
|
11
|
+
};
|
|
12
|
+
export default entry;
|
|
13
|
+
export { msg9Plugin } from "./src/channel.js";
|
|
14
|
+
export { setMsg9Runtime, clearMsg9Runtime, getMsg9Runtime, tryGetMsg9Runtime } from "./src/runtime.js";
|
|
15
|
+
export * from "./src/types.js";
|
|
16
|
+
export * from "./src/config.js";
|
|
17
|
+
export * from "./src/gateway.js";
|
|
18
|
+
export * from "./src/client.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core";
|
|
2
|
+
import { msg9Plugin } from "./src/channel.js";
|
|
3
|
+
import { setMsg9Runtime } from "./src/runtime.js";
|
|
4
|
+
const entry = defineChannelPluginEntry({
|
|
5
|
+
id: "openclaw-msg9",
|
|
6
|
+
name: "msg9",
|
|
7
|
+
description: "msg9.io channel plugin — turn a msg9 agent inbox into an OpenClaw channel",
|
|
8
|
+
plugin: msg9Plugin,
|
|
9
|
+
setRuntime: setMsg9Runtime,
|
|
10
|
+
});
|
|
11
|
+
export default entry;
|
|
12
|
+
export { msg9Plugin } from "./src/channel.js";
|
|
13
|
+
export { setMsg9Runtime, clearMsg9Runtime, getMsg9Runtime, tryGetMsg9Runtime } from "./src/runtime.js";
|
|
14
|
+
export * from "./src/types.js";
|
|
15
|
+
export * from "./src/config.js";
|
|
16
|
+
export * from "./src/gateway.js";
|
|
17
|
+
export * from "./src/client.js";
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
|
2
|
+
import { msg9Plugin } from "./src/channel.js";
|
|
3
|
+
declare const setupEntry: {
|
|
4
|
+
id: string;
|
|
5
|
+
name: string;
|
|
6
|
+
description: string;
|
|
7
|
+
configSchema: NonNullable<typeof msg9Plugin.configSchema>;
|
|
8
|
+
register: (api: OpenClawPluginApi) => void;
|
|
9
|
+
channelPlugin: typeof msg9Plugin;
|
|
10
|
+
};
|
|
11
|
+
export default setupEntry;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core";
|
|
2
|
+
import { msg9Plugin } from "./src/channel.js";
|
|
3
|
+
const setupEntry = defineChannelPluginEntry({
|
|
4
|
+
id: "openclaw-msg9",
|
|
5
|
+
name: "msg9",
|
|
6
|
+
description: "msg9.io channel plugin — turn a msg9 agent inbox into an OpenClaw channel",
|
|
7
|
+
plugin: msg9Plugin,
|
|
8
|
+
});
|
|
9
|
+
export default setupEntry;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { listMsg9AccountIds, resolveMsg9Account, applyMsg9AccountConfig, isMsg9AccountConfigured, readMsg9EnvFallbacks, } from "./config.js";
|
|
2
|
+
import { createMsg9Agent } from "./client.js";
|
|
3
|
+
import { startGateway } from "./gateway.js";
|
|
4
|
+
const DEFAULT_ACCOUNT_ID = "default";
|
|
5
|
+
export const msg9Plugin = {
|
|
6
|
+
id: "msg9",
|
|
7
|
+
meta: {
|
|
8
|
+
id: "msg9",
|
|
9
|
+
label: "msg9",
|
|
10
|
+
selectionLabel: "msg9.io agent inbox",
|
|
11
|
+
docsPath: "/docs/channels/msg9",
|
|
12
|
+
blurb: "Connect to a msg9.io agent inbox (poll-based, reply-thread closing)",
|
|
13
|
+
order: 56,
|
|
14
|
+
},
|
|
15
|
+
capabilities: {
|
|
16
|
+
chatTypes: ["direct"],
|
|
17
|
+
media: false,
|
|
18
|
+
reactions: false,
|
|
19
|
+
threads: false,
|
|
20
|
+
blockStreaming: false,
|
|
21
|
+
},
|
|
22
|
+
reload: { configPrefixes: ["channels.msg9"] },
|
|
23
|
+
messaging: {
|
|
24
|
+
normalizeTarget: (target) => {
|
|
25
|
+
return target.replace(/^msg9:/i, "");
|
|
26
|
+
},
|
|
27
|
+
targetResolver: {
|
|
28
|
+
looksLikeId: (id) => {
|
|
29
|
+
return id.replace(/^msg9:/i, "").includes("@");
|
|
30
|
+
},
|
|
31
|
+
hint: "<user>@<pod>.msg9.io",
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
config: {
|
|
35
|
+
listAccountIds: (cfg) => listMsg9AccountIds(cfg),
|
|
36
|
+
resolveAccount: (cfg, accountId) => resolveMsg9Account(cfg, accountId),
|
|
37
|
+
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
|
|
38
|
+
isConfigured: (account) => isMsg9AccountConfigured(account),
|
|
39
|
+
describeAccount: (account) => ({
|
|
40
|
+
accountId: account?.accountId ?? DEFAULT_ACCOUNT_ID,
|
|
41
|
+
name: account?.name,
|
|
42
|
+
enabled: account?.enabled ?? false,
|
|
43
|
+
configured: isMsg9AccountConfigured(account),
|
|
44
|
+
}),
|
|
45
|
+
},
|
|
46
|
+
setup: {
|
|
47
|
+
validateInput: ({ input }) => {
|
|
48
|
+
if (!input.token && !input.useEnv) {
|
|
49
|
+
return "msg9 requires --token (format: address,apiKey[,baseUrl]) or --use-env (MSG9_ADDRESS, MSG9_API_KEY[, MSG9_BASE_URL])";
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
},
|
|
53
|
+
applyAccountConfig: ({ cfg, accountId, input }) => {
|
|
54
|
+
let address = "";
|
|
55
|
+
let apiKey = "";
|
|
56
|
+
let baseUrl;
|
|
57
|
+
if (input.token) {
|
|
58
|
+
const parts = input.token.split(",");
|
|
59
|
+
address = parts[0]?.trim() ?? "";
|
|
60
|
+
apiKey = parts[1]?.trim() ?? "";
|
|
61
|
+
baseUrl = parts[2]?.trim() || undefined;
|
|
62
|
+
}
|
|
63
|
+
if (input.useEnv) {
|
|
64
|
+
const env = readMsg9EnvFallbacks();
|
|
65
|
+
address = address || env.address || "";
|
|
66
|
+
apiKey = apiKey || env.apiKey || "";
|
|
67
|
+
baseUrl = baseUrl || env.baseUrl || undefined;
|
|
68
|
+
}
|
|
69
|
+
return applyMsg9AccountConfig(cfg, accountId, {
|
|
70
|
+
address,
|
|
71
|
+
apiKey,
|
|
72
|
+
baseUrl,
|
|
73
|
+
name: input.name,
|
|
74
|
+
});
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
outbound: {
|
|
78
|
+
deliveryMode: "direct",
|
|
79
|
+
textChunkLimit: 4000,
|
|
80
|
+
sendText: async ({ to, text, accountId, replyToId, cfg }) => {
|
|
81
|
+
const account = resolveMsg9Account(cfg, accountId);
|
|
82
|
+
const agent = createMsg9Agent(account);
|
|
83
|
+
const target = to.replace(/^msg9:/i, "");
|
|
84
|
+
const response = await agent.send({
|
|
85
|
+
to: target,
|
|
86
|
+
body: { text },
|
|
87
|
+
...(replyToId ? { reply_to: replyToId } : {}),
|
|
88
|
+
});
|
|
89
|
+
return {
|
|
90
|
+
channel: "msg9",
|
|
91
|
+
messageId: response.message_id,
|
|
92
|
+
};
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
gateway: {
|
|
96
|
+
startAccount: async (ctx) => {
|
|
97
|
+
const { account, abortSignal, log } = ctx;
|
|
98
|
+
log?.info(`[msg9:${account.accountId}] Starting gateway`);
|
|
99
|
+
await startGateway({
|
|
100
|
+
account,
|
|
101
|
+
abortSignal,
|
|
102
|
+
cfg: ctx.cfg,
|
|
103
|
+
log,
|
|
104
|
+
onReady: () => {
|
|
105
|
+
log?.info(`[msg9:${account.accountId}] Gateway ready`);
|
|
106
|
+
ctx.setStatus({
|
|
107
|
+
...ctx.getStatus(),
|
|
108
|
+
running: true,
|
|
109
|
+
connected: true,
|
|
110
|
+
lastConnectedAt: Date.now(),
|
|
111
|
+
});
|
|
112
|
+
},
|
|
113
|
+
onError: (error) => {
|
|
114
|
+
log?.error(`[msg9:${account.accountId}] Gateway error: ${error.message}`);
|
|
115
|
+
ctx.setStatus({
|
|
116
|
+
...ctx.getStatus(),
|
|
117
|
+
lastError: error.message,
|
|
118
|
+
});
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
status: {
|
|
124
|
+
defaultRuntime: {
|
|
125
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
126
|
+
running: false,
|
|
127
|
+
connected: false,
|
|
128
|
+
lastConnectedAt: null,
|
|
129
|
+
lastError: null,
|
|
130
|
+
},
|
|
131
|
+
buildAccountSnapshot: ({ account, runtime }) => ({
|
|
132
|
+
accountId: account?.accountId ?? DEFAULT_ACCOUNT_ID,
|
|
133
|
+
name: account?.name,
|
|
134
|
+
enabled: account?.enabled ?? false,
|
|
135
|
+
configured: isMsg9AccountConfigured(account),
|
|
136
|
+
running: runtime?.running ?? false,
|
|
137
|
+
connected: runtime?.connected ?? false,
|
|
138
|
+
lastConnectedAt: runtime?.lastConnectedAt ?? null,
|
|
139
|
+
lastError: runtime?.lastError ?? null,
|
|
140
|
+
}),
|
|
141
|
+
},
|
|
142
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Message, SendMessageOptions, SendResponse } from "msg9-io";
|
|
2
|
+
import type { ResolvedMsg9Account } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* The msg9 agent surface this plugin relies on.
|
|
5
|
+
*
|
|
6
|
+
* Factory return type doubles as the test seam: gateway and outbound both
|
|
7
|
+
* construct clients through {@link createMsg9Agent}, so tests stub the factory
|
|
8
|
+
* and never touch the network.
|
|
9
|
+
*/
|
|
10
|
+
export interface Msg9AgentClient {
|
|
11
|
+
unread(): AsyncGenerator<Message>;
|
|
12
|
+
send(options: SendMessageOptions): Promise<SendResponse>;
|
|
13
|
+
markRead(id: string): Promise<void>;
|
|
14
|
+
markProcessed(id: string): Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
export declare function createMsg9Agent(account: ResolvedMsg9Account): Msg9AgentClient;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { Agent } from "msg9-io";
|
|
2
|
+
const DEFAULT_BASE_URL = "https://api.msg9.io";
|
|
3
|
+
export function createMsg9Agent(account) {
|
|
4
|
+
const agent = new Agent({
|
|
5
|
+
address: account.address,
|
|
6
|
+
apiKey: account.apiKey,
|
|
7
|
+
...(account.baseUrl ? { baseUrl: account.baseUrl } : {}),
|
|
8
|
+
});
|
|
9
|
+
const baseUrl = (account.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
10
|
+
async function postInboxState(suffix) {
|
|
11
|
+
const response = await fetch(`${baseUrl}/api/v1/inbox/messages/${encodeURIComponent(suffix)}`, {
|
|
12
|
+
method: "POST",
|
|
13
|
+
headers: { Authorization: `Bearer ${account.apiKey}` },
|
|
14
|
+
});
|
|
15
|
+
if (!response.ok) {
|
|
16
|
+
throw new Error(`msg9 inbox state update failed: ${response.status} ${response.statusText}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
unread: () => agent.unread(),
|
|
21
|
+
send: (options) => agent.send(options),
|
|
22
|
+
markRead: typeof agent.markRead === "function"
|
|
23
|
+
? (id) => agent.markRead(id, "agent")
|
|
24
|
+
: (id) => postInboxState(`${id}/read`),
|
|
25
|
+
markProcessed: typeof agent.markProcessed === "function"
|
|
26
|
+
? (id) => agent.markProcessed(id, "agent")
|
|
27
|
+
: (id) => postInboxState(`${id}/processed`),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { ResolvedMsg9Account, Msg9ChannelConfig } from "./types.js";
|
|
2
|
+
export declare const DEFAULT_POLL_INTERVAL_MS = 3000;
|
|
3
|
+
interface OpenClawConfig {
|
|
4
|
+
channels?: {
|
|
5
|
+
msg9?: Msg9ChannelConfig;
|
|
6
|
+
[key: string]: unknown;
|
|
7
|
+
};
|
|
8
|
+
[key: string]: unknown;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Environment variable fallbacks (default account only).
|
|
12
|
+
*/
|
|
13
|
+
export declare function readMsg9EnvFallbacks(): {
|
|
14
|
+
address?: string;
|
|
15
|
+
apiKey?: string;
|
|
16
|
+
baseUrl?: string;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* List all configured msg9 account IDs.
|
|
20
|
+
*/
|
|
21
|
+
export declare function listMsg9AccountIds(cfg: OpenClawConfig): string[];
|
|
22
|
+
/**
|
|
23
|
+
* Resolve a msg9 account from config.
|
|
24
|
+
*
|
|
25
|
+
* Environment variable fallbacks (MSG9_ADDRESS, MSG9_API_KEY, MSG9_BASE_URL)
|
|
26
|
+
* apply to the default account only, matching other channels' conventions.
|
|
27
|
+
*/
|
|
28
|
+
export declare function resolveMsg9Account(cfg: OpenClawConfig, accountId?: string | null): ResolvedMsg9Account;
|
|
29
|
+
/**
|
|
30
|
+
* Whether a resolved account has the minimum credentials to run.
|
|
31
|
+
*/
|
|
32
|
+
export declare function isMsg9AccountConfigured(account: ResolvedMsg9Account | null | undefined): boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Apply account configuration changes (immutable — returns a new cfg).
|
|
35
|
+
*/
|
|
36
|
+
export declare function applyMsg9AccountConfig(cfg: OpenClawConfig, accountId: string, input: {
|
|
37
|
+
address?: string;
|
|
38
|
+
apiKey?: string;
|
|
39
|
+
baseUrl?: string;
|
|
40
|
+
name?: string;
|
|
41
|
+
}): OpenClawConfig;
|
|
42
|
+
export {};
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
const DEFAULT_ACCOUNT_ID = "default";
|
|
2
|
+
export const DEFAULT_POLL_INTERVAL_MS = 3000;
|
|
3
|
+
function isAccountPopulated(config) {
|
|
4
|
+
return Boolean(config?.address && config?.apiKey);
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Environment variable fallbacks (default account only).
|
|
8
|
+
*/
|
|
9
|
+
export function readMsg9EnvFallbacks() {
|
|
10
|
+
return {
|
|
11
|
+
address: process.env.MSG9_ADDRESS || undefined,
|
|
12
|
+
apiKey: process.env.MSG9_API_KEY || undefined,
|
|
13
|
+
baseUrl: process.env.MSG9_BASE_URL || undefined,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* List all configured msg9 account IDs.
|
|
18
|
+
*/
|
|
19
|
+
export function listMsg9AccountIds(cfg) {
|
|
20
|
+
const ids = new Set();
|
|
21
|
+
const msg9 = cfg.channels?.msg9;
|
|
22
|
+
if (isAccountPopulated(msg9)) {
|
|
23
|
+
ids.add(DEFAULT_ACCOUNT_ID);
|
|
24
|
+
}
|
|
25
|
+
if (msg9?.accounts) {
|
|
26
|
+
for (const accountId of Object.keys(msg9.accounts)) {
|
|
27
|
+
if (isAccountPopulated(msg9.accounts[accountId])) {
|
|
28
|
+
ids.add(accountId);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return Array.from(ids);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Resolve a msg9 account from config.
|
|
36
|
+
*
|
|
37
|
+
* Environment variable fallbacks (MSG9_ADDRESS, MSG9_API_KEY, MSG9_BASE_URL)
|
|
38
|
+
* apply to the default account only, matching other channels' conventions.
|
|
39
|
+
*/
|
|
40
|
+
export function resolveMsg9Account(cfg, accountId) {
|
|
41
|
+
const resolvedAccountId = accountId ?? DEFAULT_ACCOUNT_ID;
|
|
42
|
+
const msg9 = cfg.channels?.msg9;
|
|
43
|
+
let accountConfig;
|
|
44
|
+
if (resolvedAccountId === DEFAULT_ACCOUNT_ID) {
|
|
45
|
+
accountConfig = {
|
|
46
|
+
enabled: msg9?.enabled,
|
|
47
|
+
name: msg9?.name,
|
|
48
|
+
address: msg9?.address,
|
|
49
|
+
apiKey: msg9?.apiKey,
|
|
50
|
+
baseUrl: msg9?.baseUrl,
|
|
51
|
+
pollIntervalMs: msg9?.pollIntervalMs,
|
|
52
|
+
allowFrom: msg9?.allowFrom,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
accountConfig = msg9?.accounts?.[resolvedAccountId] ?? {};
|
|
57
|
+
}
|
|
58
|
+
let address = accountConfig.address ?? "";
|
|
59
|
+
let apiKey = accountConfig.apiKey ?? "";
|
|
60
|
+
let baseUrl = accountConfig.baseUrl;
|
|
61
|
+
if (resolvedAccountId === DEFAULT_ACCOUNT_ID) {
|
|
62
|
+
const env = readMsg9EnvFallbacks();
|
|
63
|
+
if (!address && env.address) {
|
|
64
|
+
address = env.address;
|
|
65
|
+
}
|
|
66
|
+
if (!apiKey && env.apiKey) {
|
|
67
|
+
apiKey = env.apiKey;
|
|
68
|
+
}
|
|
69
|
+
if (!baseUrl && env.baseUrl) {
|
|
70
|
+
baseUrl = env.baseUrl;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
accountId: resolvedAccountId,
|
|
75
|
+
name: accountConfig.name,
|
|
76
|
+
enabled: accountConfig.enabled !== false,
|
|
77
|
+
address,
|
|
78
|
+
apiKey,
|
|
79
|
+
baseUrl,
|
|
80
|
+
pollIntervalMs: accountConfig.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS,
|
|
81
|
+
allowFrom: accountConfig.allowFrom,
|
|
82
|
+
config: accountConfig,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Whether a resolved account has the minimum credentials to run.
|
|
87
|
+
*/
|
|
88
|
+
export function isMsg9AccountConfigured(account) {
|
|
89
|
+
return Boolean(account?.address && account?.apiKey);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Apply account configuration changes (immutable — returns a new cfg).
|
|
93
|
+
*/
|
|
94
|
+
export function applyMsg9AccountConfig(cfg, accountId, input) {
|
|
95
|
+
const next = { ...cfg };
|
|
96
|
+
const msg9 = next.channels?.msg9;
|
|
97
|
+
const patch = {
|
|
98
|
+
enabled: true,
|
|
99
|
+
...(input.address ? { address: input.address } : {}),
|
|
100
|
+
...(input.apiKey ? { apiKey: input.apiKey } : {}),
|
|
101
|
+
...(input.baseUrl ? { baseUrl: input.baseUrl } : {}),
|
|
102
|
+
...(input.name ? { name: input.name } : {}),
|
|
103
|
+
};
|
|
104
|
+
if (accountId === DEFAULT_ACCOUNT_ID) {
|
|
105
|
+
next.channels = {
|
|
106
|
+
...next.channels,
|
|
107
|
+
msg9: {
|
|
108
|
+
...msg9,
|
|
109
|
+
...patch,
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
next.channels = {
|
|
115
|
+
...next.channels,
|
|
116
|
+
msg9: {
|
|
117
|
+
...msg9,
|
|
118
|
+
enabled: true,
|
|
119
|
+
accounts: {
|
|
120
|
+
...msg9?.accounts,
|
|
121
|
+
[accountId]: {
|
|
122
|
+
...msg9?.accounts?.[accountId],
|
|
123
|
+
...patch,
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
return next;
|
|
130
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
|
|
2
|
+
import type { ResolvedMsg9Account } from "./types.js";
|
|
3
|
+
import { getMsg9Runtime } from "./runtime.js";
|
|
4
|
+
export interface GatewayContext {
|
|
5
|
+
account: ResolvedMsg9Account;
|
|
6
|
+
abortSignal: AbortSignal;
|
|
7
|
+
cfg: OpenClawConfig;
|
|
8
|
+
onReady?: (data: unknown) => void;
|
|
9
|
+
onError?: (error: Error) => void;
|
|
10
|
+
log?: {
|
|
11
|
+
info: (msg: string) => void;
|
|
12
|
+
error: (msg: string) => void;
|
|
13
|
+
debug?: (msg: string) => void;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export declare function resolveInboundCommandAuthorization(params: {
|
|
17
|
+
pluginRuntime: ReturnType<typeof getMsg9Runtime>;
|
|
18
|
+
cfg: OpenClawConfig;
|
|
19
|
+
allowFrom?: string[];
|
|
20
|
+
peerId: string;
|
|
21
|
+
}): boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Poll the msg9 inbox until the abort signal fires.
|
|
24
|
+
*
|
|
25
|
+
* Each sweep drains `agent.unread()` fully. Every message is deduped by
|
|
26
|
+
* message_id, allowFrom-filtered (non-listed senders are marked read and
|
|
27
|
+
* skipped), then marked processed BEFORE dispatch so the agent is driven
|
|
28
|
+
* at-most-once even if dispatch or delivery fails.
|
|
29
|
+
*/
|
|
30
|
+
export declare function startGateway(ctx: GatewayContext): Promise<void>;
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { getMsg9Runtime } from "./runtime.js";
|
|
2
|
+
import { createMsg9Agent } from "./client.js";
|
|
3
|
+
// Inbound dedupe — the platform delivers at-least-once, so replays within a
|
|
4
|
+
// gateway run are dropped by message_id. The set is bounded and reset when full.
|
|
5
|
+
const SEEN_MESSAGE_IDS_MAX = 1000;
|
|
6
|
+
export function resolveInboundCommandAuthorization(params) {
|
|
7
|
+
const { pluginRuntime, cfg, allowFrom, peerId } = params;
|
|
8
|
+
const hasAllowFrom = Array.isArray(allowFrom) && allowFrom.length > 0;
|
|
9
|
+
const senderAllowedForCommands = hasAllowFrom && allowFrom.includes(peerId);
|
|
10
|
+
const resolveCommandAuthorized = pluginRuntime.channel.commands?.resolveCommandAuthorizedFromAuthorizers;
|
|
11
|
+
if (typeof resolveCommandAuthorized !== "function") {
|
|
12
|
+
return senderAllowedForCommands;
|
|
13
|
+
}
|
|
14
|
+
// Access groups default on in current SDKs; older gateways exposed the
|
|
15
|
+
// switch as cfg.commands.useAccessGroups, so honor it when present.
|
|
16
|
+
const commandsCfg = cfg.commands;
|
|
17
|
+
const useAccessGroups = commandsCfg?.useAccessGroups ?? true;
|
|
18
|
+
return resolveCommandAuthorized({
|
|
19
|
+
useAccessGroups,
|
|
20
|
+
authorizers: [
|
|
21
|
+
{
|
|
22
|
+
configured: hasAllowFrom,
|
|
23
|
+
allowed: senderAllowedForCommands,
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
modeWhenAccessGroupsOff: hasAllowFrom ? "configured" : "deny",
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
function sleepAbortable(ms, abortSignal) {
|
|
30
|
+
return new Promise((resolve) => {
|
|
31
|
+
if (abortSignal.aborted) {
|
|
32
|
+
resolve();
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const timer = setTimeout(() => {
|
|
36
|
+
abortSignal.removeEventListener("abort", onAbort);
|
|
37
|
+
resolve();
|
|
38
|
+
}, ms);
|
|
39
|
+
const onAbort = () => {
|
|
40
|
+
clearTimeout(timer);
|
|
41
|
+
resolve();
|
|
42
|
+
};
|
|
43
|
+
abortSignal.addEventListener("abort", onAbort, { once: true });
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Formats the inbound prompt envelope. SDKs around 2026.4 exposed
|
|
48
|
+
* `formatInboundEnvelope` on the reply runtime; current SDKs renamed it to
|
|
49
|
+
* `formatAgentEnvelope` with the same envelope fields. Prefer the current
|
|
50
|
+
* name and fall back for older hosts; if neither exists, pass the raw body
|
|
51
|
+
* through so dispatch still works.
|
|
52
|
+
*/
|
|
53
|
+
function formatInboundEnvelope(pluginRuntime, params) {
|
|
54
|
+
const reply = pluginRuntime.channel.reply;
|
|
55
|
+
const format = reply.formatAgentEnvelope ?? reply.formatInboundEnvelope;
|
|
56
|
+
if (typeof format !== "function") {
|
|
57
|
+
return params.body;
|
|
58
|
+
}
|
|
59
|
+
return format(params);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Poll the msg9 inbox until the abort signal fires.
|
|
63
|
+
*
|
|
64
|
+
* Each sweep drains `agent.unread()` fully. Every message is deduped by
|
|
65
|
+
* message_id, allowFrom-filtered (non-listed senders are marked read and
|
|
66
|
+
* skipped), then marked processed BEFORE dispatch so the agent is driven
|
|
67
|
+
* at-most-once even if dispatch or delivery fails.
|
|
68
|
+
*/
|
|
69
|
+
export async function startGateway(ctx) {
|
|
70
|
+
const { account, abortSignal, cfg, onReady, onError, log } = ctx;
|
|
71
|
+
if (!account.address || !account.apiKey) {
|
|
72
|
+
throw new Error("msg9 not configured (missing address/apiKey)");
|
|
73
|
+
}
|
|
74
|
+
let aborted = false;
|
|
75
|
+
abortSignal.addEventListener("abort", () => {
|
|
76
|
+
aborted = true;
|
|
77
|
+
}, { once: true });
|
|
78
|
+
const agent = createMsg9Agent(account);
|
|
79
|
+
const seenMessageIds = new Set();
|
|
80
|
+
onReady?.({});
|
|
81
|
+
while (!aborted) {
|
|
82
|
+
try {
|
|
83
|
+
for await (const message of agent.unread()) {
|
|
84
|
+
if (aborted)
|
|
85
|
+
break;
|
|
86
|
+
const messageId = message.message_id ?? message.id;
|
|
87
|
+
if (seenMessageIds.has(messageId)) {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (seenMessageIds.size >= SEEN_MESSAGE_IDS_MAX) {
|
|
91
|
+
seenMessageIds.clear();
|
|
92
|
+
}
|
|
93
|
+
seenMessageIds.add(messageId);
|
|
94
|
+
const from = message.from_address;
|
|
95
|
+
// allowFrom check — blocked senders stay visible in the owner's
|
|
96
|
+
// unprocessed folder but never reach the agent.
|
|
97
|
+
if (account.allowFrom && account.allowFrom.length > 0 && !account.allowFrom.includes(from)) {
|
|
98
|
+
log?.info(`[msg9:${account.accountId}] Ignoring message from unlisted ${from}`);
|
|
99
|
+
await agent.markRead(messageId);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
log?.info(`[msg9:${account.accountId}] Message from ${from} msg=${messageId}: ${(message.body?.text ?? "").slice(0, 100)}`);
|
|
103
|
+
// Mark processed first: implies read, is idempotent, and matches the
|
|
104
|
+
// server-side auto-close that happens when a reply goes out.
|
|
105
|
+
await agent.markProcessed(messageId);
|
|
106
|
+
await dispatchMessage({ message, messageId, from, account, cfg, agent, log });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
log?.error(`[msg9:${account.accountId}] Poll sweep failed: ${err}`);
|
|
111
|
+
onError?.(err instanceof Error ? err : new Error(String(err)));
|
|
112
|
+
}
|
|
113
|
+
await sleepAbortable(account.pollIntervalMs, abortSignal);
|
|
114
|
+
}
|
|
115
|
+
// Do not return until the host stops this account.
|
|
116
|
+
if (abortSignal.aborted) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
await new Promise((resolve) => {
|
|
120
|
+
abortSignal.addEventListener("abort", () => resolve(), { once: true });
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
async function dispatchMessage(params) {
|
|
124
|
+
const { message, messageId, from, account, cfg, agent, log } = params;
|
|
125
|
+
const pluginRuntime = getMsg9Runtime();
|
|
126
|
+
pluginRuntime.channel.activity.record({
|
|
127
|
+
channel: "msg9",
|
|
128
|
+
accountId: account.accountId,
|
|
129
|
+
direction: "inbound",
|
|
130
|
+
});
|
|
131
|
+
const route = pluginRuntime.channel.routing.resolveAgentRoute({
|
|
132
|
+
cfg,
|
|
133
|
+
channel: "msg9",
|
|
134
|
+
accountId: account.accountId,
|
|
135
|
+
peer: {
|
|
136
|
+
kind: "direct",
|
|
137
|
+
id: from,
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
const bodyText = message.body?.text ?? "";
|
|
141
|
+
const timestamp = Date.parse(message.created_at);
|
|
142
|
+
const body = formatInboundEnvelope(pluginRuntime, {
|
|
143
|
+
channel: "Msg9",
|
|
144
|
+
from,
|
|
145
|
+
timestamp,
|
|
146
|
+
body: bodyText,
|
|
147
|
+
});
|
|
148
|
+
const commandAuthorized = resolveInboundCommandAuthorization({
|
|
149
|
+
pluginRuntime,
|
|
150
|
+
cfg,
|
|
151
|
+
allowFrom: account.allowFrom,
|
|
152
|
+
peerId: from,
|
|
153
|
+
});
|
|
154
|
+
const ctxPayload = pluginRuntime.channel.reply.finalizeInboundContext({
|
|
155
|
+
Body: body,
|
|
156
|
+
RawBody: bodyText,
|
|
157
|
+
From: `msg9:${from}`,
|
|
158
|
+
To: account.address,
|
|
159
|
+
SessionKey: route.sessionKey,
|
|
160
|
+
AccountId: route.accountId,
|
|
161
|
+
ChatType: "direct",
|
|
162
|
+
SenderId: from,
|
|
163
|
+
SenderName: from,
|
|
164
|
+
Provider: "msg9",
|
|
165
|
+
Surface: "msg9",
|
|
166
|
+
MessageSid: messageId,
|
|
167
|
+
Timestamp: timestamp,
|
|
168
|
+
CommandAuthorized: commandAuthorized,
|
|
169
|
+
CommandSource: "text",
|
|
170
|
+
OriginatingChannel: "msg9",
|
|
171
|
+
OriginatingTo: account.address,
|
|
172
|
+
});
|
|
173
|
+
log?.info(`[msg9:${account.accountId}] ctxPayload: From=msg9:${from}, To=${account.address}, SessionKey=${route.sessionKey}, MessageSid=${messageId}`);
|
|
174
|
+
const messagesConfig = pluginRuntime.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId);
|
|
175
|
+
try {
|
|
176
|
+
await pluginRuntime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
177
|
+
ctx: ctxPayload,
|
|
178
|
+
cfg,
|
|
179
|
+
dispatcherOptions: {
|
|
180
|
+
responsePrefix: messagesConfig.responsePrefix,
|
|
181
|
+
deliver: async (payload) => {
|
|
182
|
+
if (!payload.text)
|
|
183
|
+
return;
|
|
184
|
+
await agent.send({
|
|
185
|
+
to: from,
|
|
186
|
+
body: { text: payload.text },
|
|
187
|
+
reply_to: messageId,
|
|
188
|
+
});
|
|
189
|
+
pluginRuntime.channel.activity.record({
|
|
190
|
+
channel: "msg9",
|
|
191
|
+
accountId: account.accountId,
|
|
192
|
+
direction: "outbound",
|
|
193
|
+
});
|
|
194
|
+
},
|
|
195
|
+
onError: async (err) => {
|
|
196
|
+
log?.error(`[msg9:${account.accountId}] Dispatch error: ${err}`);
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
replyOptions: {},
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
catch (err) {
|
|
203
|
+
log?.error(`[msg9:${account.accountId}] Message processing failed: ${err}`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
|
|
2
|
+
export declare const setMsg9Runtime: (next: PluginRuntime) => void;
|
|
3
|
+
export declare const clearMsg9Runtime: () => void;
|
|
4
|
+
export declare const tryGetMsg9Runtime: () => PluginRuntime | null;
|
|
5
|
+
export declare const getMsg9Runtime: () => PluginRuntime;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
|
|
2
|
+
const runtimeStore = createPluginRuntimeStore("msg9 runtime not initialized");
|
|
3
|
+
export const setMsg9Runtime = runtimeStore.setRuntime;
|
|
4
|
+
export const clearMsg9Runtime = runtimeStore.clearRuntime;
|
|
5
|
+
export const tryGetMsg9Runtime = runtimeStore.tryGetRuntime;
|
|
6
|
+
export const getMsg9Runtime = runtimeStore.getRuntime;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* msg9 account configuration (raw config values)
|
|
3
|
+
*/
|
|
4
|
+
export interface Msg9AccountConfig {
|
|
5
|
+
enabled?: boolean;
|
|
6
|
+
name?: string;
|
|
7
|
+
/** msg9 agent address, e.g. my-project@kimi.ice.msg9.io */
|
|
8
|
+
address?: string;
|
|
9
|
+
/** Agent API key (msg9_sk_...); treat as a secret */
|
|
10
|
+
apiKey?: string;
|
|
11
|
+
/** msg9 API base URL (defaults to https://api.msg9.io) */
|
|
12
|
+
baseUrl?: string;
|
|
13
|
+
/** Inbox poll interval in milliseconds (default 3000) */
|
|
14
|
+
pollIntervalMs?: number;
|
|
15
|
+
/** Whitelist of allowed sender addresses; empty/undefined allows all */
|
|
16
|
+
allowFrom?: string[];
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Resolved msg9 account (ready to use)
|
|
20
|
+
*/
|
|
21
|
+
export interface ResolvedMsg9Account {
|
|
22
|
+
accountId: string;
|
|
23
|
+
name?: string;
|
|
24
|
+
enabled: boolean;
|
|
25
|
+
address: string;
|
|
26
|
+
apiKey: string;
|
|
27
|
+
baseUrl?: string;
|
|
28
|
+
pollIntervalMs: number;
|
|
29
|
+
allowFrom?: string[];
|
|
30
|
+
config: Msg9AccountConfig;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* msg9 channel config section (top-level account + named sub-accounts)
|
|
34
|
+
*/
|
|
35
|
+
export interface Msg9ChannelConfig extends Msg9AccountConfig {
|
|
36
|
+
accounts?: Record<string, Msg9AccountConfig>;
|
|
37
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "openclaw-msg9",
|
|
3
|
+
"name": "msg9",
|
|
4
|
+
"description": "msg9.io channel plugin — turn a msg9 agent inbox into an OpenClaw channel",
|
|
5
|
+
"version": "0.1.0",
|
|
6
|
+
"channels": ["msg9"],
|
|
7
|
+
"channelEnvVars": {
|
|
8
|
+
"msg9": [
|
|
9
|
+
"MSG9_ADDRESS",
|
|
10
|
+
"MSG9_API_KEY",
|
|
11
|
+
"MSG9_BASE_URL"
|
|
12
|
+
]
|
|
13
|
+
},
|
|
14
|
+
"configSchema": {
|
|
15
|
+
"type": "object",
|
|
16
|
+
"properties": {},
|
|
17
|
+
"additionalProperties": false
|
|
18
|
+
},
|
|
19
|
+
"channelConfigs": {
|
|
20
|
+
"msg9": {
|
|
21
|
+
"label": "msg9",
|
|
22
|
+
"description": "msg9.io agent inbox settings",
|
|
23
|
+
"schema": {
|
|
24
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
25
|
+
"type": "object",
|
|
26
|
+
"properties": {
|
|
27
|
+
"enabled": {
|
|
28
|
+
"type": "boolean",
|
|
29
|
+
"default": true
|
|
30
|
+
},
|
|
31
|
+
"name": {
|
|
32
|
+
"type": "string",
|
|
33
|
+
"description": "Display name for this account"
|
|
34
|
+
},
|
|
35
|
+
"address": {
|
|
36
|
+
"type": "string",
|
|
37
|
+
"description": "msg9 agent address, e.g. my-project@kimi.ice.msg9.io"
|
|
38
|
+
},
|
|
39
|
+
"apiKey": {
|
|
40
|
+
"type": "string",
|
|
41
|
+
"description": "Agent API key (msg9_sk_...); treat as a secret"
|
|
42
|
+
},
|
|
43
|
+
"baseUrl": {
|
|
44
|
+
"type": "string",
|
|
45
|
+
"description": "msg9 API base URL (defaults to https://api.msg9.io)"
|
|
46
|
+
},
|
|
47
|
+
"pollIntervalMs": {
|
|
48
|
+
"type": "integer",
|
|
49
|
+
"minimum": 250,
|
|
50
|
+
"description": "Inbox poll interval in milliseconds (default 3000)"
|
|
51
|
+
},
|
|
52
|
+
"allowFrom": {
|
|
53
|
+
"type": "array",
|
|
54
|
+
"items": { "type": "string" },
|
|
55
|
+
"description": "Allowed sender addresses (full msg9 addresses). Configure this for trusted senders before enabling text commands. Empty = allow all."
|
|
56
|
+
},
|
|
57
|
+
"accounts": {
|
|
58
|
+
"type": "object",
|
|
59
|
+
"description": "Additional msg9 accounts",
|
|
60
|
+
"additionalProperties": {
|
|
61
|
+
"type": "object",
|
|
62
|
+
"properties": {
|
|
63
|
+
"enabled": { "type": "boolean" },
|
|
64
|
+
"name": { "type": "string" },
|
|
65
|
+
"address": {
|
|
66
|
+
"type": "string",
|
|
67
|
+
"description": "msg9 agent address, e.g. my-project@kimi.ice.msg9.io"
|
|
68
|
+
},
|
|
69
|
+
"apiKey": {
|
|
70
|
+
"type": "string",
|
|
71
|
+
"description": "Agent API key (msg9_sk_...); treat as a secret"
|
|
72
|
+
},
|
|
73
|
+
"baseUrl": {
|
|
74
|
+
"type": "string",
|
|
75
|
+
"description": "msg9 API base URL (defaults to https://api.msg9.io)"
|
|
76
|
+
},
|
|
77
|
+
"pollIntervalMs": {
|
|
78
|
+
"type": "integer",
|
|
79
|
+
"minimum": 250,
|
|
80
|
+
"description": "Inbox poll interval in milliseconds (default 3000)"
|
|
81
|
+
},
|
|
82
|
+
"allowFrom": {
|
|
83
|
+
"type": "array",
|
|
84
|
+
"items": { "type": "string" },
|
|
85
|
+
"description": "Allowed sender addresses (full msg9 addresses). Empty = allow all."
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
"additionalProperties": false
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
"additionalProperties": false
|
|
93
|
+
},
|
|
94
|
+
"uiHints": {
|
|
95
|
+
"address": {
|
|
96
|
+
"label": "Agent address",
|
|
97
|
+
"placeholder": "my-project@kimi.ice.msg9.io"
|
|
98
|
+
},
|
|
99
|
+
"apiKey": {
|
|
100
|
+
"label": "API key",
|
|
101
|
+
"sensitive": true
|
|
102
|
+
},
|
|
103
|
+
"baseUrl": {
|
|
104
|
+
"label": "API base URL",
|
|
105
|
+
"placeholder": "https://api.msg9.io"
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "openclaw-msg9",
|
|
3
|
+
"displayName": "OpenClaw msg9",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"description": "OpenClaw msg9.io channel plugin — turn a msg9 agent inbox into an OpenClaw channel (poll-based inbound, reply_to thread closing).",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "dist/index.js",
|
|
8
|
+
"types": "dist/index.d.ts",
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"openclaw.plugin.json"
|
|
12
|
+
],
|
|
13
|
+
"author": "msg9.io",
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"openclaw": {
|
|
16
|
+
"extensions": [
|
|
17
|
+
"./dist/index.js"
|
|
18
|
+
],
|
|
19
|
+
"setupEntry": "./dist/setup-entry.js",
|
|
20
|
+
"compat": {
|
|
21
|
+
"pluginApi": ">=2026.3.23-1"
|
|
22
|
+
},
|
|
23
|
+
"build": {
|
|
24
|
+
"openclawVersion": "2026.4.26",
|
|
25
|
+
"pluginSdkVersion": "2026.4.26"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "tsc",
|
|
30
|
+
"test": "vitest run",
|
|
31
|
+
"test:watch": "vitest",
|
|
32
|
+
"pack:dry-run": "npm pack --dry-run"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"msg9-io": "^0.10.0"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@types/node": "^20.0.0",
|
|
39
|
+
"openclaw": "^2026.4.26",
|
|
40
|
+
"typescript": "^5.0.0",
|
|
41
|
+
"vitest": "^2.1.9"
|
|
42
|
+
}
|
|
43
|
+
}
|