Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions deployment/qq.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
services:
napcat:
image: mlikiowa/napcat-docker:latest
container_name: napcat
restart: always
# mac_address: 02:42:ac:11:00:02 # 添加MAC地址固化配置
environment:
- NAPCAT_UID=${NAPCAT_UID}
- NAPCAT_GID=${NAPCAT_GID}

ports:
- 3001:3001
- 6096:6096
- 6097:6097
- 6099:6099

volumes:
- ../.data/napcat/config:/app/napcat/config
- ../.data/ntqq:/app/.config/QQ
networks:
- ema-network

networks:
ema-network:
driver: bridge
51 changes: 51 additions & 0 deletions docs/plugin.zh-CN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# 插件

插件是 Ema 的扩展机制,可以通过插件来扩展 Ema 的功能。目前支持的插件有:

- QQ

## 插件配置

目前只有一个环境变量 `EMA_PLUGINS`,用于配置插件列表,多个插件用逗号分隔。例如:

```bash
EMA_PLUGINS=qq
```

## 添加插件

插件的命名必须以`ema-plugin-`开头,例如 `ema-plugin-discord`。插件的开发可以通过以下步骤进行:

1. 创建一个插件包,例如 `ema-plugin-discord`。
2. 在 [`ema-ui/package.json`](/packages/ema-ui/package.json) 的 `peerDependencies` 中添加一行:

```jsonc
{
"peerDependencies": {
// PNPM 工作空间依赖
"ema-plugin-discord": "workspace:*",
// 或外部包依赖
"ema-plugin-discord": "^1.0.0",
},
}
```

3. 重启服务器。

## 插件开发

插件的根文件需要导出 `Plugin` 符号:

```ts
import type { EmaPluginProvider, Server } from "ema";
export const Plugin: EmaPluginProvider = class {
static name = "QQ";
constructor(private readonly server: Server) {}
start(): Promise<void> {
console.log("[ema-qq] started", !!this.server.chat);
return Promise.resolve();
}
};
```

根据编译错误的指引实现 `ema-plugin-discord` 包。
26 changes: 26 additions & 0 deletions packages/ema-plugin-qq/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"name": "ema-plugin-qq",
"version": "0.1.0",
"type": "module",
"keywords": [
"qq"
],
"repository": {
"type": "git",
"url": "git+https://github.com/EmaFanClub/EverMemoryArchive.git",
"directory": "packages/ema-plugin-qq"
},
"bugs": {
"url": "https://github.com/EmaFanClub/EverMemoryArchive/issues"
},
"dependencies": {
"ema": "workspace:*",
"node-napcat-ts": "^0.4.20"
},
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
}
}
}
149 changes: 149 additions & 0 deletions packages/ema-plugin-qq/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import type { AgentEventContent, EmaPluginProvider, Server } from "ema";
import { NCWebsocket, type GroupMessage } from "node-napcat-ts";
import { execSync } from "child_process";

const gitRev = getGitRev();
const gitRemote = getGitRemote();

const napcat = new NCWebsocket(
{
protocol: "ws",
host: "172.19.0.2",
port: 6097,
accessToken: process.env.NAPCAT_ACCESS_TOKEN,
// 是否需要在触发 socket.error 时抛出错误, 默认关闭
throwPromise: true,
// ↓ 自动重连(可选)
reconnection: {
enable: true,
attempts: 10,
delay: 5000,
},
// ↓ 是否开启 DEBUG 模式
},
true,
);

export const Plugin: EmaPluginProvider = class {
static name = "QQ";
constructor(private readonly server: Server) {}
async start(): Promise<void> {
const replyPat = process.env.NAPCAT_REPLY_PATTERN;
if (!replyPat) {
throw new Error("NAPCAT_REPLY_PATTERN is not set");
}

await napcat.connect();

const actor = await this.server.getActor(1, 1);

interface GroupMessageTask {
message: GroupMessage;
}

let taskId = 0;
const tasks: Record<number, GroupMessageTask> = {};
const messageCache = new Map<number, string>();

actor.subscribe((response) => {
console.log("[ema-qq] actor response", response);
for (const event of response.events) {
console.log("[ema-qq] actor event", event);
if (event.type === "runFinished") {
const runFinishedEvent =
event.content as AgentEventContent<"runFinished">;
console.log("[ema-qq] actor run finished", runFinishedEvent);
if (runFinishedEvent.ok) {
const task = tasks[runFinishedEvent.metadata.taskId];
if (!task) {
console.error(
"[ema-qq] task not found",
runFinishedEvent.metadata.taskId,
);
continue;
}
const message = task.message;
const msg = runFinishedEvent.msg
.trim()
.replaceAll(
gitRev,
`[${gitRev}]( ${gitRemote}/commit/${gitRev} )`,
);
message.quick_action(
[
{
type: "text",
data: {
text: ` ${msg}`,
},
},
],
true,
);
}
}
}
});

napcat.on("message.group", async (message) => {
// { type: 'reply', data: [Object] },
console.log("[ema-qq] group message");
console.log("[ema-qq] group message", message);

if (!message.raw_message.includes(replyPat)) {
console.log("message ignored");
return;
}
let replyContext = "";
const reply = message.message.find((m) => m.type === "reply");
if (reply) {
const replyId = Number.parseInt(reply?.data.id);
if (replyId && !Number.isNaN(replyId)) {
const cached = messageCache.get(replyId);
if (cached) {
replyContext = cached;
} else {
const msg = await napcat.get_msg({ message_id: replyId });
if (msg) {
replyContext = msg.raw_message;
messageCache.set(replyId, replyContext);
}
}
}
}
messageCache.set(message.message_id, message.raw_message);

const id = taskId++;
tasks[id] = { message };

let contentList = [];
if (replyContext) {
contentList.push(`注意:这则消息是在回复:<Reply>`);
contentList.push(replyContext);
contentList.push(`</Reply>`);
}
contentList.push(message.raw_message);
// current time
contentList.push(`当前时间:<Time>${new Date().toLocaleString()}</Time>`);
contentList.push(`GitRev(分支):<Rev>${gitRev}</Rev>`);

const content = contentList.join("\n");

actor.work({
metadata: { taskId: id },
inputs: [{ kind: "text", content }],
});
});

return Promise.resolve();
}
};

function getGitRev(): string {
// git commit hash
return execSync("git rev-parse HEAD").toString().trim();
}

function getGitRemote(): string {
return execSync("git remote get-url origin").toString().trim();
}
15 changes: 15 additions & 0 deletions packages/ema-plugin-qq/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"verbatimModuleSyntax": true,
"experimentalDecorators": true,
"skipLibCheck": true,
"types": ["vitest/globals", "node"],
"outDir": "./dist"
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
8 changes: 8 additions & 0 deletions packages/ema-ui/instrumentation-node.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { getServer } from "@/app/api/shared-server";
import { loadPlugins } from "@/plugin";

getServer()
.then(loadPlugins)
.catch((error) => {
console.error("Failed to load plugins:", error);
});
9 changes: 9 additions & 0 deletions packages/ema-ui/instrumentation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
await import("./instrumentation-node");
}

if (process.env.NEXT_RUNTIME === "edge") {
console.warn("Edge runtime is not supported yet");
}
}
3 changes: 3 additions & 0 deletions packages/ema-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
"@lancedb/lancedb": "^0.23.0",
"mongodb": "^7.0.0"
},
"peerDependencies": {
"ema-plugin-qq": "workspace:*"
},
"devDependencies": {
"@next/env": "^16.1.0",
"@types/node": "^20",
Expand Down
2 changes: 1 addition & 1 deletion packages/ema-ui/src/app/api/actor/input/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export const POST = postBody(ActorInputRequest)(async (body) => {
const actor = await server.getActor(body.userId, body.actorId);

// Processes input.
await actor.work(body.inputs);
await actor.work({ inputs: body.inputs });

return new Response(JSON.stringify({ success: true }), {
status: 200,
Expand Down
77 changes: 77 additions & 0 deletions packages/ema-ui/src/plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import type { EmaPluginModule, Server } from "ema";
import * as fs from "fs";

/**
* Loads plugins by environment variable `EMA_PLUGINS`
* @param server - The server instance
*/
export async function loadPlugins(server: Server): Promise<void> {
Comment on lines +4 to +8
Copy link

Copilot AI Dec 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing @returns JSDoc tag for the loadPlugins function. According to the codebase conventions (seen throughout packages/ema/src/*.ts files), functions should include @returns tags in their JSDoc comments even when they return Promise<void>.

Copilot generated this review using guidance from repository custom instructions.
/**
* Comma-separated list of plugins to load
*
* @example
* EMA_PLUGINS=qq,discord
*/
const enabledPlugins = new Set(
(process.env.EMA_PLUGINS ?? "")
.split(",")
.map((name) => name.trim())
.filter((name) => name.length > 0),
);

await Promise.all(
getPluginModules()
.filter((name) => enabledPlugins.has(name))
.map(async (name: string) => {
try {
const m: EmaPluginModule = await import(`ema-plugin-${name}`);
if (m.Plugin.name in server.plugins) {
throw new Error(`Plugin ${m.Plugin.name} already loaded`);
}

const plugin = new m.Plugin(server);

server.plugins[m.Plugin.name] = plugin;
} catch (error) {
console.error(`Failed to load plugin package "${name}":`, error);
return;
}
Comment on lines +35 to +38
Copy link

Copilot AI Dec 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Error handling silently returns without re-throwing or properly handling the error. When a plugin fails to load (line 36), the error is logged but execution continues. However, when getPluginModules() includes a plugin name that doesn't exist, the dynamic import at line 27 will fail with a potentially cryptic error message. Consider providing more context about whether the plugin package doesn't exist or if there was an import error.

Copilot uses AI. Check for mistakes.
}),
);

const plugins = Object.entries(server.plugins);
await Promise.all(
plugins.map(async ([name, plugin]) => {
if (!plugin) {
return;
}
try {
return await plugin.start();
} catch (error) {
console.error(`Failed to start plugin "${name}":`, error);
}
}),
);
Comment on lines +4 to +54
Copy link

Copilot AI Dec 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential race condition in plugin initialization. The code loads all plugin modules into server.plugins (line 34) before starting any of them (line 49). However, if the instrumentation file is called multiple times during server initialization, or if getServer() is called from multiple places, plugins could be loaded multiple times or started multiple times. There's a check for duplicate plugin names (line 28-30), but no protection against re-initialization of the entire plugin system.

Suggested change
/**
* Loads plugins by environment variable `EMA_PLUGINS`
* @param server - The server instance
*/
export async function loadPlugins(server: Server): Promise<void> {
/**
* Comma-separated list of plugins to load
*
* @example
* EMA_PLUGINS=qq,discord
*/
const enabledPlugins = new Set(
(process.env.EMA_PLUGINS ?? "")
.split(",")
.map((name) => name.trim())
.filter((name) => name.length > 0),
);
await Promise.all(
getPluginModules()
.filter((name) => enabledPlugins.has(name))
.map(async (name: string) => {
try {
const m: EmaPluginModule = await import(`ema-plugin-${name}`);
if (m.Plugin.name in server.plugins) {
throw new Error(`Plugin ${m.Plugin.name} already loaded`);
}
const plugin = new m.Plugin(server);
server.plugins[m.Plugin.name] = plugin;
} catch (error) {
console.error(`Failed to load plugin package "${name}":`, error);
return;
}
}),
);
const plugins = Object.entries(server.plugins);
await Promise.all(
plugins.map(async ([name, plugin]) => {
if (!plugin) {
return;
}
try {
return await plugin.start();
} catch (error) {
console.error(`Failed to start plugin "${name}":`, error);
}
}),
);
const initializingServers = new WeakMap<Server, Promise<void>>();
const initializedServers = new WeakSet<Server>();
/**
* Loads plugins by environment variable `EMA_PLUGINS`
* @param server - The server instance
*/
export async function loadPlugins(server: Server): Promise<void> {
if (initializedServers.has(server)) {
return;
}
const existingInitialization = initializingServers.get(server);
if (existingInitialization) {
await existingInitialization;
return;
}
const initializationPromise = (async () => {
/**
* Comma-separated list of plugins to load
*
* @example
* EMA_PLUGINS=qq,discord
*/
const enabledPlugins = new Set(
(process.env.EMA_PLUGINS ?? "")
.split(",")
.map((name) => name.trim())
.filter((name) => name.length > 0),
);
await Promise.all(
getPluginModules()
.filter((name) => enabledPlugins.has(name))
.map(async (name: string) => {
try {
const m: EmaPluginModule = await import(`ema-plugin-${name}`);
if (m.Plugin.name in server.plugins) {
throw new Error(`Plugin ${m.Plugin.name} already loaded`);
}
const plugin = new m.Plugin(server);
server.plugins[m.Plugin.name] = plugin;
} catch (error) {
console.error(`Failed to load plugin package "${name}":`, error);
return;
}
}),
);
const plugins = Object.entries(server.plugins);
await Promise.all(
plugins.map(async ([name, plugin]) => {
if (!plugin) {
return;
}
try {
return await plugin.start();
} catch (error) {
console.error(`Failed to start plugin "${name}":`, error);
}
}),
);
})();
initializingServers.set(server, initializationPromise);
try {
await initializationPromise;
initializedServers.add(server);
} finally {
initializingServers.delete(server);
}

Copilot uses AI. Check for mistakes.
}
Comment on lines +8 to +55
Copy link

Copilot AI Dec 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new plugin loading functionality in loadPlugins and plugin infrastructure lacks test coverage. According to the custom guideline in the project (see CONTRIBUTING.md and existing tests in packages/ema/src/server.spec.ts), new features should include test cases. Consider adding tests for: plugin discovery from package.json, plugin loading with EMA_PLUGINS env var, error handling for missing/invalid plugins, and plugin lifecycle (start method).

Copilot generated this review using guidance from repository custom instructions.

/**
* Finds all plugin modules in the `ema-ui` package.json
Copy link

Copilot AI Dec 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incorrect JSDoc description. The comment states "Finds all plugin modules in the ema-ui package.json", but the function actually reads from the ema-ui package directory relative to the current file using import.meta.url. The description should clarify that it reads from the ema-ui package's package.json file.

Suggested change
* Finds all plugin modules in the `ema-ui` package.json
* Finds all plugin modules declared in the `ema-ui` package's package.json file,
* which is read from the ema-ui package directory relative to this module via `import.meta.url`.

Copilot uses AI. Check for mistakes.
*
* @returns The names of the plugin modules
*/
function getPluginModules(): string[] {
const dependencies = new Set<string>();
const addOne = (name: string) => {
if (name.startsWith("ema-plugin-")) {
dependencies.add(name.slice("ema-plugin-".length));
}
};

const packageJsonData = JSON.parse(
fs.readFileSync(new URL("../package.json", import.meta.url), "utf-8"),
);

Object.keys(packageJsonData.dependencies || {}).forEach(addOne);
Object.keys(packageJsonData.peerDependencies || {}).forEach(addOne);
return Array.from(dependencies).sort();
}
Loading