Compare commits

..

6 Commits

Author SHA1 Message Date
Simon Laux
e20dc44eef docs: custom paths and tokens in DCACCOUNT ar codes are not supported
anymore
2026-01-16 21:17:28 +01:00
Simon Laux
e1ebf3e96d refactor: don't use concat! in sql statements (#7720) 2026-01-15 22:44:53 +00:00
Simon Laux
76171aea2e fix: hide incoming broadcasts in DC_GCL_FOR_FORWARDING (#7726)
you can't write to those chats, so you also can not forward to them.

Closes #7702
2026-01-15 22:26:05 +00:00
Hocuri
96b8d1720e fix: Use only lowercase letters for stats id (#7700)
If the user enables statistics-sending in the advanced settings, they
will be asked whether they also want to take part in a survey. We use a
short ID to then link the survey result to the sent statistics.

However, the survey website didn't like our base64 ids, especially the
fact that the id could contain a `-`. This PR makes it so that the id
only contains lowecase letters.
2026-01-15 18:51:04 +01:00
missytake
47b49fd02e api(jsonrpc): add run_until parameter for bots (#7688)
This commit also makes testing hooks easier, as it allows to process
events and run hooks on them, until a certain event occurs.

---------

Co-authored-by: iequidoo <117991069+iequidoo@users.noreply.github.com>
2026-01-14 19:58:44 +01:00
iequidoo
f50e3d6ffa feat: Don't scale up Origin of multiple and broadcast recipients when sending a message
84161f4202 promotes group members to `Origin::IncomingTo` when
accepting it, instead of `CreateChat` as before, but this changes almost nothing because it happens
rarely that the user only accepts a group and writes nothing there soon. Now if a message has
multiple recipients, i.e. it's a 3-or-more-member group, or if it's a broadcast message, we don't
scale up its recipients to `Origin::OutgoingTo`.
2026-01-14 14:32:59 -03:00
12 changed files with 170 additions and 94 deletions

View File

@@ -894,7 +894,7 @@ int dc_preconfigure_keypair (dc_context_t* context, const cha
* the pseudo-chat DC_CHAT_ID_ARCHIVED_LINK is added if there are _any_ archived
* chats
* - the flag DC_GCL_FOR_FORWARDING sorts "Saved messages" to the top of the chatlist
* and hides the "Device chat" and contact requests.
* and hides the "Device chat", contact requests and incoming broadcasts.
* typically used on forwarding, may be combined with DC_GCL_NO_SPECIALS
* to also hide the archive link.
* - if the flag DC_GCL_NO_SPECIALS is set, archive link is not added

View File

@@ -44,8 +44,13 @@ class AttrDict(dict):
super().__setattr__(attr, val)
def _forever(_event: AttrDict) -> bool:
return False
def run_client_cli(
hooks: Optional[Iterable[Tuple[Callable, Union[type, "EventFilter"]]]] = None,
until: Callable[[AttrDict], bool] = _forever,
argv: Optional[list] = None,
**kwargs,
) -> None:
@@ -55,10 +60,11 @@ def run_client_cli(
"""
from .client import Client
_run_cli(Client, hooks, argv, **kwargs)
_run_cli(Client, until, hooks, argv, **kwargs)
def run_bot_cli(
until: Callable[[AttrDict], bool] = _forever,
hooks: Optional[Iterable[Tuple[Callable, Union[type, "EventFilter"]]]] = None,
argv: Optional[list] = None,
**kwargs,
@@ -69,11 +75,12 @@ def run_bot_cli(
"""
from .client import Bot
_run_cli(Bot, hooks, argv, **kwargs)
_run_cli(Bot, until, hooks, argv, **kwargs)
def _run_cli(
client_type: Type["Client"],
until: Callable[[AttrDict], bool] = _forever,
hooks: Optional[Iterable[Tuple[Callable, Union[type, "EventFilter"]]]] = None,
argv: Optional[list] = None,
**kwargs,
@@ -111,7 +118,7 @@ def _run_cli(
kwargs={"email": args.email, "password": args.password},
)
configure_thread.start()
client.run_forever()
client.run_until(until)
def extract_addr(text: str) -> str:

View File

@@ -14,6 +14,7 @@ from typing import (
from ._utils import (
AttrDict,
_forever,
parse_system_add_remove,
parse_system_image_changed,
parse_system_title_changed,
@@ -91,19 +92,28 @@ class Client:
def run_forever(self) -> None:
"""Process events forever."""
self.run_until(lambda _: False)
self.run_until(_forever)
def run_until(self, func: Callable[[AttrDict], bool]) -> AttrDict:
"""Process events until the given callable evaluates to True.
The callable should accept an AttrDict object representing the
last processed event. The event is returned when the callable
evaluates to True.
"""
"""Start the event processing loop."""
self.logger.debug("Listening to incoming events...")
if self.is_configured():
self.account.start_io()
self._process_messages() # Process old messages.
return self._process_events(until_func=func) # Loop over incoming events
def _process_events(
self,
until_func: Callable[[AttrDict], bool],
until_event: EventType = False,
) -> AttrDict:
"""Process events until the given callable evaluates to True,
or until a certain event happens.
The until_func callable should accept an AttrDict object representing
the last processed event. The event is returned when the callable
evaluates to True.
"""
while True:
event = self.account.wait_for_event()
event["kind"] = EventType(event.kind)
@@ -112,10 +122,13 @@ class Client:
if event.kind == EventType.INCOMING_MSG:
self._process_messages()
stop = func(event)
stop = until_func(event)
if stop:
return event
if event.kind == until_event:
return event
def _on_event(self, event: AttrDict, filter_type: Type[EventFilter] = RawEvent) -> None:
for hook, evfilter in self._hooks.get(filter_type, []):
if evfilter.filter(event):

View File

@@ -3316,10 +3316,7 @@ pub async fn marknoticed_chat(context: &Context, chat_id: ChatId) -> Result<()>
context.emit_event(EventType::MsgsNoticed(chat_id));
chatlist_events::emit_chatlist_item_changed(context, chat_id);
if !chat_id.is_archived_link() {
// prevents event duplication when marking all archived chats as noticed
context.on_archived_chats_maybe_noticed();
}
context.on_archived_chats_maybe_noticed();
Ok(())
}
@@ -5113,14 +5110,12 @@ impl Context {
}
}
/// Emits the appropriate `MsgsChanged` and `ChatlistItemChanged` event.
/// Should be called if the number of unnoticed
/// Emits the appropriate `MsgsChanged` event. Should be called if the number of unnoticed
/// archived chats could decrease. In general we don't want to make an extra db query to know if
/// a noticed chat is archived. Emitting events should be cheap, a false-positive `MsgsChanged`
/// is ok.
pub(crate) fn on_archived_chats_maybe_noticed(&self) {
self.emit_msgs_changed_without_msg_id(DC_CHAT_ID_ARCHIVED_LINK);
chatlist_events::emit_chatlist_item_changed(self, DC_CHAT_ID_ARCHIVED_LINK);
}
}

View File

@@ -76,7 +76,7 @@ impl Chatlist {
/// the pseudo-chat DC_CHAT_ID_ARCHIVED_LINK is added if there are *any* archived
/// chats
/// - the flag DC_GCL_FOR_FORWARDING sorts "Saved messages" to the top of the chatlist
/// and hides the device-chat and contact requests
/// and hides the device-chat, contact requests and incoming broadcasts.
/// typically used on forwarding, may be combined with DC_GCL_NO_SPECIALS
/// - if the flag DC_GCL_NO_SPECIALS is set, archive link is not added
/// to the list (may be used eg. for selecting chats on forwarding, the flag is
@@ -224,8 +224,9 @@ impl Chatlist {
let process_rows = |rows: rusqlite::AndThenRows<_>| {
rows.filter_map(|row: std::result::Result<(_, _, Params, _), _>| match row {
Ok((chat_id, typ, param, msg_id)) => {
if typ == Chattype::Mailinglist
&& param.get(Param::ListPost).is_none_or_empty()
if typ == Chattype::InBroadcast
|| (typ == Chattype::Mailinglist
&& param.get(Param::ListPost).is_none_or_empty())
{
None
} else {
@@ -597,6 +598,41 @@ mod tests {
assert_eq!(chats.len(), 1);
}
/// Test that DC_CHAT_TYPE_IN_BROADCAST are hidden
/// and DC_CHAT_TYPE_OUT_BROADCAST are shown in chatlist for forwarding.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_broadcast_visiblity_on_forward() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let alice_broadcast_a_id = create_broadcast(alice, "Channel Alice".to_string()).await?;
let qr = get_securejoin_qr(alice, Some(alice_broadcast_a_id))
.await
.unwrap();
let bob_broadcast_a_id = tcm.exec_securejoin_qr(bob, alice, &qr).await;
let bob_broadcast_b_id = create_broadcast(bob, "Channel Bob".to_string()).await?;
let chats = Chatlist::try_load(bob, DC_GCL_FOR_FORWARDING, None, None)
.await
.unwrap();
assert!(
!chats
.iter()
.any(|(chat_id, _)| chat_id == &bob_broadcast_a_id),
"alice broadcast is not shown in bobs forwarding chatlist"
);
assert!(
chats
.iter()
.any(|(chat_id, _)| chat_id == &bob_broadcast_b_id),
"bobs own broadcast is shown in his forwarding chatlist"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_search_special_chat_names() {
let t = TestContext::new_alice().await;

View File

@@ -1120,21 +1120,19 @@ impl Context {
let list = self
.sql
.query_map_vec(
concat!(
"SELECT m.id",
" FROM msgs m",
" LEFT JOIN contacts ct",
" ON m.from_id=ct.id",
" LEFT JOIN chats c",
" ON m.chat_id=c.id",
" WHERE m.state=?",
" AND m.hidden=0",
" AND m.chat_id>9",
" AND ct.blocked=0",
" AND c.blocked=0",
" AND NOT(c.muted_until=-1 OR c.muted_until>?)",
" ORDER BY m.timestamp DESC,m.id DESC;"
),
"SELECT m.id
FROM msgs m
LEFT JOIN contacts ct
ON m.from_id=ct.id
LEFT JOIN chats c
ON m.chat_id=c.id
WHERE m.state=?
AND m.hidden=0
AND m.chat_id>9
AND ct.blocked=0
AND c.blocked=0
AND NOT(c.muted_until=-1 OR c.muted_until>?)
ORDER BY m.timestamp DESC,m.id DESC",
(MessageState::InFresh, time()),
|row| {
let msg_id: MsgId = row.get(0)?;

View File

@@ -87,12 +87,10 @@ impl MsgId {
let result = context
.sql
.query_row_optional(
concat!(
"SELECT m.state, mdns.msg_id",
" FROM msgs m LEFT JOIN msgs_mdns mdns ON mdns.msg_id=m.id",
" WHERE id=?",
" LIMIT 1",
),
"SELECT m.state, mdns.msg_id
FROM msgs m LEFT JOIN msgs_mdns mdns ON mdns.msg_id=m.id
WHERE id=?
LIMIT 1",
(self,),
|row| {
let state: MessageState = row.get(0)?;
@@ -501,40 +499,38 @@ impl Message {
let mut msg = context
.sql
.query_row_optional(
concat!(
"SELECT",
" m.id AS id,",
" rfc724_mid AS rfc724mid,",
" pre_rfc724_mid AS pre_rfc724mid,",
" m.mime_in_reply_to AS mime_in_reply_to,",
" m.chat_id AS chat_id,",
" m.from_id AS from_id,",
" m.to_id AS to_id,",
" m.timestamp AS timestamp,",
" m.timestamp_sent AS timestamp_sent,",
" m.timestamp_rcvd AS timestamp_rcvd,",
" m.ephemeral_timer AS ephemeral_timer,",
" m.ephemeral_timestamp AS ephemeral_timestamp,",
" m.type AS type,",
" m.state AS state,",
" mdns.msg_id AS mdn_msg_id,",
" m.download_state AS download_state,",
" m.error AS error,",
" m.msgrmsg AS msgrmsg,",
" m.starred AS original_msg_id,",
" m.mime_modified AS mime_modified,",
" m.txt AS txt,",
" m.subject AS subject,",
" m.param AS param,",
" m.hidden AS hidden,",
" m.location_id AS location,",
" c.blocked AS blocked",
" FROM msgs m",
" LEFT JOIN chats c ON c.id=m.chat_id",
" LEFT JOIN msgs_mdns mdns ON mdns.msg_id=m.id",
" WHERE m.id=? AND chat_id!=3",
" LIMIT 1",
),
"SELECT
m.id AS id,
rfc724_mid AS rfc724mid,
pre_rfc724_mid AS pre_rfc724mid,
m.mime_in_reply_to AS mime_in_reply_to,
m.chat_id AS chat_id,
m.from_id AS from_id,
m.to_id AS to_id,
m.timestamp AS timestamp,
m.timestamp_sent AS timestamp_sent,
m.timestamp_rcvd AS timestamp_rcvd,
m.ephemeral_timer AS ephemeral_timer,
m.ephemeral_timestamp AS ephemeral_timestamp,
m.type AS type,
m.state AS state,
mdns.msg_id AS mdn_msg_id,
m.download_state AS download_state,
m.error AS error,
m.msgrmsg AS msgrmsg,
m.starred AS original_msg_id,
m.mime_modified AS mime_modified,
m.txt AS txt,
m.subject AS subject,
m.param AS param,
m.hidden AS hidden,
m.location_id AS location,
c.blocked AS blocked
FROM msgs m
LEFT JOIN chats c ON c.id=m.chat_id
LEFT JOIN msgs_mdns mdns ON mdns.msg_id=m.id
WHERE m.id=? AND chat_id!=3
LIMIT 1",
(id,),
|row| {
let state: MessageState = row.get("state")?;

View File

@@ -426,8 +426,16 @@ impl MimeFactory {
},
)
.await?;
let recipient_ids: Vec<_> = recipient_ids.into_iter().collect();
ContactId::scaleup_origin(context, &recipient_ids, Origin::OutgoingTo).await?;
let recipient_ids: Vec<_> = recipient_ids
.into_iter()
.filter(|id| *id != ContactId::SELF)
.collect();
if recipient_ids.len() == 1
&& msg.param.get_cmd() != SystemMessage::MemberRemovedFromGroup
&& chat.typ != Chattype::OutBroadcast
{
ContactId::scaleup_origin(context, &recipient_ids, Origin::OutgoingTo).await?;
}
if !msg.is_system_message()
&& msg.param.get_int(Param::Reaction).unwrap_or_default() == 0

View File

@@ -2481,18 +2481,16 @@ async fn handle_mdn(
let Some((msg_id, chat_id, has_mdns, is_dup)) = context
.sql
.query_row_optional(
concat!(
"SELECT",
" m.id AS msg_id,",
" c.id AS chat_id,",
" mdns.contact_id AS mdn_contact",
" FROM msgs m ",
" LEFT JOIN chats c ON m.chat_id=c.id",
" LEFT JOIN msgs_mdns mdns ON mdns.msg_id=m.id",
" WHERE rfc724_mid=? AND from_id=1",
" ORDER BY msg_id DESC, mdn_contact=? DESC",
" LIMIT 1",
),
"SELECT
m.id AS msg_id,
c.id AS chat_id,
mdns.contact_id AS mdn_contact
FROM msgs m
LEFT JOIN chats c ON m.chat_id=c.id
LEFT JOIN msgs_mdns mdns ON mdns.msg_id=m.id
WHERE rfc724_mid=? AND from_id=1
ORDER BY msg_id DESC, mdn_contact=? DESC
LIMIT 1",
(&rfc724_mid, from_id),
|row| {
let msg_id: MsgId = row.get("msg_id")?;

View File

@@ -655,7 +655,6 @@ async fn decode_ideltachat(context: &Context, prefix: &str, qr: &str) -> Result<
/// scheme: `DCACCOUNT:example.org`
/// or `DCACCOUNT:https://example.org/new`
/// or `DCACCOUNT:https://example.org/new_email?t=1w_7wDjgjelxeX884x96v3`
fn decode_account(qr: &str) -> Result<Qr> {
let payload = qr
.get(DCACCOUNT_SCHEME.len()..)

View File

@@ -3878,6 +3878,29 @@ async fn test_group_contacts_goto_bottom() -> Result<()> {
let contacts = Contact::get_all(bob, 0, None).await?;
assert_eq!(contacts.len(), 2);
assert_eq!(contacts[0], bob_fiona_id);
send_text_msg(
bob,
bob_chat_id,
"Hi Alice, stay down in my contact list".to_string(),
)
.await?;
bob.pop_sent_msg().await;
let contacts = Contact::get_all(bob, 0, None).await?;
assert_eq!(contacts[0], bob_fiona_id);
remove_contact_from_chat(bob, bob_chat_id, bob_fiona_id).await?;
bob.pop_sent_msg().await;
let contacts = Contact::get_all(bob, 0, None).await?;
// Fiona is still the 0th contact. This makes sense, maybe Bob is going to remove Alice from the
// chat too, so no need to make Alice a more "important" contact yet.
assert_eq!(contacts[0], bob_fiona_id);
send_text_msg(bob, bob_chat_id, "Alice, jump up!".to_string()).await?;
bob.pop_sent_msg().await;
let contacts = Contact::get_all(bob, 0, None).await?;
let bob_alice_id = bob.add_or_lookup_contact_id(alice).await;
assert_eq!(contacts[0], bob_alice_id);
Ok(())
}

View File

@@ -9,6 +9,7 @@ use anyhow::{Context as _, Result};
use deltachat_derive::FromSql;
use num_traits::ToPrimitive;
use pgp::types::PublicKeyTrait;
use rand::distr::SampleString as _;
use rusqlite::OptionalExtension;
use serde::Serialize;
@@ -21,7 +22,7 @@ use crate::key::load_self_public_keyring;
use crate::log::LogExt;
use crate::message::{Message, Viewtype};
use crate::securejoin::QrInvite;
use crate::tools::{create_id, time};
use crate::tools::time;
pub(crate) const STATISTICS_BOT_EMAIL: &str = "self_reporting@testrun.org";
const STATISTICS_BOT_VCARD: &str = include_str!("../assets/statistics-bot.vcf");
@@ -390,7 +391,9 @@ pub(crate) async fn stats_id(context: &Context) -> Result<String> {
Ok(match context.get_config(Config::StatsId).await? {
Some(id) => id,
None => {
let id = create_id();
let id = rand::distr::Alphabetic
.sample_string(&mut rand::rng(), 25)
.to_lowercase();
context
.set_config_internal(Config::StatsId, Some(&id))
.await?;