@minaim/openclaw-channel 0.0.0-stage → 0.2.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/README.md +50 -2
- package/bridge.mjs +92 -0
- package/diagnostics.mjs +47 -0
- package/index.mjs +128 -0
- package/openclaw.plugin.json +25 -0
- package/package.json +55 -4
- package/setup.mjs +37 -0
- package/vendor/__init__.py +0 -0
- package/vendor/compatibility.json +7 -0
- package/vendor/mina_agent.py +363 -0
- package/vendor/mina_media.py +172 -0
- package/vendor/mina_native.py +293 -0
- package/vendor/mina_setup.py +133 -0
package/README.md
CHANGED
|
@@ -1,3 +1,51 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Mina OpenClaw Channel
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Mina IM 的 OpenClaw 原生渠道插件,连接你自行部署和运行的 AI Agent。Mina 不提供默认模型或模型密钥。
|
|
4
|
+
|
|
5
|
+
当前为公开预览版,支持助手所有者的文本、图片和文件私聊。群 Agent 调用尚未开放;真实云服务器、Mina 服务和手机的端到端验收尚未完成。
|
|
6
|
+
|
|
7
|
+
## 安装
|
|
8
|
+
|
|
9
|
+
发布到 npm 后,使用以下不带版本号的命令安装官方 registry 的最新发布版:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
openclaw plugins install npm:@minaim/openclaw-channel
|
|
13
|
+
openclaw channels add mina
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
安装时按 OpenClaw 提示审阅来源及能力。用户安装公开包不需要 npm 账号。一个 account 对应一个 Mina AI 助手,可配置多个 account。
|
|
17
|
+
|
|
18
|
+
## 配置
|
|
19
|
+
|
|
20
|
+
在 Mina App“我的 → AI 助手”创建助手,向导中输入 HTTPS 网关、AppID、助手 ID、所有者 ID、凭证版本和一次性 AppSecret。密钥通过隐藏输入保存至私有文件,不写入 OpenClaw 普通配置。宿主只保存配置路径;已有不同绑定不覆盖。
|
|
21
|
+
|
|
22
|
+
启动或重启 Gateway 后在 App 检查连接。配置探测是本地被动检查,不表示已联网。Mina 长轮询只需出站 HTTPS。同一助手同时只允许一个有效消费者,插件不会自动抢占另一个实例。
|
|
23
|
+
|
|
24
|
+
## 更新
|
|
25
|
+
|
|
26
|
+
更新前停止当前 Gateway,再执行:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
openclaw plugins update mina
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
完成后重新启动 Gateway。用户不需要填写版本号;npm 更新跟随 `latest` 发布标签,Gitee 的代码改动需重新发布 npm 才会进入该更新渠道。保留原来的私有配置、SQLite 和待提交文件,不通过删除状态库重试任务。活动执行的热升级与自动回滚尚未提供。
|
|
33
|
+
|
|
34
|
+
## 环境
|
|
35
|
+
|
|
36
|
+
- 已验证 OpenClaw 2026.10.5;Node >=24.16.0 <25 或 >=26.1.0。
|
|
37
|
+
- Python >=3.9,使用标准库持久工作进程。npm 包已包含 Python 源码,但服务器仍需提供 Python 运行时。
|
|
38
|
+
- Linux/macOS 本地磁盘;实际宿主测试在 Linux 完成。
|
|
39
|
+
- 私有目录 0700、配置文件 0600;容器中保留整个宿主 state 持久卷。
|
|
40
|
+
|
|
41
|
+
宿主版本与运行时检查不因更新到最新插件而取消。不要把本插件当作模型或 Agent 托管服务。
|
|
42
|
+
|
|
43
|
+
## 图片和文档
|
|
44
|
+
|
|
45
|
+
支持 PNG/JPEG/GIF,单张最大 8 MiB;文件最大 20 MiB;最终回复最多 5 个附件。TXT、PDF、Office 文档传实际字节,解析、OCR 和编辑取决于用户宿主的模型与工具。输出文件目录和远程域名需由部署者显式授权,不从聊天正文推导上传权限。不支持流式、语音、视频和主动发信。
|
|
46
|
+
|
|
47
|
+
## 可靠性与许可
|
|
48
|
+
|
|
49
|
+
消息先可靠接收,再申请执行许可;未知执行不自动重跑,已保存结果失败只补交。诊断输出固定错误码,不含密钥、对话正文或完整附件能力链接。
|
|
50
|
+
|
|
51
|
+
维护与版本记录:[Mina Agent 插件仓库](https://gitee.com/Mina-IM/mina-agent-plugins)。当前仓库为私有测试仓库,查看源码与问题页需要授权;npm 包本身公开可下载。此包标记为 UNLICENSED,公开分发不授予开源许可证。
|
package/bridge.mjs
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import {spawn} from 'node:child_process';
|
|
2
|
+
import {isAbsolute} from 'node:path';
|
|
3
|
+
import {fileURLToPath} from 'node:url';
|
|
4
|
+
import {localError,safeStatus} from './diagnostics.mjs';
|
|
5
|
+
|
|
6
|
+
const limit=131072;
|
|
7
|
+
const fail=()=>localError('worker_stopped');
|
|
8
|
+
export class NativeWorker {
|
|
9
|
+
constructor({configFile,python='python3',signal,onReady,onStatus,execute}) {
|
|
10
|
+
if(!isAbsolute(configFile)||typeof python!=='string'||!python||python.includes('\0')) throw fail();
|
|
11
|
+
this.configFile=configFile; this.python=python; this.signal=signal;
|
|
12
|
+
this.onReady=onReady; this.onStatus=onStatus; this.execute=execute; this.child=null; this.stopping=false; this.controller=new AbortController();
|
|
13
|
+
}
|
|
14
|
+
stop() {
|
|
15
|
+
this.stopping=true;
|
|
16
|
+
this.controller.abort();
|
|
17
|
+
if(this.child && this.child.exitCode===null && this.child.signalCode===null) {
|
|
18
|
+
this.child.kill('SIGTERM');
|
|
19
|
+
const child=this.child;
|
|
20
|
+
this.killTimer=setTimeout(()=>{if(child.exitCode===null&&child.signalCode===null) child.kill('SIGKILL');},3000);
|
|
21
|
+
this.killTimer.unref();
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
async run() {
|
|
25
|
+
if(this.signal?.aborted) return;
|
|
26
|
+
const env=Object.fromEntries(Object.entries(process.env).filter(([key])=>
|
|
27
|
+
['PATH','HOME','SSL_CERT_FILE','SSL_CERT_DIR','HTTPS_PROXY','HTTP_PROXY','NO_PROXY'].includes(key)));
|
|
28
|
+
this.child=spawn(this.python,[fileURLToPath(new URL('./vendor/mina_native.py',import.meta.url)),
|
|
29
|
+
'--host','OpenClaw','--config',this.configFile],{stdio:['pipe','pipe','ignore'],env,shell:false});
|
|
30
|
+
const closed=new Promise(resolve=>{
|
|
31
|
+
this.child.once('error',error=>{this.failure=localError(error.code==='ENOENT'?'python_unavailable':'worker_stopped');this.controller.abort();resolve(false);});
|
|
32
|
+
this.child.once('close',code=>{this.controller.abort();resolve(code===0);});
|
|
33
|
+
});
|
|
34
|
+
// Avoid unhandled EPIPE if the lease worker exits while the host finishes a turn.
|
|
35
|
+
this.child.stdin.on('error',()=>this.stop());
|
|
36
|
+
this.signal?.addEventListener('abort',this.abort=()=>this.stop(),{once:true});
|
|
37
|
+
const readyTimer=setTimeout(()=>this.stop(),35000);
|
|
38
|
+
let ready=false, buffer=Buffer.alloc(0);
|
|
39
|
+
try {
|
|
40
|
+
for await (const chunk of this.child.stdout) {
|
|
41
|
+
buffer=Buffer.concat([buffer,chunk]);
|
|
42
|
+
let end;
|
|
43
|
+
while((end=buffer.indexOf(10))>=0) {
|
|
44
|
+
if(end+1>limit||this.stopping) throw fail();
|
|
45
|
+
const raw=buffer.subarray(0,end); buffer=buffer.subarray(end+1);
|
|
46
|
+
const frame=JSON.parse(raw.toString('utf8'));
|
|
47
|
+
if(frame?.type==='error'&&Object.keys(frame).sort().join(',')==='code,type')throw localError(frame.code);
|
|
48
|
+
if(ready&&frame?.type==='status'&&Object.keys(frame).sort().join(',')==='code,type'){await this.onStatus?.(safeStatus(frame.code));continue;}
|
|
49
|
+
if(!ready) {
|
|
50
|
+
if(frame.type!=='ready'||frame.protocol!==1||!/^bot_[a-f0-9]{32}$/.test(frame.bot_id)
|
|
51
|
+
||!/^mina_[a-f0-9]{32}$/.test(frame.app_id)||!/^[A-Za-z0-9_-]{1,128}$/.test(frame.owner_id)
|
|
52
|
+
||!Number.isSafeInteger(frame.credential_version)||frame.credential_version<1) throw fail();
|
|
53
|
+
ready=true; this.identity=Object.freeze(frame); clearTimeout(readyTimer);
|
|
54
|
+
await this.onReady?.(this.identity);
|
|
55
|
+
} else {
|
|
56
|
+
if(frame.type!=='execute'||Object.keys(frame).sort().join(',')!=='event,type') throw fail();
|
|
57
|
+
const signal=this.controller.signal;
|
|
58
|
+
let cancel;
|
|
59
|
+
const canceled=new Promise((_,reject)=>{
|
|
60
|
+
cancel=()=>reject(fail());
|
|
61
|
+
signal.addEventListener('abort',cancel,{once:true});
|
|
62
|
+
if(signal.aborted) cancel();
|
|
63
|
+
});
|
|
64
|
+
const deadline=setTimeout(()=>this.stop(),240000);
|
|
65
|
+
let content;
|
|
66
|
+
try {content=await Promise.race([Promise.resolve().then(()=>this.execute(frame.event,this.identity,signal)),canceled]);}
|
|
67
|
+
finally {clearTimeout(deadline);signal.removeEventListener('abort',cancel);}
|
|
68
|
+
if(this.stopping||this.signal?.aborted) throw fail();
|
|
69
|
+
const text=typeof content==='string'?content:content?.text;
|
|
70
|
+
const attachments=typeof content==='string'?[]:content?.attachments;
|
|
71
|
+
if(typeof text!=='string'||!Array.isArray(attachments)||attachments.length>5||(!text&&!attachments.length)||Buffer.byteLength(text)>32768) throw fail();
|
|
72
|
+
const response=JSON.stringify({type:'result',event_id:frame.event.event_id,content:text,...(attachments.length?{attachments}:{})})+'\n';
|
|
73
|
+
if(Buffer.byteLength(response)>limit)throw fail();
|
|
74
|
+
await new Promise((resolve,reject)=>this.child.stdin.write(response,error=>error?reject(fail()):resolve()));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if(buffer.length>limit) throw fail();
|
|
78
|
+
}
|
|
79
|
+
if(buffer.length||!ready) throw fail();
|
|
80
|
+
const ok=await closed;
|
|
81
|
+
if(!ok&&!this.signal?.aborted) throw fail();
|
|
82
|
+
} catch(error) {
|
|
83
|
+
throw this.failure??(error?.code?localError(error.code):fail());
|
|
84
|
+
} finally {
|
|
85
|
+
clearTimeout(readyTimer);
|
|
86
|
+
this.stop();
|
|
87
|
+
this.signal?.removeEventListener('abort',this.abort);
|
|
88
|
+
await closed;
|
|
89
|
+
clearTimeout(this.killTimer);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
package/diagnostics.mjs
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import {spawn} from 'node:child_process';
|
|
2
|
+
import {readFileSync} from 'node:fs';
|
|
3
|
+
import {dirname,join,isAbsolute} from 'node:path';
|
|
4
|
+
import {fileURLToPath} from 'node:url';
|
|
5
|
+
|
|
6
|
+
const codes=new Set(['configuration_valid','configuration_missing','configuration_invalid','host_version_unsupported',
|
|
7
|
+
'python_unsupported','platform_unsupported','python_unavailable','state_locked','worker_stopped','protocol_error',
|
|
8
|
+
'connected','invalid_agent_credentials','authorization_revoked','lease_held','stale_lease','network_unavailable',
|
|
9
|
+
'transport_unavailable','media_unavailable','rate_limited','storage_unavailable']);
|
|
10
|
+
export const safeStatus=code=>codes.has(code)?code:'worker_stopped';
|
|
11
|
+
export function localError(code){const error=new Error(`Mina: ${safeStatus(code)}`);error.code=safeStatus(code);return error;}
|
|
12
|
+
export function hostVersion(){
|
|
13
|
+
try{let path=dirname(fileURLToPath(import.meta.resolve('openclaw/plugin-sdk/channel-core')));
|
|
14
|
+
for(let n=0;n<8;n++,path=dirname(path)){try{const pkg=JSON.parse(readFileSync(join(path,'package.json'),'utf8'));if(pkg.name==='openclaw')return pkg.version;}catch{}}
|
|
15
|
+
}catch{}return '';
|
|
16
|
+
}
|
|
17
|
+
function supportedNode(){const [major,minor]=process.versions.node.split('.').map(Number);return (major===24&&minor>=16)||(major===26&&minor>=1)||major>26;}
|
|
18
|
+
export function runLocalTool(args,{python='python3',input}={}){
|
|
19
|
+
return new Promise((resolve,reject)=>{
|
|
20
|
+
if(!['linux','darwin'].includes(process.platform))return reject(localError('platform_unsupported'));
|
|
21
|
+
if(!supportedNode()||hostVersion()!=='2026.10.5')return reject(localError('host_version_unsupported'));
|
|
22
|
+
if(typeof python!=='string'||!python||python.includes('\0'))return reject(localError('python_unavailable'));
|
|
23
|
+
const env=Object.fromEntries(Object.entries(process.env).filter(([key])=>['PATH','HOME','LANG'].includes(key)));
|
|
24
|
+
const child=spawn(python,[fileURLToPath(new URL('./vendor/mina_setup.py',import.meta.url)),...args],{stdio:['pipe','pipe','ignore'],env,shell:false});
|
|
25
|
+
let data=Buffer.alloc(0),oversized=false;
|
|
26
|
+
const timer=setTimeout(()=>{child.kill('SIGKILL');reject(localError('worker_stopped'));},10000);
|
|
27
|
+
child.on('error',error=>{clearTimeout(timer);reject(localError(error.code==='ENOENT'?'python_unavailable':'worker_stopped'));});
|
|
28
|
+
child.stdout.on('data',chunk=>{if(data.length+chunk.length>32768){oversized=true;child.kill('SIGKILL');return;}data=Buffer.concat([data,chunk]);});
|
|
29
|
+
child.stdin.on('error',()=>{});
|
|
30
|
+
child.on('close',()=>{clearTimeout(timer);try{if(oversized)throw new Error();const value=JSON.parse(data.toString('utf8'));if(!value||typeof value!=='object'||Array.isArray(value))throw new Error();resolve(value);}catch{reject(localError('worker_stopped'));}});
|
|
31
|
+
child.stdin.end(input?JSON.stringify(input):undefined);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
export async function createConfiguration(directory,values,python){
|
|
35
|
+
const result=await runLocalTool(['configure','--directory',directory],{input:values,python});
|
|
36
|
+
if(result.config_file!==join(directory,'assistant.json')||result.state_file!==join(directory,`state-v${values.credential_version}.sqlite`))throw localError(result.code??'configuration_invalid');
|
|
37
|
+
return {configFile:result.config_file,python:python??'python3',enabled:true};
|
|
38
|
+
}
|
|
39
|
+
export async function probeConfiguration(account){
|
|
40
|
+
let code='configuration_missing',configured=false;
|
|
41
|
+
try{
|
|
42
|
+
if(typeof account?.configFile!=='string'||!isAbsolute(account.configFile))return {ok:false,code,configured,connected:null,network_checked:false};
|
|
43
|
+
const report=await runLocalTool(['doctor','--config',account.configFile,'--host','OpenClaw','--host-version',hostVersion()],{python:account.python??'python3'});
|
|
44
|
+
code=safeStatus(report.code);configured=report.configured===true;
|
|
45
|
+
}catch(error){code=safeStatus(error.code);}
|
|
46
|
+
return {ok:code==='configuration_valid',code,configured,connected:null,network_checked:false};
|
|
47
|
+
}
|
package/index.mjs
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import {createHash} from 'node:crypto';
|
|
2
|
+
import {createChatChannelPlugin,defineChannelPluginEntry} from 'openclaw/plugin-sdk/channel-core';
|
|
3
|
+
import {buildChannelInboundEventContext,dispatchChannelInboundTurn} from 'openclaw/plugin-sdk/channel-inbound';
|
|
4
|
+
import {resolveAgentRoute} from 'openclaw/plugin-sdk/routing';
|
|
5
|
+
import {NativeWorker} from './bridge.mjs';
|
|
6
|
+
import {minaSetupWizard,minaSetupAdapter} from './setup.mjs';
|
|
7
|
+
import {probeConfiguration,safeStatus,localError} from './diagnostics.mjs';
|
|
8
|
+
|
|
9
|
+
function validate(event,identity) {
|
|
10
|
+
for(const [key,value] of Object.entries({schema_version:'1',event_type:'message.created',bot_id:identity.bot_id,
|
|
11
|
+
sender_id:identity.owner_id,conversation_id:identity.owner_id,conversation_type:'private',
|
|
12
|
+
credential_version:identity.credential_version})) {
|
|
13
|
+
if(event?.[key]!==value) throw new Error('Mina private event scope mismatch');
|
|
14
|
+
}
|
|
15
|
+
for(const [key,pattern] of [['event_id',/^evt_[a-f0-9]{32}$/],['session_key',/^mina:private:[a-f0-9]{64}$/],
|
|
16
|
+
['message_id',/^[A-Za-z0-9_-]{1,128}$/]]) {
|
|
17
|
+
if(typeof event[key]!=='string'||!pattern.test(event[key])) throw new Error('Invalid Mina event metadata');
|
|
18
|
+
}
|
|
19
|
+
for(const key of ['timestamp_ms','expires_at_ms','context_epoch'])
|
|
20
|
+
if(!Number.isSafeInteger(event[key])||event[key]<1) throw new Error('Invalid Mina event lifecycle');
|
|
21
|
+
const items=event.attachments??[];
|
|
22
|
+
if(!['text','image','file'].includes(event.message_type)||!Array.isArray(items)||(event.message_type==='text'?items.length!==0:items.length!==1)) throw new Error('Invalid Mina attachment event');
|
|
23
|
+
for(const a of items)if(a.kind!==event.message_type||typeof a.path!=='string'||!a.path.startsWith('/')||a.path.includes('\0')||!/^att_[a-f0-9]{32}$/.test(a.attachment_id)||!Number.isSafeInteger(a.size)||a.size<1||a.size>(a.kind==='image'?8388608:20971520)||!/^[a-f0-9]{64}$/.test(a.sha256)) throw new Error('Invalid verified Mina attachment');
|
|
24
|
+
if(event.expires_at_ms<=event.timestamp_ms||typeof event.content!=='string'||(!event.content&&!items.length)||Buffer.byteLength(event.content)>8192)
|
|
25
|
+
throw new Error('Unsupported Mina text event');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function buildTurn({event,identity,cfg,accountId}) {
|
|
29
|
+
validate(event,identity);
|
|
30
|
+
const resolved=resolveAgentRoute({cfg,channel:'mina',accountId,peer:{kind:'direct',id:event.session_key}});
|
|
31
|
+
// Force a private server-issued context even when the host defaults dmScope to main.
|
|
32
|
+
// Context/cv also fence a native session if an upstream key is accidentally reused.
|
|
33
|
+
const key=createHash('sha256').update(JSON.stringify([identity.app_id,event.session_key,
|
|
34
|
+
event.context_epoch,event.credential_version])).digest('hex');
|
|
35
|
+
const route={agentId:resolved.agentId,sessionKey:`agent:${resolved.agentId}:mina:${key}`};
|
|
36
|
+
const ctxPayload=buildChannelInboundEventContext({
|
|
37
|
+
channel:'mina',accountId,provider:'mina',surface:'mina',messageId:event.message_id,
|
|
38
|
+
timestamp:event.timestamp_ms,from:`mina:${event.sender_id}`,
|
|
39
|
+
sender:{id:event.sender_id},conversation:{kind:'direct',id:event.session_key,routePeer:{kind:'direct',id:event.session_key}},
|
|
40
|
+
route:{agentId:route.agentId,accountId,routeSessionKey:route.sessionKey,dispatchSessionKey:route.sessionKey},
|
|
41
|
+
reply:{to:`mina:${event.sender_id}`,originatingTo:`mina:${event.sender_id}`},
|
|
42
|
+
message:{body:event.content,bodyForAgent:event.content,rawBody:event.content,commandBody:''},
|
|
43
|
+
media:(event.attachments??[]).map(a=>({path:a.path,contentType:a.mime_type,kind:a.kind==='image'?'image':'document',fileName:a.name,sizeBytes:a.size,width:a.width,height:a.height})),
|
|
44
|
+
access:{commands:{authorized:false}},channelIngress:'unsupported',
|
|
45
|
+
});
|
|
46
|
+
return {route,ctxPayload};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function createFinalCollector() {
|
|
50
|
+
const texts=[],attachments=[]; let invalid=false;
|
|
51
|
+
return {
|
|
52
|
+
async deliver(payload,info) {
|
|
53
|
+
if(info.kind!=='final'||payload.isReasoning||payload.isCommentary) return;
|
|
54
|
+
if(payload.isError||payload.presentation||payload.interactive||payload.location) {
|
|
55
|
+
invalid=true; return;
|
|
56
|
+
}
|
|
57
|
+
if(typeof payload.text==='string'&&payload.text.trim()) texts.push(payload.text);
|
|
58
|
+
for(const source of payload.mediaUrls??(payload.mediaUrl?[payload.mediaUrl]:[])){
|
|
59
|
+
if(typeof source!=='string'||attachments.length>=5){invalid=true;continue;}
|
|
60
|
+
let path=source;
|
|
61
|
+
try{
|
|
62
|
+
if(source.startsWith('file://')){const url=new URL(source);if(url.hostname||url.search||url.hash)throw new Error();path=decodeURIComponent(url.pathname);}
|
|
63
|
+
if(!path.startsWith('/')&&!path.startsWith('https://'))throw new Error();
|
|
64
|
+
const name=path.startsWith('https://')?decodeURIComponent(new URL(path).pathname.split('/').pop())||'attachment.bin':path.split('/').pop();
|
|
65
|
+
const kind=/\.(png|jpe?g|gif)$/i.test(name)?'image':'file';
|
|
66
|
+
if(!attachments.some(a=>a.path===path))attachments.push({path,kind,name});
|
|
67
|
+
}catch{invalid=true;}
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
error(){invalid=true;},
|
|
71
|
+
result(){
|
|
72
|
+
const content=texts.join('\n\n');
|
|
73
|
+
if(invalid||(!content&&!attachments.length)||Buffer.byteLength(content)>32768) throw new Error('Mina requires a complete text reply');
|
|
74
|
+
return attachments.length?{text:content,attachments}:content;
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function executeTurn({event,identity,cfg,accountId,signal}, replyResolver) {
|
|
80
|
+
if(signal?.aborted) throw new Error('Mina execution canceled');
|
|
81
|
+
const {route,ctxPayload}=buildTurn({event,identity,cfg,accountId});
|
|
82
|
+
const collector=createFinalCollector();
|
|
83
|
+
await dispatchChannelInboundTurn({cfg,channel:'mina',accountId,route,ctxPayload,
|
|
84
|
+
record:{onRecordError:()=>{throw new Error('Mina session recording failed');}},
|
|
85
|
+
replyOptions:{disableBlockStreaming:true,abortSignal:signal},
|
|
86
|
+
delivery:{durable:false,deliver:collector.deliver,onError:()=>collector.error()},
|
|
87
|
+
...(replyResolver?{replyResolver}:{}),
|
|
88
|
+
});
|
|
89
|
+
if(signal?.aborted) throw new Error('Mina execution canceled');
|
|
90
|
+
return collector.result();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const section=cfg=>cfg.channels?.mina??{};
|
|
94
|
+
const accounts=cfg=>section(cfg).accounts??{};
|
|
95
|
+
const resolveAccount=(cfg,accountId='default')=>({...accounts(cfg)[accountId],accountId,
|
|
96
|
+
enabled:section(cfg).enabled!==false&&accounts(cfg)[accountId]?.enabled!==false});
|
|
97
|
+
const blocked=async()=>{throw new Error('Mina only supports a bound final reply to an authorized inbound event');};
|
|
98
|
+
export const minaChannel=createChatChannelPlugin({base:{
|
|
99
|
+
id:'mina',meta:{id:'mina',label:'Mina AI 助手',selectionLabel:'Mina',docsPath:'/channels/mina',
|
|
100
|
+
blurb:'Connect a self-hosted Agent to Mina private chats.'},
|
|
101
|
+
capabilities:{chatTypes:['direct'],nativeCommands:false,blockStreaming:false,media:true,reply:true},
|
|
102
|
+
setupWizard:minaSetupWizard,setup:minaSetupAdapter,
|
|
103
|
+
status:{
|
|
104
|
+
defaultRuntime:{accountId:'default',running:false,connected:false},
|
|
105
|
+
probeAccount:({account})=>probeConfiguration(account),
|
|
106
|
+
buildAccountSnapshot:({account,runtime={},probe})=>({accountId:account.accountId,enabled:account.enabled!==false,configured:!!account.configFile,running:runtime.running===true,connected:runtime.connected===true,mediaReady:runtime.mediaReady===true,lastError:runtime.lastError?safeStatus(runtime.lastError):null,probe}),
|
|
107
|
+
collectStatusIssues:accounts=>accounts.filter(a=>a.enabled!==false&&(!a.configured||a.lastError)).map(a=>({channel:'mina',accountId:a.accountId,kind:!a.configured?'config':'runtime',message:a.lastError?safeStatus(a.lastError):'configuration_missing'})),
|
|
108
|
+
},
|
|
109
|
+
config:{
|
|
110
|
+
listAccountIds:cfg=>Object.keys(accounts(cfg)),resolveAccount,
|
|
111
|
+
isEnabled:account=>account.enabled!==false,
|
|
112
|
+
isConfigured:account=>typeof account.configFile==='string'&&account.configFile.startsWith('/'),
|
|
113
|
+
inspectAccount:(cfg,id)=>({enabled:resolveAccount(cfg,id).enabled!==false,configured:!!resolveAccount(cfg,id).configFile}),
|
|
114
|
+
},
|
|
115
|
+
gateway:{apiVersion:1,async startAccount(ctx) {
|
|
116
|
+
try {
|
|
117
|
+
const report=await probeConfiguration(ctx.account);
|
|
118
|
+
if(!report.ok)throw localError(report.code);
|
|
119
|
+
const worker=new NativeWorker({configFile:ctx.account.configFile,python:ctx.account.python??'python3',
|
|
120
|
+
signal:ctx.abortSignal,onReady:identity=>ctx.setStatus({accountId:ctx.accountId,connected:true,running:true,mediaReady:identity.media_enabled===true,lastError:null,lastStartAt:Date.now()}),
|
|
121
|
+
onStatus:code=>ctx.setStatus({accountId:ctx.accountId,connected:code==='connected',running:true,lastError:code==='connected'?null:safeStatus(code)}),
|
|
122
|
+
execute:(event,identity,signal)=>executeTurn({event,identity,cfg:ctx.cfg,accountId:ctx.accountId,signal})});
|
|
123
|
+
await worker.run();}
|
|
124
|
+
catch(error){ctx.setStatus({accountId:ctx.accountId,lastError:safeStatus(error.code)});throw error;}
|
|
125
|
+
finally {ctx.setStatus({accountId:ctx.accountId,connected:false,running:false,mediaReady:false,lastStopAt:Date.now()});}
|
|
126
|
+
}},
|
|
127
|
+
},outbound:{deliveryMode:'direct',textChunkLimit:32768,sendText:blocked,sendMedia:blocked}});
|
|
128
|
+
export default defineChannelPluginEntry({id:'mina',name:'Mina AI 助手',description:'Durable private Mina channel',plugin:minaChannel});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "mina",
|
|
3
|
+
"channels": ["mina"],
|
|
4
|
+
"name": "Mina AI 助手",
|
|
5
|
+
"description": "Private Mina channel with durable polling and execution permits.",
|
|
6
|
+
"configSchema": {"type": "object", "additionalProperties": false, "properties": {}},
|
|
7
|
+
"channelConfigs": {
|
|
8
|
+
"mina": {
|
|
9
|
+
"schema": {
|
|
10
|
+
"type": "object", "additionalProperties": false,
|
|
11
|
+
"properties": {
|
|
12
|
+
"enabled": {"type": "boolean"},
|
|
13
|
+
"accounts": {
|
|
14
|
+
"type": "object",
|
|
15
|
+
"additionalProperties": {
|
|
16
|
+
"type": "object", "additionalProperties": false,
|
|
17
|
+
"required": ["configFile"],
|
|
18
|
+
"properties": {"enabled": {"type": "boolean"}, "configFile": {"type": "string"}, "python": {"type": "string"}}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,57 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@minaim/openclaw-channel",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"files": [
|
|
6
|
+
"index.mjs",
|
|
7
|
+
"bridge.mjs",
|
|
8
|
+
"openclaw.plugin.json",
|
|
9
|
+
"vendor",
|
|
10
|
+
"setup.mjs",
|
|
11
|
+
"diagnostics.mjs"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=24.16.0 <25 || >=26.1.0"
|
|
15
|
+
},
|
|
16
|
+
"peerDependencies": {
|
|
17
|
+
"openclaw": "2026.10.5"
|
|
18
|
+
},
|
|
19
|
+
"openclaw": {
|
|
20
|
+
"extensions": [
|
|
21
|
+
"./index.mjs"
|
|
22
|
+
],
|
|
23
|
+
"channel": {
|
|
24
|
+
"id": "mina",
|
|
25
|
+
"label": "Mina AI 助手",
|
|
26
|
+
"blurb": "Connect your own Agent to Mina private chats."
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"peerDependenciesMeta": {
|
|
30
|
+
"openclaw": {
|
|
31
|
+
"optional": true
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"description": "Mina IM channel for self-hosted OpenClaw agents with private chat, images and documents.",
|
|
35
|
+
"license": "UNLICENSED",
|
|
36
|
+
"author": "Mina IM",
|
|
37
|
+
"keywords": [
|
|
38
|
+
"openclaw",
|
|
39
|
+
"mina",
|
|
40
|
+
"im",
|
|
41
|
+
"agent",
|
|
42
|
+
"channel"
|
|
43
|
+
],
|
|
44
|
+
"repository": {
|
|
45
|
+
"type": "git",
|
|
46
|
+
"url": "git+https://gitee.com/Mina-IM/mina-agent-plugins.git",
|
|
47
|
+
"directory": "openclaw"
|
|
48
|
+
},
|
|
49
|
+
"bugs": {
|
|
50
|
+
"url": "https://gitee.com/Mina-IM/mina-agent-plugins/issues"
|
|
51
|
+
},
|
|
52
|
+
"homepage": "https://gitee.com/Mina-IM/mina-agent-plugins",
|
|
53
|
+
"publishConfig": {
|
|
54
|
+
"access": "public",
|
|
55
|
+
"registry": "https://registry.npmjs.org"
|
|
56
|
+
}
|
|
57
|
+
}
|
package/setup.mjs
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import {resolveStateDir} from 'openclaw/plugin-sdk/state-paths';
|
|
2
|
+
import {join,isAbsolute} from 'node:path';
|
|
3
|
+
import {createConfiguration,probeConfiguration,localError} from './diagnostics.mjs';
|
|
4
|
+
const account=(cfg,id)=>cfg.channels?.mina?.accounts?.[id]??{};
|
|
5
|
+
const keep=({cfg,accountId})=>Boolean(account(cfg,accountId).configFile);
|
|
6
|
+
const field=(inputKey,message,extra={})=>({inputKey,message,required:true,shouldPrompt:p=>!keep(p),applySet:({cfg})=>cfg,...extra});
|
|
7
|
+
const identity=(prefix)=>({value})=>new RegExp(`^${prefix}_[a-f0-9]{32}$`).test(value)?undefined:'请从 Mina 助手接入页复制有效标识';
|
|
8
|
+
export const minaSetupAdapter={
|
|
9
|
+
applyAccountConfig:({cfg,input})=>{if(input?.token||input?.privateKey)throw localError('configuration_invalid');return cfg;},
|
|
10
|
+
};
|
|
11
|
+
export const minaSetupWizard={
|
|
12
|
+
channel:'mina',
|
|
13
|
+
status:{configuredLabel:'Mina 配置已填写',unconfiguredLabel:'Mina 尚未配置',configuredHint:'运行状态需另行检查',unconfiguredHint:'连接你自己的 Agent',resolveConfigured:keep},
|
|
14
|
+
introNote:{title:'连接 Mina AI 助手',lines:['插件需要 Python 3.9+。从 Mina App 的 AI 助手接入页复制信息。','密钥通过隐藏输入保存到私有文件,不写入 OpenClaw 配置。已有账号配置保留;更换身份请新建 account。']},
|
|
15
|
+
credentials:[],
|
|
16
|
+
textInputs:[
|
|
17
|
+
field('gateway','Mina HTTPS 网关(含部署路径)',{validate:({value})=>{try{const url=new URL(value);return url.protocol==='https:'&&!url.username&&!url.password&&!url.search&&!url.hash?undefined:'需要有效 HTTPS 网关';}catch{return '需要有效 HTTPS 网关';}}}),
|
|
18
|
+
field('app_id','Mina AppID',{validate:identity('mina')}),field('bot_id','Mina 助手 ID',{validate:identity('bot')}),
|
|
19
|
+
field('owner_id','Mina 所有者 ID',{validate:({value})=>/^[A-Za-z0-9_-]{1,128}$/.test(value)?undefined:'所有者 ID 无效'}),
|
|
20
|
+
field('credential_version','凭证版本',{initialValue:()=> '1',validate:({value})=>/^[1-9][0-9]{0,15}$/.test(value)?undefined:'请输入正整数版本'}),
|
|
21
|
+
field('app_secret','Mina AppSecret(隐藏输入)',{sensitive:true,validate:({value})=>/^[A-Za-z0-9_-]{32,128}$/.test(value)?undefined:'密钥格式无效'}),
|
|
22
|
+
field('python','Python 可执行文件',{initialValue:()=> 'python3'}),
|
|
23
|
+
field('media_roots','允许回传文件的目录,逗号分隔;留空仅接收文件',{required:false}),
|
|
24
|
+
field('media_hosts','允许获取远程输出文件的 HTTPS 域名,逗号分隔;默认不允许',{required:false}),
|
|
25
|
+
],
|
|
26
|
+
async finalize({cfg,accountId,credentialValues}){
|
|
27
|
+
if(!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(accountId))throw localError('configuration_invalid');
|
|
28
|
+
if(keep({cfg,accountId})){const report=await probeConfiguration(account(cfg,accountId));if(!report.ok)throw localError(report.code);return {cfg};}
|
|
29
|
+
const v=credentialValues;const values={gateway:v.gateway,app_id:v.app_id,bot_id:v.bot_id,owner_id:v.owner_id,app_secret:v.app_secret,credential_version:Number(v.credential_version)};
|
|
30
|
+
for(const name of ['media_roots','media_hosts'])if(v[name])values[name]=v[name].split(',').map(x=>x.trim()).filter(Boolean);
|
|
31
|
+
const base=resolveStateDir();if(!isAbsolute(base))throw localError('configuration_invalid');
|
|
32
|
+
const created=await createConfiguration(join(base,'mina',accountId),values,v.python||'python3');
|
|
33
|
+
const current=cfg.channels?.mina??{};
|
|
34
|
+
return {cfg:{...cfg,channels:{...cfg.channels,mina:{...current,enabled:current.enabled??true,accounts:{...current.accounts,[accountId]:created}}}}};
|
|
35
|
+
},
|
|
36
|
+
completionNote:{title:'Mina 配置已保存',lines:['启动或重启 Gateway 后,在 Mina App 检查连接状态。','配置检查不等于联网认证。群调用未开放;文档读取依赖 Agent 工具。']},
|
|
37
|
+
};
|
|
File without changes
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": 1,
|
|
3
|
+
"plugin_version": "0.2.0",
|
|
4
|
+
"platforms": ["linux", "darwin"],
|
|
5
|
+
"OpenClaw": {"host_versions": ["2026.10.5"], "python_min": [3, 9], "node": ">=24.16.0 <25 || >=26.1.0"},
|
|
6
|
+
"Hermes": {"host_versions": ["0.21.3"], "python_min": [3, 11], "python_max_exclusive": [3, 14]}
|
|
7
|
+
}
|