/** * NIM SDK 会话管理器 * 负责会话列表的获取、监听和管理、消息发送 * 基于网易云信 NIM SDK v10.9.50 */ import { NIM, V2NIMConst } from '../dist/nim' class NIMConversationManager { constructor() { this.nim = null this.listeners = new Map() // 事件监听器集合 this.conversations = [] // 会话列表缓存 this.isInitialized = false } /** * 初始化会话管理器 * @param {Object} nimInstance - NIM SDK 实例 */ init(nimInstance) { if (this.isInitialized) { console.warn('⚠️ NIM会话管理器已经初始化') return } this.nim = nimInstance this.isInitialized = true // 注册 NIM SDK 事件监听 this.registerNIMEvents() console.log('✅ NIM会话管理器初始化成功') } /** * 注册 NIM SDK 事件监听 */ registerNIMEvents() { if (!this.nim) { console.error('❌ NIM实例不存在,无法注册事件') return } try { console.log('📡 开始注册 NIM SDK 事件监听器...') // 🔥 使用 addEventListener 方法注册会话监听器(官方推荐方式) // 会话新增 this.nim.V2NIMConversationService.on('onConversationCreated', (conversation) => { console.log('📝 NIM 会话新增:', conversation) this.handleConversationCreated(conversation) }) // 会话删除 this.nim.V2NIMConversationService.on('onConversationDeleted', (conversationIds) => { console.log('🗑️ NIM 会话删除:', conversationIds) this.handleConversationDeleted(conversationIds) }) // 会话更新 this.nim.V2NIMConversationService.on('onConversationChanged', (conversations) => { console.log('🔄 NIM 会话更新:', conversations) this.handleConversationChanged(conversations) }) // 🔥 使用 addEventListener 方法注册消息监听器(官方推荐方式) // 接收新消息 this.nim.V2NIMMessageService.on('onReceiveMessages', (messages) => { console.log('📨 NIM 收到新消息,数量:', messages?.length || 0, messages) this.handleNewMessages(messages) }) // 消息发送状态 this.nim.V2NIMMessageService.on('onSendMessage', (message) => { console.log('📤 NIM 消息发送中:', message) }) // 消息撤回 this.nim.V2NIMMessageService.on('onMessageRevokeNotifications', (revokeNotifications) => { console.log('↩️ NIM 消息撤回通知:', revokeNotifications) this.handleMessageRevoke(revokeNotifications) }) console.log('✅ NIM事件监听注册成功') } catch (error) { console.error('❌ 注册NIM事件监听失败:', error) } } /** * 获取会话列表 * @param {Number} offset - 偏移 * @param {Number} limit - 分页 * @returns {Promise} 会话列表 */ async getConversationList(offset = 0, limit = 10) { if (!this.nim) { throw new Error('NIM实例未初始化') } try { console.log('📋 开始获取NIM云端会话列表...') // 🔥 使用云端会话服务获取会话列表 const result = await this.nim.V2NIMConversationService.getConversationList( offset, limit // { // offset: options.offset || 0, // limit: options.limit || 100 // } ) console.log('✅ 获取云端会话列表成功:', result) // 转换为统一格式 const conversations = this.normalizeConversations(result?.conversationList || []) // 更新缓存 this.conversations = conversations return conversations } catch (error) { console.error('❌ 获取云端会话列表失败:', error) throw error } } /** * 将 NIM 会话数据转换为应用统一格式 * @param {Array} nimConversations - NIM会话数据 * @returns {Array} 标准化的会话列表 */ normalizeConversations(nimConversations) { if (!Array.isArray(nimConversations)) { return [] } return nimConversations.map(conv => this.normalizeConversation(conv)) } /** * 标准化单个会话 * @param {Object} conv - NIM会话对象 * @returns {Object} 标准化的会话对象 */ normalizeConversation(conv) { if (!conv) return null // 解析会话类型 const conversationType = conv.conversationType || conv.type const isGroupChat = conversationType === V2NIMConst.V2NIMConversationType.V2NIM_CONVERSATION_TYPE_TEAM // 获取最后一条消息 const lastMessage = conv.lastMessage || {} const lastMessageText = this.getMessagePreview(lastMessage) // 🔥 正确获取时间:V2NIMLastMessage.messageRefer.createTime const messageRefer = lastMessage.messageRefer || {} const messageTime = messageRefer.createTime || conv.updateTime || 0 return { // 基础信息 id: conv.conversationId, conversationId: conv.conversationId, type: isGroupChat ? 'group' : 'single', chatType: isGroupChat ? 1 : 0, // 对方信息 targetId: this.extractTargetId(conv.conversationId, isGroupChat), name: conv.name || conv.conversationName || '未知', avatar: conv.avatar || '', // 会话状态 isPinned: conv.stickTop || false, isTop: conv.stickTop || false, isMuted: conv.mute || false, unreadCount: conv.unreadCount || 0, // 🔥 最后消息信息 - 使用消息时间(messageRefer.createTime) lastMessage: lastMessageText || '', lastMessageType: this.getMessageType(lastMessage), lastMessageTime: this.formatMessageTime(messageTime), lastMsgTime: messageTime, messageStatus: this.getMessageStatus(lastMessage), // 在线状态(单聊) isOnline: false, // 需要额外获取 // 原始 NIM 数据(用于调试) _nimData: conv } } /** * 从会话ID中提取目标用户ID * @param {String} conversationId - 会话ID * @param {Boolean} isGroup - 是否是群聊 * @returns {String} 目标ID */ extractTargetId(conversationId, isGroup) { if (!conversationId) return '' // NIM会话ID格式: p2p-账号 或 team-群ID if (isGroup) { return conversationId.replace('team-', '') } else { return conversationId.replace('p2p-', '') } } /** * 获取消息预览文本 * @param {Object} message - 消息对象 * @returns {String} 预览文本 */ getMessagePreview(message) { if (!message) return '' const messageType = message.messageType // 兼容处理:如果 messageType 不存在,尝试从 text 字段获取文本 if (messageType === undefined || messageType === null) { return message.text || '' } switch (messageType) { case V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_TEXT: return message.text || '' case V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_IMAGE: return '[图片]' case V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_AUDIO: return '[语音]' case V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_VIDEO: return '[视频]' case V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_FILE: return '[文件]' case V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_LOCATION: return '[位置]' case V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_NOTIFICATION: return '[通知]' case V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_TIP: return '[提示]' case V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_CUSTOM: return '[自定义消息]' default: return '[未知消息]' } } /** * 获取消息类型 * @param {Object} message - 消息对象 * @returns {String} 消息类型 */ getMessageType(message) { if (!message || !message.messageType) return 'text' const typeMap = { [V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_TEXT]: 'text', [V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_IMAGE]: 'image', [V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_AUDIO]: 'audio', [V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_VIDEO]: 'video', [V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_FILE]: 'file', [V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_LOCATION]: 'location', [V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_NOTIFICATION]: 'notification', [V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_TIP]: 'tip', [V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_CUSTOM]: 'custom' } return typeMap[message.messageType] || 'text' } /** * 获取消息状态 * @param {Object} message - 消息对象 * @returns {String} 消息状态 */ getMessageStatus(message) { if (!message) return 'sent' if (message.sendingState === V2NIMConst.V2NIMMessageSendingState.V2NIM_MESSAGE_SENDING_STATE_SENDING) { return 'sending' } else if (message.sendingState === V2NIMConst.V2NIMMessageSendingState.V2NIM_MESSAGE_SENDING_STATE_FAILED) { return 'failed' } else { return 'sent' } } /** * 格式化消息时间(Telegram风格) * @param {Number} timestamp - 时间戳 * @returns {String} 格式化的时间 */ formatMessageTime(timestamp) { if (!timestamp) return '' const now = new Date() const msg = new Date(timestamp) // 今天:HH:mm if (now.toDateString() === msg.toDateString()) { const hh = msg.getHours().toString().padStart(2, '0') const mm = msg.getMinutes().toString().padStart(2, '0') return `${hh}:${mm}` } // 昨天 const yesterday = new Date(now) yesterday.setDate(yesterday.getDate() - 1) if (yesterday.toDateString() === msg.toDateString()) { return '昨天' } // 更早:YYYY/MM/DD const year = msg.getFullYear() const month = (msg.getMonth() + 1).toString().padStart(2, '0') const day = msg.getDate().toString().padStart(2, '0') return `${year}/${month}/${day}` } /** * 处理会话新增 */ handleConversationCreated(conversation) { const normalized = this.normalizeConversation(conversation) if (normalized) { this.conversations.unshift(normalized) this.emit('conversation_created', normalized) } } /** * 处理会话删除 */ handleConversationDeleted(conversationIds) { if (!Array.isArray(conversationIds)) return this.conversations = this.conversations.filter( conv => !conversationIds.includes(conv.conversationId) ) this.emit('conversation_deleted', conversationIds) } /** * 处理会话更新 */ handleConversationChanged(conversations) { if (!Array.isArray(conversations)) return conversations.forEach(updatedConv => { const normalized = this.normalizeConversation(updatedConv) if (!normalized) return const index = this.conversations.findIndex( conv => conv.conversationId === normalized.conversationId ) if (index >= 0) { this.conversations[index] = normalized } else { this.conversations.unshift(normalized) } }) this.emit('conversation_changed', this.conversations) } /** * 处理新消息(更新会话) */ handleNewMessages(messages) { if (!Array.isArray(messages)) { console.warn('⚠️ handleNewMessages 收到非数组消息:', messages) return } console.log('📨 处理新消息,数量:', messages.length) let hasUpdates = false messages.forEach(message => { const conversationId = message.conversationId if (!conversationId) { console.warn('⚠️ 消息缺少 conversationId:', message) return } const index = this.conversations.findIndex( conv => conv.conversationId === conversationId ) if (index >= 0) { // 更新现有会话 const conv = this.conversations[index] conv.lastMessage = this.getMessagePreview(message) conv.lastMessageType = this.getMessageType(message) // 🔥 使用消息的 createTime,如果没有则保持原时间 const messageTime = message.createTime || conv.lastMsgTime || 0 conv.lastMessageTime = this.formatMessageTime(messageTime) conv.lastMsgTime = messageTime conv.unreadCount = (conv.unreadCount || 0) + 1 // 移到列表顶部 this.conversations.splice(index, 1) this.conversations.unshift(conv) hasUpdates = true console.log('✅ 已更新会话:', conv.name || conversationId, '未读数:', conv.unreadCount) } else { console.log('⚠️ 会话不在本地列表中,conversationId:', conversationId) } }) if (hasUpdates) { console.log('📤 触发 new_message 事件,通知页面刷新') } // 🔥 无论会话是否存在于本地列表,都触发事件让页面处理 // 页面会根据情况决定是更新现有会话还是重新加载会话列表 this.emit('new_message', messages) } /** * 处理消息撤回 */ handleMessageRevoke(revokeNotifications) { if (!Array.isArray(revokeNotifications)) return revokeNotifications.forEach(notification => { const conversationId = notification.conversationId if (!conversationId) return const conv = this.conversations.find( c => c.conversationId === conversationId ) if (conv && notification.messageRefer) { // 如果撤回的是最后一条消息,更新预览 conv.lastMessage = '已撤回' conv.lastMessageType = 'system' } }) this.emit('message_revoked', revokeNotifications) } /** * 置顶/取消置顶会话 * @param {String} conversationId - 会话ID * @param {Boolean} stickTop - 是否置顶 */ async setConversationStickTop(conversationId, stickTop) { if (!this.nim) { throw new Error('NIM实例未初始化') } try { // 🔥 使用云端会话服务设置置顶 await this.nim.V2NIMConversationService.setConversationStickTop( conversationId, stickTop ) // 更新本地缓存 const conv = this.conversations.find(c => c.conversationId === conversationId) if (conv) { conv.isPinned = stickTop conv.isTop = stickTop } console.log(`✅ ${stickTop ? '置顶' : '取消置顶'}会话成功:`, conversationId) return true } catch (error) { console.error('❌ 设置会话置顶失败:', error) throw error } } /** * 删除会话 * @param {String} conversationId - 会话ID */ async deleteConversation(conversationId) { if (!this.nim) { throw new Error('NIM实例未初始化') } try { // 🔥 使用云端会话服务删除会话 await this.nim.V2NIMConversationService.deleteConversation(conversationId) // 从缓存中移除 this.conversations = this.conversations.filter( conv => conv.conversationId !== conversationId ) console.log('✅ 删除会话成功:', conversationId) return true } catch (error) { console.error('❌ 删除会话失败:', error) throw error } } /** * 清空会话未读数(标记会话已读) * @param {String} conversationId - 会话ID */ async clearUnreadCount(conversationId) { if (!this.nim) { throw new Error('NIM实例未初始化') } if (!conversationId || !conversationId.trim()) { throw new Error('会话ID不能为空') } try { console.log('📖 开始清空会话未读数 (NIM SDK V2):', conversationId) // 🔥 NIM SDK V2 使用 clearUnreadCountByIds 方法清除未读数 // 参数是会话ID数组 await this.nim.V2NIMConversationService.clearUnreadCountByIds([conversationId]) // 更新本地缓存 const conv = this.conversations.find(c => c.conversationId === conversationId) if (conv) { conv.unreadCount = 0 } console.log('✅ 清空会话未读数成功:', conversationId) return true } catch (error) { console.error('❌ 清空会话未读数失败:', error) throw error } } /** * 标记会话已读(别名方法,方便调用) * @param {String} conversationId - 会话ID */ async markConversationRead(conversationId) { return await this.clearUnreadCount(conversationId) } /** * 获取总未读数 * @returns {Number} 总未读数 */ getTotalUnreadCount() { return this.conversations.reduce((sum, conv) => sum + (conv.unreadCount || 0), 0) } /** * 注册事件监听器 * @param {String} event - 事件名称 * @param {Function} callback - 回调函数 */ on(event, callback) { if (!this.listeners.has(event)) { this.listeners.set(event, []) } this.listeners.get(event).push(callback) } /** * 移除事件监听器 * @param {String} event - 事件名称 * @param {Function} callback - 回调函数 */ off(event, callback) { if (!this.listeners.has(event)) return const callbacks = this.listeners.get(event) const index = callbacks.indexOf(callback) if (index > -1) { callbacks.splice(index, 1) } } /** * 触发事件 * @param {String} event - 事件名称 * @param {*} data - 事件数据 */ emit(event, data) { if (!this.listeners.has(event)) return const callbacks = this.listeners.get(event) callbacks.forEach(callback => { try { callback(data) } catch (error) { console.error(`❌ 执行事件回调失败 [${event}]:`, error) } }) } /** * 销毁管理器 */ destroy() { this.listeners.clear() this.conversations = [] this.nim = null this.isInitialized = false console.log('✅ NIM会话管理器已销毁') } // ==================== 🔥 消息发送功能 ==================== /** * 发送文本消息 * @param {String} conversationId - 会话ID * @param {String} text - 消息内容 * @param {Object} options - 可选参数 * @returns {Promise} 发送结果 */ async sendTextMessage(conversationId, text, options = {}) { if (!this.nim || !this.nim.V2NIMMessageService) { throw new Error('NIM消息服务未初始化') } if (!conversationId || !conversationId.trim()) { throw new Error('会话ID不能为空') } if (!text || !text.trim()) { throw new Error('消息内容不能为空') } try { console.log('📤 发送文本消息:', { conversationId, text }) // 创建文本消息 const message = this.nim.V2NIMMessageCreator.createTextMessage(text) // 发送消息 const sendResult = await this.nim.V2NIMMessageService.sendMessage( message, conversationId ) console.log('✅ 文本消息发送成功') // NIM SDK 返回的是包装对象,真正的消息在 sendResult.message 中 const actualMessage = sendResult.message || sendResult return { success: true, messageId: actualMessage.messageClientId, serverId: actualMessage.messageServerId, createTime: actualMessage.createTime, conversationId: conversationId, message: actualMessage } } catch (error) { console.error('❌ 发送文本消息失败:', error) throw error } } /** * 发送图片消息 * @param {String} conversationId - 会话ID * @param {String} imagePath - 图片路径(本地临时路径或URL) * @param {Object} options - 可选参数 * @returns {Promise} 发送结果 */ async sendImageMessage(conversationId, imagePath, options = {}) { if (!this.nim || !this.nim.V2NIMMessageService) { throw new Error('NIM消息服务未初始化') } if (!conversationId || !conversationId.trim()) { throw new Error('会话ID不能为空') } if (!imagePath) { throw new Error('图片路径不能为空') } try { console.log('📤 发送图片消息:', { conversationId, imagePath }) // 创建图片消息 const message = this.nim.V2NIMMessageCreator.createImageMessage(imagePath) // 发送消息 const sendResult = await this.nim.V2NIMMessageService.sendMessage( message, conversationId ) console.log('✅ 图片消息发送成功') const actualMessage = sendResult.message || sendResult return { success: true, conversationId, messageId: actualMessage.messageClientId, serverId: actualMessage.messageServerId, createTime: actualMessage.createTime, message: actualMessage } } catch (error) { console.error('❌ 发送图片消息失败:', error) throw error } } /** * 发送语音消息 * @param {String} conversationId - 会话ID(直接使用当前会话ID) * @param {String} audioPath - 语音文件路径(微信小程序临时文件路径) * @param {Number} duration - 语音时长(毫秒) * @param {Object} options - 可选参数 * @returns {Promise} 发送结果 */ async sendAudioMessage(conversationId, audioPath, duration, options = {}) { if (!this.nim || !this.nim.V2NIMMessageService) { throw new Error('NIM消息服务未初始化') } if (!conversationId || !conversationId.trim()) { throw new Error('会话ID不能为空') } if (!audioPath || typeof audioPath !== 'string' || !audioPath.trim()) { console.error('❌ 语音文件路径无效:', audioPath); throw new Error('语音文件路径不能为空') } try { console.log('📤 发送语音消息:', { conversationId, audioPath, duration }) // 🔥 小程序环境下创建语音消息需要使用特殊参数 // 参考: https://doc.yunxin.163.com/messaging2/guide/Dk0MTA5MDI?platform=miniapp const message = this.nim.V2NIMMessageCreator.createAudioMessage( audioPath // 微信小程序临时文件路径 ) console.log('✅ 创建语音消息对象成功') // 发送消息 const sendResult = await this.nim.V2NIMMessageService.sendMessage( message, conversationId ) console.log('✅ 语音消息发送成功') const actualMessage = sendResult.message || sendResult return { success: true, conversationId, messageId: actualMessage.messageClientId, serverId: actualMessage.messageServerId, createTime: actualMessage.createTime, message: actualMessage } } catch (error) { console.error('❌ 发送语音消息失败:', error) throw error } } /** * 发送视频消息 * @param {String} conversationId - 会话ID(直接使用当前会话ID) * @param {String} videoPath - 视频文件路径 * @param {Number} duration - 视频时长(毫秒) * @param {Object} options - 可选参数 * @returns {Promise} 发送结果 */ async sendVideoMessage(conversationId, videoPath, duration, options = {}) { if (!this.nim || !this.nim.V2NIMMessageService) { throw new Error('NIM消息服务未初始化') } if (!conversationId || !conversationId.trim()) { throw new Error('会话ID不能为空') } if (!videoPath) { throw new Error('视频文件路径不能为空') } try { console.log('📤 发送视频消息:', { conversationId, duration }) // 创建视频消息 const message = this.nim.V2NIMMessageCreator.createVideoMessage(videoPath) // 发送消息 const sendResult = await this.nim.V2NIMMessageService.sendMessage( message, conversationId ) console.log('✅ 视频消息发送成功') const actualMessage = sendResult.message || sendResult return { success: true, conversationId, messageId: actualMessage.messageClientId, serverId: actualMessage.messageServerId, createTime: actualMessage.createTime, message: actualMessage } } catch (error) { console.error('❌ 发送视频消息失败:', error) throw error } } /** * 发送文件消息 * @param {String} conversationId - 会话ID(直接使用当前会话ID) * @param {String} filePath - 文件路径 * @param {Object} options - 可选参数 * @returns {Promise} 发送结果 */ async sendFileMessage(conversationId, filePath, options = {}) { if (!this.nim || !this.nim.V2NIMMessageService) { throw new Error('NIM消息服务未初始化') } if (!conversationId || !conversationId.trim()) { throw new Error('会话ID不能为空') } if (!filePath) { throw new Error('文件路径不能为空') } try { console.log('📤 发送文件消息:', { conversationId }) // 创建文件消息 const message = this.nim.V2NIMMessageCreator.createFileMessage(filePath) // 发送消息 const sendResult = await this.nim.V2NIMMessageService.sendMessage( message, conversationId ) console.log('✅ 文件消息发送成功') const actualMessage = sendResult.message || sendResult return { success: true, conversationId, messageId: actualMessage.messageClientId, serverId: actualMessage.messageServerId, createTime: actualMessage.createTime, message: actualMessage } } catch (error) { console.error('❌ 发送文件消息失败:', error) throw error } } /** * 发送位置消息 * @param {String} conversationId - 会话ID(直接使用当前会话ID) * @param {Number} latitude - 纬度 * @param {Number} longitude - 经度 * @param {String} address - 地址描述 * @param {Object} options - 可选参数 * @returns {Promise} 发送结果 */ async sendLocationMessage(conversationId, latitude, longitude, address, options = {}) { if (!this.nim || !this.nim.V2NIMMessageService) { throw new Error('NIM消息服务未初始化') } if (!conversationId || !conversationId.trim()) { throw new Error('会话ID不能为空') } if (typeof latitude !== 'number' || typeof longitude !== 'number') { throw new Error('经纬度必须是数字') } try { console.log('📤 发送位置消息:', { conversationId, latitude, longitude }) // 创建位置消息 const message = this.nim.V2NIMMessageCreator.createLocationMessage(latitude, longitude, address || '') // 发送消息 const sendResult = await this.nim.V2NIMMessageService.sendMessage( message, conversationId ) console.log('✅ 位置消息发送成功') const actualMessage = sendResult.message || sendResult return { success: true, conversationId, messageId: actualMessage.messageClientId, serverId: actualMessage.messageServerId, createTime: actualMessage.createTime, message: actualMessage } } catch (error) { console.error('❌ 发送位置消息失败:', error) throw error } } /** * 撤回消息 * @param {String} conversationId - 会话ID * @param {Object} message - 消息对象(必须包含完整的消息信息) * @returns {Promise} 撤回结果 */ async recallMessage(conversationId, message) { if (!this.nim) { throw new Error('NIM实例未初始化') } try { console.log('↩️ 撤回消息:', { conversationId, messageId: message?.messageServerId || message?.messageClientId }) // 🔥 使用 NIM SDK V2 的 revokeMessage 方法 // 根据官方文档,revokeMessage 需要传入完整的消息对象和撤回参数 // 参数1: message - 完整的消息对象 // 参数2: revokeParams - V2NIMMessageRevokeParams 对象(可选) const revokeResult = await this.nim.V2NIMMessageService.revokeMessage( message, { serverExtension: '', // 服务端扩展字段(可选) postscript: '' // 撤回附言(可选) } ) console.log('✅ 消息撤回成功:', revokeResult) return { success: true, conversationId, messageId: message.messageServerId || message.messageClientId, revokeTime: Date.now() } } catch (error) { console.error('❌ 撤回消息失败:', error) throw error } } /** * 检查管理器是否已初始化 * @returns {Boolean} 是否已初始化 */ getInitStatus() { return this.isInitialized && this.nim !== null } } // 导出单例 const nimConversationManager = new NIMConversationManager() module.exports = nimConversationManager