koishi-plugin-chatluna-affinity 0.0.3 → 0.0.5

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.
@@ -46,6 +46,7 @@ function extendModel(ctx) {
46
46
  platform: 'string',
47
47
  selfId: { type: 'string', nullable: true },
48
48
  userId: 'string',
49
+ nickname: { type: 'string', nullable: true },
49
50
  affinity: 'integer',
50
51
  affinityInited: 'boolean',
51
52
  relation: { type: 'string', nullable: true },
@@ -142,7 +143,15 @@ function createAffinityStore(ctx, config, log) {
142
143
  const existing = await ctx.database.get(MODEL_NAME, { id });
143
144
  const current = existing?.[0];
144
145
  const now = new Date();
145
- const relationText = relation !== undefined && relation !== null ? String(relation).trim() : undefined;
146
+ const manual = findManualRelationship(identity.platform, identity.userId);
147
+ let relationText;
148
+ if (relation === undefined || relation === null) {
149
+ relationText = manual?.relation ?? current?.relation ?? '';
150
+ } else {
151
+ const trimmed = String(relation).trim();
152
+ relationText = trimmed ? trimmed : manual?.relation ?? current?.relation ?? '';
153
+ }
154
+ const nickname = seed?.nickname || seed?.authorNickname || seed?.session?.nickname || seed?.session?.author?.nickname || seed?.session?.author?.name || '';
146
155
 
147
156
  const payload = {
148
157
  id,
@@ -151,11 +160,16 @@ function createAffinityStore(ctx, config, log) {
151
160
  userId: identity.userId,
152
161
  affinity: typeof value === 'number' ? value : current?.affinity ?? defaultInitialValue(),
153
162
  affinityInited: inited ?? current?.affinityInited ?? false,
154
- relation: relationText !== undefined ? relationText : current?.relation ?? '',
163
+ relation: relationText,
164
+ nickname: nickname || current?.nickname || manual?.relationNickname || '',
155
165
  updatedAt: now,
156
166
  relationUpdatedAt: relationText !== undefined ? now : current?.relationUpdatedAt ?? now
157
167
  };
158
168
 
169
+ if (nickname && (!current || nickname !== current.nickname)) {
170
+ payload.nickname = nickname;
171
+ }
172
+
159
173
  await ctx.database.upsert(MODEL_NAME, [payload]);
160
174
  return payload;
161
175
  };
@@ -271,14 +285,17 @@ function createAffinityStore(ctx, config, log) {
271
285
  : manual?.initialAffinity ?? rollInitial();
272
286
  const initial = clampFn(base, config.min, config.max);
273
287
  const level = resolveLevelByAffinity(initial);
274
- await save({ platform, userId, selfId: session?.selfId }, initial, true, level?.relation ?? '');
288
+ const desiredRelation = manual?.relation ?? level?.relation ?? '';
289
+ await save({ platform, userId, selfId: session?.selfId, session }, initial, true, desiredRelation);
275
290
  return { affinity: initial, isNew: true };
276
291
  }
277
292
 
278
293
  const normalized = clampFn(record.affinity ?? defaultInitialValue(), config.min, config.max);
279
294
  if (normalized !== record.affinity) {
280
295
  const level = resolveLevelByAffinity(normalized);
281
- await save({ platform, userId, selfId: session?.selfId }, normalized, record.affinityInited, level?.relation ?? record.relation ?? '');
296
+ const manual = findManualRelationship(platform, userId);
297
+ const desiredRelation = manual?.relation ?? level?.relation ?? record.relation ?? '';
298
+ await save({ platform, userId, selfId: session?.selfId, session }, normalized, record.affinityInited, desiredRelation);
282
299
  }
283
300
  return { affinity: normalized, isNew: false };
284
301
  };
@@ -301,4 +318,4 @@ function createAffinityStore(ctx, config, log) {
301
318
  };
302
319
  }
303
320
 
304
- module.exports = { createAffinityStore };
321
+ module.exports = { createAffinityStore, MODEL_NAME };
package/lib/index.d.ts CHANGED
@@ -38,7 +38,10 @@ export interface Config {
38
38
  note: string;
39
39
  }>;
40
40
  registerAffinityTool: boolean;
41
+ registerBlacklistTool: boolean;
41
42
  registerRelationshipTool: boolean;
43
+ rankDefaultLimit: number;
44
+ rankRenderAsImage: boolean;
42
45
  }
43
46
  export declare const Config: Schema<Config>;
44
47
  export declare const inject: string[];
package/lib/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ const { h } = require('koishi');
1
2
  const { modelSchema } = require('koishi-plugin-chatluna/utils/schema');
2
3
  const { ChatLunaPlugin } = require('koishi-plugin-chatluna/services/chat');
3
4
  const { getMessageContent } = require('koishi-plugin-chatluna/utils/string');
@@ -5,7 +6,7 @@ const { getMessageContent } = require('koishi-plugin-chatluna/utils/string');
5
6
  const { Config, inject, name } = require('./schema');
6
7
  const { createLogger } = require('./logger');
7
8
  const { renderTemplate } = require('./template');
8
- const { createAffinityStore } = require('./affinity-store');
9
+ const { createAffinityStore, MODEL_NAME } = require('./affinity-store');
9
10
  const { createHistoryManager } = require('./history');
10
11
  const { createAffinityCache } = require('./cache');
11
12
  const { createAffinityProvider, createRelationshipProvider } = require('./providers');
@@ -63,6 +64,12 @@ function apply(ctx, config) {
63
64
  createTool: registry.createRelationshipTool
64
65
  });
65
66
  }
67
+ if (config.registerBlacklistTool) {
68
+ plugin.registerTool('adjust_blacklist', {
69
+ selector: registry.blacklistSelector,
70
+ createTool: registry.createBlacklistTool
71
+ });
72
+ }
66
73
  });
67
74
 
68
75
  const middleware = createAnalysisMiddleware(ctx, config, {
@@ -76,6 +83,138 @@ function apply(ctx, config) {
76
83
  });
77
84
 
78
85
  ctx.middleware(middleware);
86
+
87
+ const renderRankImage = async (lines) => {
88
+ const puppeteer = ctx.puppeteer;
89
+ if (!puppeteer?.page) return null;
90
+ const html = `<!DOCTYPE html>
91
+ <html lang="zh-CN">
92
+ <head>
93
+ <meta charset="utf-8" />
94
+ <style>
95
+ body {
96
+ margin: 0;
97
+ font-family: "Segoe UI", "Helvetica Neue", PingFangSC, "Microsoft Yahei", sans-serif;
98
+ background: #ffffff;
99
+ color: #111111;
100
+ }
101
+ .container {
102
+ padding: 20px 24px;
103
+ }
104
+ h1 {
105
+ font-size: 18px;
106
+ margin: 0 0 16px;
107
+ font-weight: 600;
108
+ }
109
+ table {
110
+ border-collapse: collapse;
111
+ width: 100%;
112
+ min-width: 360px;
113
+ font-size: 14px;
114
+ }
115
+ th, td {
116
+ padding: 8px 12px;
117
+ border-bottom: 1px solid #e5e5e5;
118
+ text-align: left;
119
+ white-space: nowrap;
120
+ }
121
+ th {
122
+ background: #f5f7fa;
123
+ font-weight: 600;
124
+ }
125
+ tr:nth-child(odd) td {
126
+ background: #fbfcfe;
127
+ }
128
+ </style>
129
+ </head>
130
+ <body>
131
+ <div class="container" id="rank-root">
132
+ <h1>好感度排行</h1>
133
+ <table>
134
+ <thead>
135
+ <tr><th>排名</th><th>用户名</th><th>关系</th><th>好感度</th></tr>
136
+ </thead>
137
+ <tbody>
138
+ ${lines.map((line, index) => {
139
+ const [name, relation, affinity] = line;
140
+ return `<tr><td>${index + 1}</td><td>${name}</td><td>${relation}</td><td>${affinity}</td></tr>`;
141
+ }).join('')}
142
+ </tbody>
143
+ </table>
144
+ </div>
145
+ </body>
146
+ </html>`;
147
+
148
+ let page;
149
+ try {
150
+ page = await puppeteer.page();
151
+ await page.setViewport({ width: 600, height: 200 + lines.length * 32 });
152
+ await page.setContent(html, { waitUntil: 'networkidle0' });
153
+ const element = await page.$('#rank-root');
154
+ if (!element) return null;
155
+ const buffer = await element.screenshot({ omitBackground: false });
156
+ return buffer;
157
+ } catch (error) {
158
+ ctx.logger?.warn?.('排行榜图片渲染失败', error);
159
+ return null;
160
+ } finally {
161
+ try {
162
+ await page?.close();
163
+ } catch (_) {
164
+ // ignore
165
+ }
166
+ }
167
+ };
168
+
169
+ ctx.command('affinity.rank [limit:number] [platform:string] [image]', '查看当前好感度排行', { authority: 1 })
170
+ .alias('好感度排行')
171
+ .usage('affinity.rank [人数] [平台] [image|text]')
172
+ .action(async ({ session }, limitArg, platformArg, imageArg) => {
173
+ const parsedLimit = Number(limitArg);
174
+ const limit = Math.max(1, Math.min(Number.isFinite(parsedLimit) ? parsedLimit : config.rankDefaultLimit, 50));
175
+
176
+ const shouldRenderImage = (() => {
177
+ if (imageArg === undefined) return !!config.rankRenderAsImage;
178
+ const lower = String(imageArg).toLowerCase();
179
+ if (['0', 'false', 'text', 'no', 'n'].includes(lower)) return false;
180
+ if (['1', 'true', 'image', 'img', 'yes', 'y'].includes(lower)) return true;
181
+ return !!config.rankRenderAsImage;
182
+ })();
183
+
184
+ if (shouldRenderImage && (!ctx.puppeteer || typeof ctx.puppeteer.page !== 'function')) {
185
+ return '当前环境未启用 puppeteer,已改为文本模式(可安装 koishi-plugin-puppeteer 或传入 text)。';
186
+ }
187
+
188
+ const conditions = {};
189
+ const platform = platformArg || session?.platform;
190
+ if (platform) conditions.platform = platform;
191
+ if (session?.selfId) conditions.selfId = session.selfId;
192
+
193
+ const query = ctx.database
194
+ .select(MODEL_NAME)
195
+ .project(['platform', 'selfId', 'userId', 'nickname', 'affinity', 'relation'])
196
+ .orderBy('affinity', 'desc')
197
+ .limit(limit);
198
+ if (Object.keys(conditions).length) query.where(conditions);
199
+
200
+ const rows = await query.execute();
201
+ if (!rows.length) return '当前暂无好感度记录。';
202
+
203
+ const lines = rows.map((row) => {
204
+ const name = row.nickname || row.userId;
205
+ const relation = row.relation || '——';
206
+ return [name, relation, row.affinity];
207
+ });
208
+ const textLines = ['用户名 关系 好感度', ...lines.map((item, index) => `${index + 1}. ${item[0]} ${item[1]} ${item[2]}`)];
209
+
210
+ if (shouldRenderImage) {
211
+ const buffer = await renderRankImage(lines);
212
+ if (buffer) return h.image(buffer, 'image/png');
213
+ ctx.logger?.warn?.('排行榜图片渲染失败或服务缺失,已改为文本输出');
214
+ return textLines.join('\n');
215
+ }
216
+ return textLines.join('\n');
217
+ });
79
218
  }
80
219
 
81
220
  module.exports = { apply, Config, inject, name };
package/lib/middleware.js CHANGED
@@ -128,6 +128,7 @@ function createAnalysisMiddleware(ctx, config, { store, history, cache, getModel
128
128
  const jsonCandidate = typeof text === 'string' ? text : String(text ?? '');
129
129
  const match = jsonCandidate.match(/\{[\s\S]*\}/);
130
130
  let delta = 0;
131
+ const nickname = session?.author?.nickname || session?.author?.name || session?.user?.nickname || session?.user?.name || session?.username || session?.nickname || '';
131
132
  if (match) {
132
133
  try {
133
134
  const parsed = JSON.parse(match[0]);
@@ -137,7 +138,7 @@ function createAnalysisMiddleware(ctx, config, { store, history, cache, getModel
137
138
  if (action === 'increase' && delta <= 0) delta = Math.max(1, Math.abs(delta));
138
139
  if (action === 'decrease' && delta >= 0) delta = -Math.max(1, Math.abs(delta));
139
140
  if (action === 'hold') delta = 0;
140
- if (debugEnabled) log('info', '模型返回', { raw: parsed, parsedDelta: delta, action, userId: session.userId, platform: session.platform });
141
+ if (debugEnabled) log('info', '模型返回', { raw: parsed, parsedDelta: delta, action, userId: session.userId, platform: session.platform, nickname });
141
142
  } catch (error) {
142
143
  log('warn', '解析模型响应失败', { text: jsonCandidate, error });
143
144
  }
@@ -149,11 +150,12 @@ function createAnalysisMiddleware(ctx, config, { store, history, cache, getModel
149
150
  ? Math.min(delta, positiveLimit)
150
151
  : Math.max(delta, -negativeLimit);
151
152
  const nextAffinity = clampValue(oldAffinity + limitedDelta, config.min, config.max);
153
+
152
154
  if (nextAffinity !== oldAffinity) {
153
155
  const level = store.resolveLevelByAffinity(nextAffinity);
154
- await store.save({ platform: session.platform, userId: session.userId, selfId: session?.selfId }, nextAffinity, true, level?.relation ?? '');
156
+ await store.save({ platform: session.platform, userId: session.userId, selfId: session?.selfId, session }, nextAffinity, true, level?.relation ?? '');
155
157
  cache.set(session.platform, session.userId, nextAffinity);
156
- log('info', '好感度已更新', { oldAffinity, delta: limitedDelta, nextAffinity, userId: session.userId, platform: session.platform });
158
+ log('info', '好感度已更新', { oldAffinity, delta: limitedDelta, nextAffinity, userId: session.userId, platform: session.platform, nickname });
157
159
  } else if (config.useLastAffinity) {
158
160
  cache.set(session.platform, session.userId, oldAffinity);
159
161
  }
package/lib/schema.js CHANGED
@@ -4,7 +4,7 @@ const name = 'chatluna-affinity';
4
4
 
5
5
  const inject = {
6
6
  required: ['chatluna', 'database'],
7
- optional: ['chatluna_group_analysis_message', 'chatluna_group_analysis']
7
+ optional: ['puppeteer']
8
8
  };
9
9
 
10
10
  const AffinitySchema = Schema.object({
@@ -36,7 +36,7 @@ const AffinitySchema = Schema.object({
36
36
  analysisPrompt: Schema.string()
37
37
  .role('textarea')
38
38
  .default(
39
- '你是好感度管家,需要根据上下文评估是否调整好感度。\n- 关注最近若干条群聊消息,判断整体语气与语境;\n- 当用户友善、感谢、积极互动时,适度增加;\n- 当用户正常交流且无明显倾向时,保持不变;\n- 当用户冒犯、敷衍、重复打扰时,减少;\n- 增加幅度不超过 {{maxIncreasePerMessage}} ,减少幅度不超过 {{maxDecreasePerMessage}} ,并保持在提供的范围内;\n- 使用 action 表示行为:increase 增加、decrease 减少、hold 保持。\n\n角色设定:{{personaPrompt}}\n\n当前好感度:{{currentAffinity}} (范围 {{minAffinity}} ~ {{maxAffinity}})\n最近 {{historyCount}} 条消息(旧 -> 新):\n{{historyText}}\n\n本次用户消息:\n{{userMessage}}\n\n请仅输出 JSON:{"delta": 整数, "action": "increase|decrease|hold", "reason": "简短中文原因"}。'
39
+ '你是好感度管家,需要根据上下文评估是否调整好感度。\n- 仔细阅读最近若干条群聊消息,聚焦语气、内容、语境与互动意图;\n- 只有当用户展现出明确、具体的善意或贡献时(如感谢、称赞、帮助解决问题、分享价值信息、表达真诚关心),才考虑增加;\n- 若用户只是礼貌问候、例行回应、轻度调侃或重复旧话题,可保持不变;\n- 对于冒犯、敷衍、抄袭、频繁打扰、传播负面情绪或破坏氛围的行为,应减少;\n- 增加幅度不超过 {{maxIncreasePerMessage}} ,减少幅度不超过 {{maxDecreasePerMessage}} ,并保持在提供的范围内;\n- 在总结理由时,指明触发增减的具体言辞或行为;\n- 使用 action 表示行为:increase 增加、decrease 减少、hold 保持。\n\n角色设定:{{personaPrompt}}\n\n当前好感度:{{currentAffinity}} (范围 {{minAffinity}} ~ {{maxAffinity}})\n最近 {{historyCount}} 条消息(旧 -> 新):\n{{historyText}}\n\n本次用户消息:\n{{userMessage}}\n\n请仅输出 JSON:{"delta": 整数, "action": "increase|decrease|hold", "reason": "简短中文原因"}。'
40
40
  )
41
41
  .description('好感度分析主提示词'),
42
42
  personaPrompt: Schema.string()
@@ -44,7 +44,10 @@ const AffinitySchema = Schema.object({
44
44
  .default('你是一位温暖可靠的伙伴,会根据好感度高低调整语气:好感度越高越亲近,越低越保持礼貌。')
45
45
  .description('补充的人设提示词,会注入到分析提示词中'),
46
46
  debugLogging: Schema.boolean().default(false).description('输出调试日志'),
47
- registerAffinityTool: Schema.boolean().default(false).description('注册 ChatLuna 工具:调整好感度')
47
+ registerAffinityTool: Schema.boolean().default(false).description('注册 ChatLuna 工具:调整好感度'),
48
+ registerBlacklistTool: Schema.boolean().default(false).description('注册 ChatLuna 工具:管理黑名单'),
49
+ rankDefaultLimit: Schema.number().default(10).min(1).max(50).description('排行榜默认展示人数'),
50
+ rankRenderAsImage: Schema.boolean().default(false).description('排行榜默认渲染为图片')
48
51
  }).description('好感度设置');
49
52
 
50
53
  const RelationshipSchema = Schema.object({
package/lib/tools.js CHANGED
@@ -20,7 +20,7 @@ function createAffinityTool(options) {
20
20
  if (!platform || !userId) return 'Missing platform or user ID. Unable to adjust affinity.';
21
21
  const value = options.clamp(input.affinity);
22
22
  const level = options.resolveLevelByAffinity(value);
23
- await options.save({ platform, userId, selfId: session?.selfId }, value, true, level?.relation ?? options.defaultRelation);
23
+ await options.save({ platform, userId, selfId: session?.selfId, session }, value, true, level?.relation ?? options.defaultRelation);
24
24
  options.cache.set(platform, userId, value);
25
25
  if (level?.relation) {
26
26
  return `Affinity for ${platform}/${userId} set to ${value}. Relationship updated to ${level.relation}.`;
@@ -58,7 +58,7 @@ function createRelationshipTool(options) {
58
58
  }
59
59
  const baseValue = level ? level.min : options.defaultInitial();
60
60
  const base = options.clamp(baseValue);
61
- await options.save({ platform, userId, selfId: session?.selfId }, base, true, relationName);
61
+ await options.save({ platform, userId, selfId: session?.selfId, session }, base, true, relationName);
62
62
  options.cache.set(platform, userId, base);
63
63
  options.updateRelationshipConfig(userId, relationName, base);
64
64
  if (level) {
@@ -69,6 +69,38 @@ function createRelationshipTool(options) {
69
69
  })();
70
70
  }
71
71
 
72
+ function createBlacklistTool(options) {
73
+ return new (class extends StructuredTool {
74
+ constructor() {
75
+ super({});
76
+ this.name = 'adjust_blacklist';
77
+ this.description = 'Add or remove a user from the affinity blacklist.';
78
+ this.schema = z.object({
79
+ action: z.enum(['add', 'remove']).describe('Add or remove the user from blacklist'),
80
+ targetUserId: z.string().describe('Target user ID'),
81
+ platform: z.string().optional().describe('Target platform; defaults to current session'),
82
+ note: z.string().optional().describe('Optional note when adding to blacklist')
83
+ });
84
+ }
85
+ async _call(input, _manager, runnable) {
86
+ const session = runnable?.configurable?.session;
87
+ const platform = input.platform || session?.platform;
88
+ const userId = input.targetUserId;
89
+ if (!platform || !userId) return 'Missing platform or user ID. Unable to adjust blacklist.';
90
+ if (input.action === 'add') {
91
+ options.store.recordBlacklist(platform, userId, input.note ?? 'tool');
92
+ options.cache.clear(platform, userId);
93
+ return `User ${platform}/${userId} added to blacklist.`;
94
+ }
95
+ const removed = options.store.removeBlacklist(platform, userId);
96
+ options.cache.clear(platform, userId);
97
+ return removed
98
+ ? `User ${platform}/${userId} removed from blacklist.`
99
+ : `User ${platform}/${userId} not found in blacklist.`;
100
+ }
101
+ })();
102
+ }
103
+
72
104
  function createToolRegistry(config, store, cache) {
73
105
  const options = {
74
106
  clamp: store.clamp,
@@ -80,6 +112,7 @@ function createToolRegistry(config, store, cache) {
80
112
  save: store.save,
81
113
  cache,
82
114
  updateRelationshipConfig: store.updateRelationshipConfig,
115
+ store,
83
116
  min: config.min,
84
117
  max: config.max
85
118
  };
@@ -87,8 +120,10 @@ function createToolRegistry(config, store, cache) {
87
120
  return {
88
121
  affinitySelector: () => true,
89
122
  relationshipSelector: () => true,
123
+ blacklistSelector: () => true,
90
124
  createAffinityTool: () => createAffinityTool(options),
91
- createRelationshipTool: () => createRelationshipTool(options)
125
+ createRelationshipTool: () => createRelationshipTool(options),
126
+ createBlacklistTool: () => createBlacklistTool(options)
92
127
  };
93
128
  }
94
129
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-chatluna-affinity",
3
- "description": "为 ChatLuna 提供{好感度}与{关系}变量并提供对应的工具调用和低好感自动拉黑功能。",
4
- "version": "0.0.3",
3
+ "description": "为 ChatLuna 提供{好感度}与{关系}变量并提供对应的工具调用和低好感自动拉黑功能。仅测试过伪装插件。",
4
+ "version": "0.0.5",
5
5
  "main": "lib/index.js",
6
6
  "typings": "lib/index.d.ts",
7
7
  "files": [
package/readme.md CHANGED
@@ -13,6 +13,7 @@
13
13
  - 调整工具:
14
14
  - `adjust_affinity` 将好感度设为指定数值,并同步关系区间。
15
15
  - `adjust_relationship` 将关系切换为自定义称谓,同时把好感度调至对应区间的下限。
16
+ - `adjust_blacklist` 管理自动拉黑名单,可快速拉黑或解除指定用户。
16
17
  - 特殊关系:可为指定用户记录“初始好感度 + 自定义称谓”,工具操作会实时写回配置。
17
18
 
18
19
  ## 主要配置
@@ -35,7 +36,13 @@
35
36
  | `autoBlacklist` | 自动拉黑记录列表(自动维护,可在控制台查看与编辑) |
36
37
  | `relationships` | 特殊关系配置:`userId`、`initialAffinity`、`relation`、`note` |
37
38
  | `relationshipAffinityLevels` | 区间 → 称谓映射,默认提供“陌生人/友好/亲近/挚友” |
38
- | `registerAffinityTool` / `registerRelationshipTool` | 是否注册对应 ChatLuna 工具 |
39
+ | `registerAffinityTool` / `registerRelationshipTool` / `registerBlacklistTool` | 是否注册对应 ChatLuna 工具 |
40
+ | `rankDefaultLimit` | 指令默认展示的排行榜人数 |
41
+ | `rankRenderAsImage` | 指令默认是否渲染排行榜为图片(需 `koishi-plugin-puppeteer`) |
42
+
43
+ ## 指令
44
+
45
+ - `affinity.rank [limit] [platform] [image|text]`:查看好感度排行,可指定人数、平台,并选择文本或图片输出(图片模式需 `koishi-plugin-puppeteer`)。
39
46
 
40
47
  ## 工具调用
41
48